Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad3e839b6c | ||
|
|
f9b2695dfc | ||
|
|
a63a7d22e3 | ||
|
|
1d6c2cc7f8 | ||
|
|
64b1e50d17 | ||
|
|
afb1ffaa3e | ||
|
|
8f56509c16 | ||
|
|
618e8e1f40 | ||
|
|
a1f066d361 | ||
|
|
c3f874418b | ||
|
|
f49288ddf1 | ||
|
|
78a5c09ea4 | ||
|
|
1ff31c188f | ||
|
|
fb380fb11d | ||
|
|
b67e1e3748 | ||
|
|
6f31bc3907 | ||
|
|
2205367925 | ||
|
|
5507ad48db | ||
|
|
2419059eea | ||
|
|
010649b1d1 | ||
|
|
058082824d | ||
|
|
7ba27c079f | ||
|
|
c1146bb76f | ||
|
|
f5f25e9f86 | ||
|
|
c546b567e0 | ||
|
|
ce14a7bd06 | ||
|
|
fa15a105fd | ||
|
|
e104dfd5fe | ||
|
|
2b2c500432 | ||
|
|
691456552d | ||
|
|
785b4dce50 | ||
|
|
39f71ab266 | ||
|
|
06bf067fdb | ||
|
|
f3197ffc53 | ||
|
|
ddc3dd4262 | ||
|
|
d4f0a93420 | ||
|
|
8ff00da0b4 | ||
|
|
7fd52c9a62 | ||
|
|
91354cb45d | ||
|
|
f39814cd95 | ||
|
|
e9e6677756 | ||
|
|
167a53e897 | ||
|
|
0598f4d8e2 | ||
|
|
a2daf3aa02 | ||
|
|
b41a8f666f | ||
|
|
31bdf0e39c | ||
|
|
5731e2df68 | ||
|
|
04244196bb | ||
|
|
28b9816162 | ||
|
|
a7d38b39dc | ||
|
|
804b0a8598 | ||
|
|
804e3b6fa6 | ||
|
|
1525645ace | ||
|
|
d49f375a13 | ||
|
|
0f8b5500c2 | ||
|
|
9c349bb84d | ||
|
|
5d2aa9840a | ||
|
|
204a507de7 | ||
|
|
4e167082ac | ||
|
|
ee5c0ea7bc | ||
|
|
9311909a58 | ||
|
|
ec42d89f66 | ||
|
|
c00da29f06 | ||
|
|
66b35c3a6c | ||
|
|
1a27fa0a1a | ||
|
|
8998863bb0 | ||
|
|
94443dcd35 | ||
|
|
d3f4aeef14 | ||
|
|
4bbaed7b0c | ||
|
|
b55bd5175f | ||
|
|
d1b951e9ea | ||
|
|
e2ec141d75 | ||
|
|
90c12cb070 | ||
|
|
88adce152b | ||
|
|
9e3f6327b7 | ||
|
|
8cdd0f61df | ||
|
|
2d1c9fa478 | ||
|
|
a71299803f | ||
|
|
fcf72da9db | ||
|
|
616d2f4f97 | ||
|
|
be49c53940 | ||
|
|
6cd736bec1 | ||
|
|
0d2eee7416 | ||
|
|
c7899e72a2 | ||
|
|
4e6c5a357e | ||
|
|
181af150e5 | ||
|
|
d89b99db6c | ||
|
|
32e0d3c8d9 | ||
|
|
21691d3756 | ||
|
|
ca0563039d | ||
|
|
a682ffccdd | ||
|
|
68632db6ef | ||
|
|
297c412488 | ||
|
|
ad4495f220 | ||
|
|
4dddcdfe1a | ||
|
|
965f1adc86 | ||
|
|
96072a2f75 | ||
|
|
8c8e476cb7 | ||
|
|
22810df4c1 | ||
|
|
87fb665e9e | ||
|
|
470fe6a7e7 | ||
|
|
403d652c6d | ||
|
|
e8a6ddca71 | ||
|
|
c9643cd79b | ||
|
|
29bb636cb5 | ||
|
|
89bd7c3b7e | ||
|
|
4a3f8de679 | ||
|
|
7f7de48ecc | ||
|
|
402ffe4586 | ||
|
|
177f1f7d90 | ||
|
|
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 | ||
|
|
2c8958da01 | ||
|
|
f2c945838c | ||
|
|
0e0f3a09f1 | ||
|
|
3d183fdd7c | ||
|
|
32bb4057ec | ||
|
|
90a9cbd476 | ||
|
|
5bd76b80e8 | ||
|
|
a1d71d1c73 | ||
|
|
e287fa5ebe | ||
|
|
534f5ca350 | ||
|
|
1e99c4c747 | ||
|
|
5e5f61f3b7 | ||
|
|
dec9251e7b | ||
|
|
d4e2ab494c | ||
|
|
845903a772 | ||
|
|
998de62df2 | ||
|
|
e449c194ef | ||
|
|
6cea767dec | ||
|
|
60ae97c771 | ||
|
|
d8587b8e93 | ||
|
|
695a12dc2d | ||
|
|
6385e38b33 | ||
|
|
0b5a85f461 | ||
|
|
fd9311c482 | ||
|
|
39294adcbc | ||
|
|
fee18ebc59 | ||
|
|
51f5ff085c | ||
|
|
6d4204fb22 | ||
|
|
185489fa18 | ||
|
|
41c2c79797 | ||
|
|
6dd6c3c69f | ||
|
|
9468781218 | ||
|
|
b39352dbff | ||
|
|
0d263637df | ||
|
|
bb6e222101 | ||
|
|
85ed3b5587 | ||
|
|
68d692fbe8 | ||
|
|
fa664e5147 | ||
|
|
b5c99621b5 | ||
|
|
db52b9e812 | ||
|
|
319e7a31cc | ||
|
|
25011d0f27 | ||
|
|
7733e86cfd | ||
|
|
a481f5dcbf | ||
|
|
bd6519b65e |
@@ -0,0 +1,14 @@
|
||||
# Fix: whisper.cpp (whisper-rs-sys) is configured by the cmake crate, which on
|
||||
# MSVC *without an explicit generator* overwrites CMAKE_C_FLAGS_RELEASE with
|
||||
# unoptimized flags (it also strips any -O*/O* flag from CFLAGS, so env
|
||||
# optimization flags can't survive). Result: ggml's CPU kernels compiled at
|
||||
# /Od — CPU-only transcription ran ~40x slower than real time (bench finding
|
||||
# W1, docs/benchmarks/2026-07-parakeet-vs-whisper.md). With a generator set,
|
||||
# cmake-rs skips that override (`if generator.is_none() && msvc` in
|
||||
# cmake-0.1.58) and CMake's default Release flags (/O2 /Ob2 /DNDEBUG) apply.
|
||||
# Ninja ships with VS Build Tools; builds already require vcvars64 (which puts
|
||||
# it on PATH). NDEBUG kept explicitly so C deps never build with asserts on.
|
||||
[env]
|
||||
CMAKE_GENERATOR = "Ninja"
|
||||
CFLAGS = "/DNDEBUG"
|
||||
CXXFLAGS = "/DNDEBUG"
|
||||
@@ -46,3 +46,7 @@ packaging/npu-runtime/*.zip
|
||||
# --features vulkan build (bundled into the installer); a redistributable blob,
|
||||
# not committed.
|
||||
src-tauri/vulkan-1.dll
|
||||
MEMORY.md
|
||||
# ASR bench corpus (test_parakeet branch): LibriSpeech-derived WAVs + refs,
|
||||
# staged by scripts/bench/fetch-corpus.ps1 — audio never enters the repo.
|
||||
bench-corpus/
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
# Memory — whispassist
|
||||
|
||||
> Generated: 2026-07-07 21:30:45
|
||||
> Total memories: **75**
|
||||
> Breakdown: fact: 3, decision: 10, goal: 1, preference: 1, context: 3, event: 1, learning: 24, observation: 2, artifact: 25, error: 5
|
||||
|
||||
---
|
||||
|
||||
## Instructions
|
||||
|
||||
*Standing rules, constraints, and guidelines to always follow.*
|
||||
|
||||
*No memories of this type.*
|
||||
|
||||
---
|
||||
|
||||
## Facts
|
||||
|
||||
*Verified information, project status, and established truths.*
|
||||
|
||||
### parakeet-rs (altunenes) DOES have a genuine increm...
|
||||
|
||||
parakeet-rs (altunenes) DOES have a genuine incremental streaming decode API for Parakeet-family models: ParakeetEOU and Nemotron structs thread real recurrent state between calls internally (EncoderCache: cache_last_channel/cache_last_time/cache_last_channel_len; decoder LSTM state_h/state_c; last_token) plus a 4s rolling audio ring buffer, so callers just feed sequential small chunks (160ms for EOU, 560ms for Nemotron) and get incremental partial text -- it is not naive re-chunking of a batch decoder. Source: github.com/altunenes/parakeet-rs src/parakeet_eou.rs and model_eou.rs, examples/streaming.rs (checked 2026-07-02).
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-02T20:09:27*
|
||||
|
||||
### WhispAssist app icon/branding: real logo provided ...
|
||||
|
||||
WhispAssist app icon/branding: real logo provided by user (paperclip mascot + purple speech-bubble-with-sparkles mark, WhispAssist wordmark). The app icon (title bar/taskbar/tray/installer) is cropped from just the small speech-bubble-with-sparkles mark, not the full marketing graphic or the paperclip mascot (too much fine detail to read at 16-32px). Updated twice: first a transparent-background crop, then a refined version on its own gradient purple background from a cleaner logo revision the user provided. Regenerated via ; that command generates iOS/Android/Appx/macOS outputs by default which must be deleted since WhispAssist is Windows-only (not referenced by tauri.conf.json). tray.png is a manual 32x32 export, not one of tauri icon's own output names.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-02T20:13:49*
|
||||
|
||||
### The EOU streaming variant used by parakeet-rs is a...
|
||||
|
||||
The EOU streaming variant used by parakeet-rs is a genuinely separate ONNX export, not a mode of the batch Parakeet TDT model: it's NVIDIA's own nvidia/parakeet_realtime_eou_120m-v1 (120M params, cache-aware FastConformer encoder + LSTM decoder, 80-160ms chunks, English-only, no punctuation/casing, emits <EOU> token). This is distinct from istupakov/parakeet-tdt-0.6b-v3-onnx (600M, the community ONNX conversion used for WhispAssist's originally-considered batch/full-file transcription path, which has the ~4-5min length limit). Both are downloaded separately; author's own code comment on reset_on_eou says 'I must admit that this is not work very well on my real world tests'.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-02T20:09:30*
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
*Architectural choices, approach selections, and their rationale.*
|
||||
|
||||
### M4.4 (MS Graph calendar source, T8.9, FR-CAL-6) SH...
|
||||
|
||||
M4.4 (MS Graph calendar source, T8.9, FR-CAL-6) SHIPPED 2026-07-07 on branch feature_chore_bug_005 (commits 83cb30e..7df76df + 1cf0be1). This LIFTS the 2026-07-02 'T8.9 on hold' decision — user explicitly asked to build it in this session, overriding the earlier hold. Implementation: calendar::GraphSource implementing CalendarSource, reusing sync::oauth's provider-agnostic PKCE/loopback machinery (added a 'graph-calendar' OAuth provider entry, made sync::resolve_access_token pub(crate) for cross-module reuse) rather than building new OAuth plumbing. This completes all of milestone M4 (M4.1 chunked upload, M4.2 multi-language, M4.3 Dropbox/Box, M4.4 Graph calendar).
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-08T02:22:48*
|
||||
|
||||
### WhispAssist roadmap: T8.8 (at-rest encryption / va...
|
||||
|
||||
WhispAssist roadmap: T8.8 (at-rest encryption / vault) and T8.9 (Microsoft Graph calendar) are ON HOLD per user decision (2026-07-02). Consequence: Phase 9c (T9.12 client-side encryption before upload) is blocked since it depends on the T8.8 vault - skip 9c for now. Building Phase 9 (remote sync) starting with 9a (WebDAV).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-03T01:01:49*
|
||||
|
||||
### WhispAssist GPU acceleration decision (2026-07): w...
|
||||
|
||||
WhispAssist GPU acceleration decision (2026-07): whisper.cpp GPU transcription via VULKAN as primary cross-vendor path — ONE binary covers NVIDIA+AMD+Intel (whisper-rs 'vulkan' feature). whisper-rs GPU features: cuda (NVIDIA-only, CUDA toolkit at build), vulkan (cross-vendor, Vulkan SDK at build + vulkan-1.dll loader shipping with every GPU driver), hipblas (AMD/ROCm LINUX-ONLY, unusable on Windows), metal (Apple). KEY: whisper.cpp GPU backends are COMPILE-TIME/static (cannot download runtime on-demand like the ort load-dynamic NPU path); binary must be built with the feature. Vulkan is default GPU build; CUDA is optional NVIDIA-only turbo variant LATER (user: 'we will do cuda later'). Vulkan degrades to CPU if no GPU present.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-05T22:03:34*
|
||||
|
||||
### WhispAssist roadmap: T8.8 (at-rest encryption/vaul...
|
||||
|
||||
WhispAssist roadmap: T8.8 (at-rest encryption/vault) and T8.9 (MS Graph calendar) ON HOLD per user (2026-07-02). Consequence: Phase 9c (T9.12 client-side encryption) blocked (needs T8.8 vault) - skip for now. Building Phase 9 remote sync starting with 9a WebDAV.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-03T01:02:11*
|
||||
|
||||
### In WhispAssist's calendar module, CalendarSource::...
|
||||
|
||||
In WhispAssist's calendar module, CalendarSource::import() is a synchronous trait method (matches PstSource's blocking subprocess call). GraphSource (MS Graph, async reqwest HTTP) bridges into that sync signature via tauri::async_runtime::block_on inside fetch_events, and callers run the whole import() call inside tauri::async_runtime::spawn_blocking (same pattern commands.rs already used for PstSource's readpst subprocess) — avoids making CalendarSource async just for one source. Kept a separate WA_GRAPH_CALENDAR_BASE_URL env var (distinct from sync's WA_GRAPH_BASE_URL used by OneDriveTarget) so calendar and sync tests never race on the same process-global env var in cargo test.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-08T02:22:55*
|
||||
|
||||
### WhispAssist PST usability fixes (2026-07-06, commi...
|
||||
|
||||
WhispAssist PST usability fixes (2026-07-06, commits 00de9d3/9d757be/75ec24d): (1) calendar path is now remembered in Settings (pst_last_path field) so the user doesn't re-browse every launch; (2) added an opt-in 'auto-sync on launch' checkbox (pst_auto_sync) that re-imports the remembered path once at startup - deliberately a ONE-SHOT pass mirroring the existing sync-job-resume pattern in lib.rs, NOT a periodic timer, because NFR-RES-1 ('no polling timers when idle') is enforced consistently everywhere else in this codebase (every startup task has a comment noting this). User asked about a periodic 'read frequency' option too; I declined to build that specific piece and explained the NFR-RES-1 conflict rather than silently building or silently dropping it.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-06T20:40:52*
|
||||
|
||||
### Completed a full UI/UX redesign of WhispAssist: se...
|
||||
|
||||
Completed a full UI/UX redesign of WhispAssist: semantic CSS design-token system (light/dark, WCAG AA verified), all emoji/Unicode icons replaced with @lucide/svelte SVG icons, segmented Monitor/Sun/Moon theme toggle defaulting to system preference, custom theme-aware scrollbars. Informed by researching Granola and Meetily's UIs; kept WhispAssist's 3-pane layout since diarization/speaker-naming already beats both competitors.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-02T20:13:34*
|
||||
|
||||
### WhispAssist M1 feature-briefs decomposition (2026-...
|
||||
|
||||
WhispAssist M1 feature-briefs decomposition (2026-07-06, docs-only, branch feature_chore_bug_004). KEY INSIGHT: M1 is heavily scaffolded already, do NOT re-create — IPC types FeatureBrief/FeatureBriefInfo/ContextExcerpt (models.rs), api.ts bindings createFeatureBrief/listFeatureBriefs/getFeatureBrief/setBriefExposed, the 4 commands registered in lib.rs, DB tables feature_briefs + mcp_access_log (migrations/0003_ai_mcp.sql), the briefs/<id>.json schema (docs/03-data-model.md), and the FeatureBriefBuilder trait (docs/04-api-contracts.md) ALL EXIST; only the 4 command bodies return not_implemented(). REMAINING to build: (1) Store methods insert_feature_brief/list_feature_briefs/get_feature_brief_row/set_brief_exposed; (2) a new non-streaming LlmProvider::complete(system,user)->String primitive (mirrors suggest_tags; impl Ollama+OpenAiCompat, Anthropic later); (3) FeatureBriefBuilder in a new briefs module (strict '## Title/## Problem/## Desired Outcome/## Acceptance Criteria' prompt like RESPONSE_FORMAT_INSTRUCTIONS + parse_brief mirroring parse_summary); (4) the 4 command bodies (add state: State<AppState>, Tauri injects it, api.ts unchanged); (5) UI in SummaryPanel; (6) golden-transcript tests. KEY DESIGN DECISIONS: context_excerpts are VERBATIM transcript substrings (grounding invariant enforced by the golden test), selected by keyword overlap, NOT model paraphrase; the on-disk file is a sealed BriefFile envelope {schema,generated_at,provider,model,...fields,source} indexed by the feature_briefs table (file=truth, row=index); write file+row only AFTER a successful distill (no partial artifacts on LLM failure). Full implementation-ready checklist in docs/05-roadmap.md M1; JSON schema in docs/03-data-model.md; tests in docs/06-test-strategy.md P10.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-07T04:28:36*
|
||||
|
||||
### Decision: NOT pursuing NVIDIA Parakeet or DirectML...
|
||||
|
||||
Decision: NOT pursuing NVIDIA Parakeet or DirectML/NPU acceleration for WhispAssist transcription near-term, as of 2026-07-02. Researched achetronic/parakeet (Go, Linux-only, dead end) and altunenes/parakeet-rs (Rust, built on ort, has genuine streaming via a separate EOU 120M model with real internally-threaded encoder cache and LSTM decoder state). Reasons not to pursue now: (1) DirectML support for this model family is unproven - zero reports of anyone running it, and there is a live unresolved ONNX Runtime bug (microsoft/onnxruntime issue 19837) producing wrong output on DirectML for the exact LSTM+Einsum op combination these models use; (2) DirectML itself is now in Microsoft maintenance mode, with new NPU/GPU work moving to Windows ML instead, which calls WhispAssist's existing ADR-0004 (ort + DirectML for NPU) into question independent of Parakeet; (3) Parakeet streaming needs a continuous per-meeting state machine fed small sequential chunks, fundamentally incompatible with WhispAssist's current stateless independent 4-second-window architecture - a real rearchitecture, not a swap, and it would lose whisper.cpp's crash-recoverable-per-window property; (4) competitor Meetily does not actually do live Parakeet+hardware-acceleration either - they only use it for offline batch re-transcription. Recommended future path if revisited: CPU-only Parakeet-EOU streaming spike first to validate the rearchitecture and quality tradeoffs (EOU is English-only, no punctuation/capitalization), treat NPU acceleration as a separate track that should probably target Windows ML rather than raw DirectML.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-02T20:14:37*
|
||||
|
||||
### WhispAssist CUDA + AMD-non-Vulkan acceleration PLA...
|
||||
|
||||
WhispAssist CUDA + AMD-non-Vulkan acceleration PLAN (2026-07-05): whisper.cpp bakes in exactly ONE GPU backend at compile time. Universal build=Vulkan (all vendors). NEW: NVIDIA-turbo installer variant = whisper.cpp CUDA (feature already declared Cargo.toml:94) — but a CUDA build has NO Vulkan, so AMD/Intel GPUs in that variant need a non-Vulkan accel path. DECISION (recommended, user was away/didn't confirm): AMD-non-Vulkan = DirectML via the EXISTING ort/ONNX NPU path (OnnxNpuTranscriber generalized to accept EP: OpenVINO-NPU vs DirectML-GPU+device_id). DirectML works on any DX12 GPU, load-dynamic (no new build toolchain), one binary, degrades to CPU. hipBLAS/ROCm DEFERRED (narrow gfx coverage, fragile on Windows, needs 3rd installer). CORE CODE CHANGE: add AccelPath enum {WhisperCuda,WhisperVulkan,WhisperCpu,OnnxOpenVino,OnnxDirectML} + resolver in hardware/mod.rs from (BackendId+cfg!(feature)+runtime readiness); transcriber factory switches on it. This also CLOSES the known detection-honesty gap (GPU marked available from DXGI regardless of compiled feature -> no-op GPU routing). PHASES: P0 detection-honesty+AccelPath (small, no hw, do first), P2 AMD/Intel DirectML (validate on Intel Arc iGPU locally), P1 CUDA (needs NVIDIA hw/CI). ort needs 'directml' feature + an onnxruntime.dll built with DML EP in the runtime bundle; DirectML.dll ships with Win10 1903+.
|
||||
|
||||
*Confidence: 0.85 | Status: active | Created: 2026-07-05T23:28:58*
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
*Objectives, targets, and milestones to track progress.*
|
||||
|
||||
### WhispAssist NEXT STEPS — CPU transcription slownes...
|
||||
|
||||
WhispAssist NEXT STEPS — CPU transcription slowness testing round (open bug): the full transcribe_file path takes ~90s for a 6.3s clip on a 14-thread CPU with base.en (should be ~2-3s). This is PRE-EXISTING (predates Vulkan) and independent of the GPU work. To root-cause next: (a) verify whisper set_n_threads actually applies available_threads()=14 (log n_threads in run_full); (b) check whether the encoder pays full 1500 audio_ctx per 30s window even for a 6s clip in the non-streaming path (the streaming path fix in commit 8530a22 scaled audio_ctx by window length, but transcribe_file/run_full may not); (c) run stock whisper-cli directly on the same model+wav to isolate whether it's OUR run_full config vs whisper.cpp itself; (d) test greedy vs beam params and q5_1 vs f16 model; (e) confirm it's not thermal/throttle. The non-vulkan CPU baseline re-run (task, ~90s expected) was in progress to formally confirm parity with the vulkan build's CPU number.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-05T22:04:11*
|
||||
|
||||
---
|
||||
|
||||
## Commitments
|
||||
|
||||
*Promises, obligations, and TODOs that need follow-through.*
|
||||
|
||||
*No memories of this type.*
|
||||
|
||||
---
|
||||
|
||||
## Preferences
|
||||
|
||||
*User and entity preferences for personalization.*
|
||||
|
||||
### WhispAssist user working style (observed 2026-07-0...
|
||||
|
||||
WhispAssist user working style (observed 2026-07-06): (1) Demands EMPIRICAL PROOF over theory. When I attributed silent new recordings to the mic-not-in-WAV design, they pushed back ('the old file is also vault-sealed and plays fine, so it must be the 32->16bit change'). Resolving it required an actual real-hardware loopback capture test (play a known sound, read peak i16 from the WAV) to prove the 16-bit path records real audio — only then accept the diagnosis. HOW TO APPLY: when diagnosing a bug, verify the cause with a runnable test/measurement and show the evidence; don't just assert a root cause. (2) Highly protective of encryption-at-rest. They independently spotted that the decrypted audio.play.wav on disk and the browser 'download' button undermined the vault, and asked to switch playback to in-memory on-the-fly decryption. HOW TO APPLY: proactively avoid writing plaintext of vault-sealed data to disk and close off easy exfiltration paths (downloads, temp files).
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-07T01:05:30*
|
||||
|
||||
---
|
||||
|
||||
## Relationships
|
||||
|
||||
*Entity connections, team context, and collaboration patterns.*
|
||||
|
||||
*No memories of this type.*
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
*Session summaries, status updates, and conversation state.*
|
||||
|
||||
### WhispAssist release/versioning process (as of v0.2...
|
||||
|
||||
WhispAssist release/versioning process (as of v0.2.0, 2026-07-06): the version string lives in THREE files that must be bumped together — package.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml (Cargo.lock updates on build). The shipped UNIVERSAL installer is built with: npm run tauri build -- --features vulkan --config src-tauri/tauri.vulkan.conf.json, with env VULKAN_SDK=C:\VulkanSDK\1.4.350.0, CMAKE_GENERATOR=Ninja, CARGO_TARGET_DIR=C:\wt, and vcvars64.bat loaded from 'C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat'. Outputs land at C:\wt\release\bundle\msi\WhispAssist_<ver>_x64_en-US.msi and \nsis\WhispAssist_<ver>_x64-setup.exe (MSI ~44MB, NSIS ~9MB). The build does NOT create/push git tags — after merging to main, tag manually: git tag v<ver>; git push origin v<ver>. Last release before 0.2.0 was v0.1.6 (PR #15).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-07T01:05:16*
|
||||
|
||||
### Project status as of 2026-07-02: Phase 8 tasks T8....
|
||||
|
||||
Project status as of 2026-07-02: Phase 8 tasks T8.7 (multi-language transcription + i18n scaffold) and T8.8 (at-rest encryption/vault, needs an ADR decision on SQLCipher vs file-level encryption first) remain deferred/pending. User paused them to do a full UI/UX redesign, then a transcription-latency investigation and fix, then Parakeet/NPU research. Both T8.7 and T8.8 are still the next planned work whenever the user returns to the Phase 8 roadmap.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-02T20:14:43*
|
||||
|
||||
### WhispAssist remaining-work assessment + plan (2026...
|
||||
|
||||
WhispAssist remaining-work assessment + plan (2026-07-06, docs-only planning on branch feature_chore_bug_004, NOT built). As of v0.2.0, Phases 1-9 ship. The only real gap is Phase 10 (external AI + agent handoff, ADR-0011) plus a few polish items. STUBBED (commands return Err(not_implemented)): create/list/get_feature_brief, set_brief_exposed, mcp_status, set_mcp_enabled, set_mcp_scope, mcp_access_log, run_agent, create_issue_from_brief. SKELETON/ABSENT: mcp/mod.rs (trait + todo!() only), AnthropicProvider (returns 'not built yet'), MS Graph CalendarSource, multi-language transcription, DropboxTarget/BoxTarget (OAuth wired in sync/oauth.rs but no upload impl), chunked/resumable upload. BUILT already: OpenAiCompatProvider. The phased plan lives in docs/05-roadmap.md section 'Remaining work — post-v0.2.0 execution plan': M1 feature briefs (first; LLM-only, no egress) -> M2 MCP server (the differentiator; inbound loopback, zero added egress, FR-MCP-7 egress-unchanged is the merge gate) ; M3 hosted AI (parallel; finish Anthropic + hosted-key/banner/allowlist) ; M4 reliability/breadth (chunked upload is TOP item because recordings are now ~50-100MB and put() buffers whole file, OneDrive/Graph caps PUT at 250MB; then multi-language, Dropbox/Box) ; M5 push handoff (agent runner + issue tracker, Could-priority, last/optional). MS Graph calendar deferred (Could).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-07T04:28:28*
|
||||
|
||||
---
|
||||
|
||||
## Events
|
||||
|
||||
*Important conversations, milestones, and temporal occurrences.*
|
||||
|
||||
### WhispAssist reached v0.2.0 (2026-07-06), supersedi...
|
||||
|
||||
WhispAssist reached v0.2.0 (2026-07-06), superseding v0.1.6. Additions since 0.1.6: microphone capture (records+transcribes the user's voice, mixed into transcript AND the saved recording at native quality via MicBridge), in-app recording playback with in-memory on-the-fly decryption (waaudio:// custom protocol, no plaintext on disk, download disabled), AI-generated tags + chip tag editor + tag filtering, meeting rename (editable title), notes single-pane Editor/Preview toggle, cancel-a-recording, 16-bit half-size recordings, live per-item sync upload progress, audio output + microphone device pickers, Outlook .pst recurring-event import/filtering + auto-sync-on-launch, and a SINGLE universal Vulkan installer (bundled vulkan-1.dll) replacing the separate CPU/NPU vs Vulkan builds. Built on branch feature_chore_bug_003 — still needs merge to main + tag v0.2.0. Installers already produced at C:\wt\release\bundle.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-07T01:05:36*
|
||||
|
||||
---
|
||||
|
||||
## Learnings
|
||||
|
||||
*Knowledge acquired from experience, corrections, and insights.*
|
||||
|
||||
### Real libpst quirk found via a real 7.2GB mailbox t...
|
||||
|
||||
Real libpst quirk found via a real 7.2GB mailbox test (2026-07-06): this readpst/libpst build joins a multi-value RRULE BYDAY with semicolons instead of RFC 5545's commas, e.g. 'RRULE:FREQ=WEEKLY;COUNT=10;BYDAY=MO;TU;WE;TH;FR' - so TU/WE/TH/FR appear as bare semicolon-separated tokens with no '='. A naive RRULE parser using '?' on split_once('=') silently bails out (returns None) for any event with more than one weekday, which is why the first version of expand_rrule worked for single-BYDAY series but silently dropped recurrence for multi-weekday ones. Fix: track the last-seen key and attribute a bare (no '=') token to it as a continuation value. This joins other already-documented libpst 0.6.63 quirks in ADR-0008 (no ORGANIZER/ATTENDEE emitted, no -8 flag support, wrong -t usage string).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T20:40:50*
|
||||
|
||||
### WhispAssist WASAPI microphone finding (2026-07-06)...
|
||||
|
||||
WhispAssist WASAPI microphone finding (2026-07-06): unlike get_default_device(Render) which FAILS in cargo test on this dev machine, capturing the DEFAULT MIC (Direction::Capture) DOES work under cargo test AND delivers real frames (~3840 16kHz frames in 0.6s). Caveat: the FIRST COM activation of the mic can deliver 0 frames within the first ~600ms (cold start); a second run delivers normally. So a hardware mic smoke test should assert open+stop succeed (summary.sample_rate>0), not frames>0. Test: audio::tests::microphone_capture_opens_and_stops_cleanly (#[ignore], run with --ignored).
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-06T21:34:39*
|
||||
|
||||
### CONFIRMED FIX for WhispAssist release build crashe...
|
||||
|
||||
CONFIRMED FIX for WhispAssist release build crashes: Windows Defender real-time scanning of src-tauri/target was corrupting rustc.exe's compilation (random STATUS_STACK_BUFFER_OVERRUN crashes on different crates each run). Adding Defender exclusions (Add-MpPreference -ExclusionPath for target/, ~/.cargo, ~/.rustup, and -ExclusionProcess for rustc.exe/cargo.exe) fixed it completely -- full release build (whisper-rs, sherpa-rs native deps, MSI+NSIS bundling) now succeeds cleanly with the project's normal aggressive release profile (opt-level=z, codegen-units=1, lto=true). No toolchain reinstall or profile change was needed after all; those were red herrings from earlier in the debugging session. Also: a leftover running whispassist.exe instance can block cargo from overwriting the binary with 'Access is denied' -- close it before rebuilding.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-01T21:09:24*
|
||||
|
||||
### Dark-mode bug pattern learned: CSS custom properti...
|
||||
|
||||
Dark-mode bug pattern learned: CSS custom properties don't cascade upward to ancestor elements. If data-theme (or similar theme attribute) is only set on an inner .app div, html/body keep the browser's default white background + 8px UA-stylesheet margin, invisible in light mode but a bright white border around the whole window in dark mode. Fix: mirror the theme attribute onto document.documentElement via an effect, and add a global html/body margin:0 + background:var(--bg) rule.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-02T20:13:41*
|
||||
|
||||
### WhispAssist NPU spike (T3.4): Intel AI Boost NPU (...
|
||||
|
||||
WhispAssist NPU spike (T3.4): Intel AI Boost NPU (Core Ultra 5 135U, Meteor Lake, PCI VEN_8086&DEV_7D1D) runs the Whisper base.en ONNX encoder via ONNX Runtime OpenVINO EP (device_type=NPU) at ~66 ms/window vs ~236 ms/window on CPU = 3.58x faster, correct output shape (1,1500,512), full op coverage. Validates the plan: offload the fixed-shape Whisper encoder to the NPU, keep the dynamic decoder on CPU.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-02T20:31:08*
|
||||
|
||||
### WhispAssist: an OAuth-linked calendar source (MS G...
|
||||
|
||||
WhispAssist: an OAuth-linked calendar source (MS Graph) is stored in Settings (settings.json: graph_calendar_enabled + graph_calendar_credential_ref) rather than as a sync_targets DB row, because it is not a SyncTarget/upload destination — reusing add_sync_target's OAuth flow would have wrongly surfaced it in the Sync UI and risked the upload pump trying to build a SyncTarget for it. begin_graph_calendar_link is a separate command from begin_oauth_link for this reason, duplicating ~60 lines of PKCE handshake rather than sharing it, since the two flows diverge in storage/eventing.
|
||||
|
||||
*Confidence: 0.85 | Status: active | Created: 2026-07-08T02:23:03*
|
||||
|
||||
### cargo fmt -- <specific files> does not scope forma...
|
||||
|
||||
cargo fmt -- <specific files> does not scope formatting to those files in the WhispAssist repo (src-tauri) — it reformats the entire crate regardless of file args passed after --, pulling in unrelated pre-existing drift in untouched files (observed: src/audio/mod.rs). After running cargo fmt scoped to touched files, always git status/diff to catch and git checkout -- any unrelated files it touched before committing. Also: memanto's on-prem backend (localhost:8080) needs Ollama (localhost:11434, embedding model nomic-embed-text) running for recall/export/sync to work, and the active agent session (memanto agent activate whispassist) can expire/drop mid-session — 'remember' can report success even when the write doesn't actually persist/index, so verify with 'memanto recall --recent' after a batch of remember calls rather than trusting the success message alone.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-08T02:23:10*
|
||||
|
||||
### WhispAssist runtime bundle URLs CORRECTED to gitea...
|
||||
|
||||
WhispAssist runtime bundle URLs CORRECTED to gitea /media/ path (2026-07-06, commit 224aaf9): the .7z runtime bundles are hosted via Git LFS, and gitea's /raw/ endpoint returns the LFS POINTER (text) not the file, so the download URLs were changed from /raw/branch/main/ to /media/branch/main/ (gitea's media endpoint resolves LFS objects). Final URLs: NPU=https://git.dou.bet/iamdoubz/WhispAssist/media/branch/main/runtime/openvino.7z, DirectML=https://git.dou.bet/iamdoubz/WhispAssist/media/branch/main/runtime/directml.7z. SHAs unchanged (openvino ca0be9fc..., directml 34369222...). Files tracked via Git LFS (.gitattributes: runtime/*.7z filter=lfs). Still on branch chore_debug (no PR yet); URLs point at main so they resolve after merge. GOTCHA for future: GitHub raw.githubusercontent AND gitea /raw/ both serve LFS pointers not content — always use gitea /media/ (or GitHub media/LFS URL) for LFS-backed download targets.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T14:45:20*
|
||||
|
||||
### cargo test in WhispAssist src-tauri reliably crash...
|
||||
|
||||
cargo test in WhispAssist src-tauri reliably crashes at LINK time (rustc.exe exit 0xc0000409 STATUS_STACK_BUFFER_OVERRUN) when linking the full debug test binary against whisper.cpp+sherpa-onnx native libs with full debug info (debuginfo=2 default for test profile). This reproduces even from a clean target/debug, with reduced --jobs, regardless of Defender exclusions (which fixed the earlier release-build crashes but not this). FIX: set env var CARGO_PROFILE_TEST_DEBUG=0 (drop debug info) before cargo test -- this shrinks the PDB/link footprint enough to avoid the crash. Confirmed working: 'cargo test privacy_self_check' passed cleanly with CARGO_PROFILE_TEST_DEBUG=0 --jobs 4. Also noted: commands.rs is NOT feature-gated for cpu-transcription/diarization (unconditionally imports SherpaDiarizer/WhisperTranscriber/run_streaming_worker), so cargo test --no-default-features fails to compile -- can't lighten the native-link footprint that way, must use the debug-info trick instead.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-01T22:01:31*
|
||||
|
||||
### WhispAssist dark-theme native-control gotcha (2026...
|
||||
|
||||
WhispAssist dark-theme native-control gotcha (2026-07-06, commits 4442f80/8038918): the app theme is a MANUAL toggle (data-theme on html + .app), independent of the OS prefers-color-scheme. Native form controls default to LIGHT and render bright-white in dark mode. Fixes applied: (1) added CSS 'color-scheme: light' on :global(:root) and 'color-scheme: dark' on :global([data-theme="dark"]) in App.svelte — this alone fixed unstyled controls. (2) A <textarea> was white because Settings.svelte's 'input, select { background:var(--bg); color:var(--fg) }' rule EXCLUDED textarea; fix = add textarea to that selector. (3) The header template <select> (.theme-select) options popup stayed WHITE even with color-scheme:dark because it had 'background: transparent' — an AUTHOR-styled select makes Chromium/WebView2 render its options popup in light unless the options carry their own colors. Fix = give .theme-select an explicit 'background: var(--bg-elevated)' AND style '.theme-select option { background: var(--bg-elevated); color: var(--fg) }'. LESSON: for dark-mode selects, set color-scheme on the root AND author the <option> background/color with theme tokens; don't rely on transparent backgrounds.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-06T13:02:41*
|
||||
|
||||
### WhispAssist test-environment quirk found 2026-07-0...
|
||||
|
||||
WhispAssist test-environment quirk found 2026-07-06: wasapi::get_default_device(&Direction::Render) (and thus find_render_device(None)) FAILS when called from within 'cargo test' on this dev machine, even though the real desktop app (running as a normal foreground GUI process) resolves it fine. Enumeration itself (DeviceCollection) works fine in both contexts -- it's specifically the 'default device' role query that needs a real interactive audio session. Tests for this were written to assert consistency (find_render_device(None) vs a raw wasapi::get_default_device call, or unknown-id fallback vs None) rather than assuming a default device is resolvable, so they pass in both environments. Implication: don't trust 'cargo test' alone to validate anything touching wasapi's default-device APIs -- verify audio-device-selection behavior via the actual running app instead.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-06T21:02:54*
|
||||
|
||||
### WhispAssist NPU Rust recipe (T3.4, validated): ort...
|
||||
|
||||
WhispAssist NPU Rust recipe (T3.4, validated): ort crate v2.0.0-rc.10 with features [load-dynamic, openvino] links against Intel's pip onnxruntime-openvino DLLs and runs the Whisper base.en encoder on the Intel NPU at 65 ms/window (matches Python; CPU is 236ms). Recipe: (1) ORT_DYLIB_PATH -> site-packages/onnxruntime/capi/onnxruntime.dll (the OpenVINO-enabled ORT build); (2) prepend BOTH openvino/libs AND onnxruntime/capi to PATH so dependent DLLs (openvino.dll, onnxruntime_providers_openvino.dll, onnxruntime_providers_shared.dll) resolve; (3) OpenVINOExecutionProvider::default().with_device_type("NPU").build().error_on_failure() to make a failed NPU registration LOUD instead of silently falling back to CPU; (4) Session::run needs &mut session. For shipping, bundle these DLLs with the Tauri app (resource/sidecar) instead of relying on pip. load-dynamic means no C++/OpenVINO build step in cargo.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-02T20:42:31*
|
||||
|
||||
### DirectML support in parakeet-rs for NVIDIA Parakee...
|
||||
|
||||
DirectML support in parakeet-rs for NVIDIA Parakeet/EOU/Nemotron models is purely theoretical/unvalidated, not a proven working combination. Evidence: (1) parakeet-rs's own Cargo.toml/execution.rs just forwards to ort's generic directml feature with zero model-specific notes -- contrast with its explicit CoreML warning ('CoreML EP currently runs slower than CPU for Sortformer/Parakeet models because the ONNX graphs have dynamic input shapes'); no equivalent DirectML note exists. (2) Searched all 113 issues in altunenes/parakeet-rs GitHub repo: zero mention DirectML. (3) microsoft/onnxruntime issue #19837 (opened 2024, still unresolved as of check) reports DirectML EP producing wrong numeric results on a model containing LSTM+Einsum ops -- root cause never found. (4) Microsoft's own microsoft/DirectML GitHub repo now carries a banner: DirectML is in maintenance/sustained-engineering mode, with new feature development moved to Windows ML (WinML); relevant since WhispAssist ADR-0004 specifies ort+DirectML for NPU accel. Recommend flagging ADR-0004 for review given this shift.
|
||||
|
||||
*Confidence: 0.85 | Status: active | Created: 2026-07-02T20:09:34*
|
||||
|
||||
### Rebuilding WhispAssist release binary after Phase ...
|
||||
|
||||
Rebuilding WhispAssist release binary after Phase 6: initial 'tauri build' failed with 'only metadata stub found for rlib dependency core' / cannot find crate for std,num_traits (whisper-rs-sys build script, atoi). Root cause: stale/corrupted 19GB target/ dir from a prior interrupted build. Fix: cargo clean in src-tauri, then rebuild clean. Always load vcvars64.bat (VS2022 BuildTools) before cargo/tauri build.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-01T20:27:51*
|
||||
|
||||
### Root cause of WhispAssist release build crashes fo...
|
||||
|
||||
Root cause of WhispAssist release build crashes found: NOT toolchain corruption. rustc.exe crashes with STATUS_STACK_BUFFER_OVERRUN (0xc0000409) specifically compiling the windows-rs crate (v0.61.3) and pxfm crate, reproducible under both rustc 1.94.1 and 1.96.1. Root cause is the project's aggressive release profile in src-tauri/Cargo.toml: opt-level='z' + codegen-units=1 + lto=true triggers an LLVM/rustc codegen crash on these large generated crates. Confirmed fix: overriding just opt-level=2, codegen-units=16 via CARGO_PROFILE_RELEASE_OPT_LEVEL/CARGO_PROFILE_RELEASE_CODEGEN_UNITS env vars lets the windows crate compile cleanly in isolation. Earlier 'toolchain corruption' and 'cargo clean' theories were red herrings -- the missing-std/core-prelude errors seen on other crates were a cascade effect of cargo continuing after the crashed crate's .rlib was never written.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-01T20:40:50*
|
||||
|
||||
### WhispAssist recording fix + diagnosis (2026-07-06,...
|
||||
|
||||
WhispAssist recording fix + diagnosis (2026-07-06, branch feature_chore_bug_003): User reported new recordings had 'no sound' while old ones played. DIAGNOSED: NOT the 32->16bit change. Proved via ignored hardware test loopback_16bit_wav_captures_played_audio (played Windows Alarm01.wav through default device, captured peak i16=11670) that 16-bit loopback capture records real audio fine. Root cause: the recorded WAV was loopback-only, so a mic-only moment (user talking, nothing playing through speakers) recorded as silence while their voice still reached the transcript. FIX (user chose native-quality): added audio::MicBridge (AtomicU32 rate + Mutex<VecDeque<f32>>, cap ~0.5s for clock-drift): loopback thread publishes its rate + pulls mic samples per-frame and mixes into every channel in write_wav_bytes(mic:&[f32]); mic thread resamples its audio to loopback rate (Resampler::new_to) and pushes to the bridge. New WasapiCapture::start_loopback_recording / start_microphone_recording; commands.rs uses them with a shared bridge when mic enabled. Recording is now native rate/stereo 16-bit WITH the user's voice. Verified by ignored test loopback_recording_with_mic_bridge_captures_played_audio (peak i16=22358). This SUPERSEDES the earlier 'mic transcript-only, not in WAV' limitation.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T23:27:12*
|
||||
|
||||
### WhispAssist T3.4 steps 1-3 DONE & validated end-to...
|
||||
|
||||
WhispAssist T3.4 steps 1-3 DONE & validated end-to-end: OnnxNpuTranscriber transcribes speech correctly on the Intel NPU. Pipeline: hand-rolled Whisper log-mel (transcription/mel.rs, matches HF WhisperFeatureExtractor) -> NPU encoder (OpenVINO EP) -> CPU greedy decoder (no KV cache, re-feeds prefix) -> hand-rolled byte-BPE detok from tokenizer.json (no tokenizers crate). Behind cargo feature 'npu' = [dep:ort, dep:rustfft]; renamed from the old empty 'directml' feature. Decode config from generation_config.json: decoder_start=50257, eos=50256, forced_decoder_ids=[[1,50362]] (notimestamps); mask token ids >=50257 (except eot) in argmax. TTS test: spoke 'testing one two three four, the quick brown fox...' got 'testing 1234 the quick brown fox jumps over the lazy dog.' All gates green first try (clippy -D, fmt, 69 default tests, 77 npu tests). NOT YET wired into commands.rs dispatch (that is step 6) — nothing routes to it in-app yet.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-02T21:14:31*
|
||||
|
||||
### WhispAssist NPU build gotcha: ort's default downlo...
|
||||
|
||||
WhispAssist NPU build gotcha: ort's default download-binaries do NOT include the OpenVINO execution provider. Must supply an ONNX Runtime built with OpenVINO (Intel's prebuilt onnxruntime-openvino) PLUS the OpenVINO runtime DLLs on the DLL path. HARD VERSION PIN: onnxruntime-openvino 1.24.1 requires openvino runtime 2025.4.1 EXACTLY. Version mismatch (e.g. openvino 2026.2) does NOT error loudly — it silently falls back to CPUExecutionProvider (Win Error 127 'procedure could not be found'). Pin the ort<->onnxruntime<->openvino version triple and assert the active provider is OpenVINOExecutionProvider at load, else the NPU is silently unused.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-02T20:31:10*
|
||||
|
||||
### CONFIRMED ROOT CAUSE (2026-07-02) of the WhispAssi...
|
||||
|
||||
CONFIRMED ROOT CAUSE (2026-07-02) of the WhispAssist whisper.cpp model-load crash: it is Trend Micro Security Agent (Worry-Free Business Security, corporate-managed -- services ntrtscan/TMBMServer/TmCCSF/tmlisten all running) injecting into whispassist.exe and hooking file I/O / MessageBox APIs. Live cdb attach to the process while the 'Debug Assertion Failed: _osfile(fh) & FOPEN' dialog was showing revealed 'tmmon64' (Trend Micro's monitoring module) sitting directly in the call stack between USER32!MessageBoxW and ucrtbased!__acrt_MessageBoxW, and the read path (whisper.cpp's std::ifstream -> xsgetn -> fread) resolves into ucrtbased.dll (debug CRT) even though whisper-rs-sys's CMakeCache.txt confirms /MD (release CRT) was used to build it -- i.e. Trend Micro's hook is corrupting the CRT call path, not a real build misconfiguration. Ruled out first: NOT a stack-size issue (tried 16MiB worker thread stack, crash identical), NOT stale/corrupted build artifacts (crash reproduces identically from a fully clean cargo clean --profile dev rebuild), NOT Windows Defender (already excluded target/ and C:\Users\dadous\AppData\Local\WhispAssist, crash persisted). Fix requires excluding whispassist.exe / the WhispAssist install and model directories from Trend Micro's real-time scan and behavior monitoring -- likely needs corporate IT/policy admin involvement since TMBMServer implies tamper-protected central management, not a self-service local exclusion like Defender. Tooling note: installed WinDbg Preview via 'winget install --id Microsoft.WinDbg' -- ships cdbX64.exe (classic command-line debugger) alongside the modern WinDbgX.exe GUI, usable for live process attach analysis without needing the full Visual Studio IDE debugger.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-02T13:30:42*
|
||||
|
||||
### Fixed a real bug behind reported 30-65 second live...
|
||||
|
||||
Fixed a real bug behind reported 30-65 second live-transcription lag: whisper.cpp's encoder always runs over a full padded 30-second mel window (1500 encoder positions) unless audio_ctx is explicitly reduced via set_audio_ctx. WhispAssist's live-transcription streaming windows are only about 4 seconds each but were never setting audio_ctx, so every window paid the full 30-second-equivalent encode cost, serially, on one worker thread. Fixed in src-tauri/src/transcription/mod.rs (function audio_ctx_for_window) by scaling audio_ctx proportionally to the real window length (1500 positions = 30s, so a 4s window gets about 201). Committed as 8530a22. Verified about 30 percent faster in a controlled A/B benchmark, though that specific test ran under heavy CPU contention from Docker Desktop and other concurrent Claude Code sessions on this machine, which likely masks a larger real-world improvement since the fix targets the encoder O(n^2)-ish attention cost specifically.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-02T20:14:23*
|
||||
|
||||
### WhispAssist dev/test workflow gotchas (2026-07-06)...
|
||||
|
||||
WhispAssist dev/test workflow gotchas (2026-07-06): (1) A running 'npm run tauri dev' / whispassist.exe holds the cargo build lock on its target dir — STOP it (Stop-Process -Name whispassist, plus kill the node/cargo/vite procs whose CommandLine matches tauri|whispassist|vite) BEFORE running cargo build/clippy/test or the build blocks on the lock. (2) 'npm run tauri dev' writes output to the Windows console handle, NOT the redirected background-task log file (which stays empty) — confirm the app actually launched via Get-Process whispassist, not by reading the log; it typically appears ~30s after launch. (3) The audio-device tests find_render_device_none_matches_get_default_device and find_render_device_unknown_id_falls_back_exactly_like_none are FLAKY under cargo test (the wasapi get_default_device(Render) COM quirk) — a rerun passes; do NOT chase them as regressions. Loopback/mic hardware smoke tests (loopback_16bit_wav_captures_played_audio, etc.) are #[ignore]'d and run with --ignored.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-07T01:05:20*
|
||||
|
||||
### WhispAssist tooling FOOTGUN (2026-07-06): 'npm run...
|
||||
|
||||
WhispAssist tooling FOOTGUN (2026-07-06): 'npm run format' = 'prettier --write .' — it reformats the ENTIRE repo, not just changed files. Running it during a small change reformatted 120+ files (all docs/ADRs/.claude skills/CLAUDE.md/stores/etc) because the repo isn't uniformly prettier-clean, burying the real diff. RECOVERY that worked: git diff --name-only | grep out the intended KEEP files | xargs git checkout -- , then verify only intended files remain; the reformats were content+EOL noise (git diff -w showed EOL-only for many). LESSON: to format/verify only your touched files use 'npx prettier --write <files>' or just 'npm run check' (svelte-check) + 'npx eslint <files>' which don't write. ALSO: 'npm run lint' currently reports ~143 PRE-EXISTING errors (mostly 'console'/'process' is not defined no-undef in node-context files) unrelated to app code — don't be alarmed, they predate any given change. Repo has mixed LF/CRLF (git warns 'LF will be replaced by CRLF'); harmless line-ending churn.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-06T12:48:15*
|
||||
|
||||
### WhispAssist dev-run finding (2026-07-06): 'npm run...
|
||||
|
||||
WhispAssist dev-run finding (2026-07-06): 'npm run tauri dev' builds the Rust with features audio,cpu-transcription,diarization,pst,sync,npu = the DEFAULT set MINUS vulkan and cuda. Consequence: the everyday dev/default build has NEITHER a whisper.cpp GPU backend NOR CUDA, so on the Intel Core Ultra + Arc iGPU dev machine the ONLY GPU accel path is DirectML (via the ort/npu feature) — and directml_would_help() returns TRUE there, so the DirectML Settings card is visible. To exercise the Vulkan path you must build with --features vulkan explicitly (see whispassist-vulkan-build-recipe). Incremental dev rebuild ~42s once whisper.cpp/deps are cached; debug binary at src-tauri/target/debug/whispassist.exe. Launch recipe: load vcvars64.bat (VS2022 BuildTools) then 'npm run tauri dev'; the WebView2 window opens on the user's desktop. Opening Settings does NOT trigger the debug-CRT Abort/Retry/Ignore assertion dialog (that only fires on the transcription file-read path), so a UI-only visual check on the debug build is safe.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-06T12:27:54*
|
||||
|
||||
### Windows dev-loop gotchas confirmed again this sess...
|
||||
|
||||
Windows dev-loop gotchas confirmed again this session (2026-07-06): (1) Git Bash's quoting of 'cmd.exe /c "...vcvars64.bat" && cargo ...' silently no-ops (just opens/closes an interactive cmd shell) - must run that exact vcvars64.bat wrapper via the PowerShell tool instead, never Bash; (2) both 'cargo fmt' (no path args) and 'npm run format' (prettier --write .) reformat the ENTIRE repo/workspace, not just touched files - this touched unrelated pre-existing files (commands.rs WebDavTarget chain, all of docs/, .claude/skills/, package.json, CLAUDE.md, package-lock.json) and had to be reverted via targeted git checkout, keeping only the intended diff. Going forward: use 'rustfmt --edition 2021 <specific files>' and 'npx prettier --write <specific files>' instead of the whole-repo commands.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-06T20:40:55*
|
||||
|
||||
---
|
||||
|
||||
## Observations
|
||||
|
||||
*Patterns noticed, behavioral notes, and recurring themes.*
|
||||
|
||||
### WhispAssist transcription benchmark (2026-07, rele...
|
||||
|
||||
WhispAssist transcription benchmark (2026-07, release 0.1.4, SAME 6.3s TTS clip 'Testing 1234 the quick brown fox...', base.en q5_1 model, full transcribe_file path, machine=Intel Core Ultra 14-thread + Arc iGPU): VULKAN on Intel Arc iGPU = load 274ms, infer ~4.5s (fastest, edges out NPU). NPU OpenVINO = load 1355ms, infer ~5.7s. CPU whisper.cpp = load ~190ms, infer 92-99s (PATHOLOGICAL, ~15x SLOWER than real-time). All three produce the correct transcript. CRITICAL FINDING (user-confirmed): CPU was ALREADY ~90s BEFORE Vulkan was added — so the Vulkan build did NOT regress the CPU path; the 0.1.4 Vulkan build is SAFE to ship (CPU fallback unchanged). The ~90s CPU is a PRE-EXISTING bug in the full transcribe_file path, NOT caused by Vulkan. Vulkan and NPU are ~16-20x faster than the broken CPU path. Note the STREAMING path was already fixed earlier (audio_ctx scaling, commit 8530a22); transcribe_file (single_segment=false, 30s-padded seek loop) is the still-slow one.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-05T22:04:09*
|
||||
|
||||
### Meetily (Zackriya-Solutions/meetily), the competit...
|
||||
|
||||
Meetily (Zackriya-Solutions/meetily), the competitor app cited as prior art for Parakeet integration, uses Parakeet only for BATCH/offline transcription via the transcribe-rs crate (built on istupakov's ONNX conversion, per meetily's own README credits) -- not live streaming. Its 'Import & Enhance' feature (post-hoc re-transcription) is the actual use case; no DirectML-specific hardware acceleration for Parakeet is documented in its backend README. A true streaming fork (Nemotron streaming ASR engine) exists only as a community fork (Amitsurya2000/transcribe-rs), not upstream. sherpa-onnx (k2-fsa) also lacks true streaming Parakeet TDT support as of its open issues #2918 and #3573. Checked 2026-07-02, informs WhispAssist NPU/Parakeet research.
|
||||
|
||||
*Confidence: 0.85 | Status: active | Created: 2026-07-02T20:09:37*
|
||||
|
||||
---
|
||||
|
||||
## Artifacts
|
||||
|
||||
*Tool outputs, files, reports, and external references.*
|
||||
|
||||
### M4.1 (chunked/resumable upload, T9.2 refinement, F...
|
||||
|
||||
M4.1 (chunked/resumable upload, T9.2 refinement, FR-SYNC-2/5) SHIPPED 2026-07-07 on branch feature_chore_bug_005 (commits be025bb, 2cc48c9), same day as M4.2/M4.3/M4.4 but a separate prior session. Recordings are now native-quality (~50-100 MB) and the old put() buffered the whole file into memory in one PUT; OneDrive/Graph also caps a single PUT at 250 MB. Fixed by streaming disk-to-network in fixed-size chunks on both sync backends: WebDavTarget::put and OneDriveTarget::put now use tokio::fs::File + BufReader (O(chunk) memory, not O(file size)) for every upload. Files at/below LARGE_FILE_THRESHOLD (8 MiB) still take a single streamed PUT — only large artifacts (.wav recordings) take the chunked path. WebDAV (Nextcloud/ownCloud) implements the chunking-v2 protocol; OneDrive uses Graph's upload-session API. Needed enabling tokio's io-util/net features for AsyncReadExt/AsyncSeekExt/BufReader (separate chore commit be025bb).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-08T02:27:25*
|
||||
|
||||
### WhispAssist calendar/PST recurrence expansion (202...
|
||||
|
||||
WhispAssist calendar/PST recurrence expansion (2026-07-06, commits fa8a7f2/6f07f28): expand_rrule in calendar/mod.rs now parses RRULE (DAILY/WEEKLY/MONTHLY/YEARLY, INTERVAL/COUNT/UNTIL/BYDAY/BYMONTHDAY/BYMONTH) and expands each recurring PST event into its own stored row keyed by '{uid}@{ymd}' for dedup, instead of only storing the first occurrence. Rewritten on chrono::Local (promoted from transitive to direct dependency) instead of hand-rolled epoch-day math, because the first version had a real DST bug: it kept a fixed UTC time-of-day per occurrence, so a meeting created in winter (CST) drifted an hour once its weekly recurrence crossed into summer (CDT) - e.g. 16:30 UTC showed correctly as 10:30 in January but wrongly as 11:30 in July. Fix: convert dtstart to local wall-clock once, keep hour/min/sec fixed, re-resolve the UTC offset per occurrence date. Tests assert local wall-clock time is identical across all occurrences (would fail under the old code).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T20:40:48*
|
||||
|
||||
### WhispAssist audio-device-selection feature (2026-0...
|
||||
|
||||
WhispAssist audio-device-selection feature (2026-07-06, commits bb1d173/8e3e22c/2759a0e/5b28153/1376059/38b9cb2/b8dc5fc): added Settings > Hardware > 'Audio Devices' picker overriding the default 'Default system audio' WASAPI loopback render device. Backend: AudioCapture::start now takes device_id: Option<&str> (Device::get_id() string) instead of always resolving wasapi::get_default_device(&Direction::Render); new find_render_device() enumerates via wasapi::DeviceCollection and falls back to system default if the configured device is gone (same degrade-gracefully spirit as the existing mid-recording reconnect, which now retries the SAME selection first instead of switching to whatever's currently default). New list_render_devices()/list_audio_devices command (wasapi crate already supported enumeration, just was unused until now). Settings.audio_output_device: Option<String>, #[serde(default)] for backward compat. This is playback/render-device-only (loopback capture) -- WhispAssist has no microphone capture path at all (FR-CAP-1), so there is no separate 'input device' setting.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T21:02:52*
|
||||
|
||||
### WhispAssist M3 (hosted AI providers) SHIPPED (2026...
|
||||
|
||||
WhispAssist M3 (hosted AI providers) SHIPPED (2026-07-07, branch feature_chore_bug_005, merge commit e966181, on top of M1+M2 merge 2582d89). Built by an agent in an isolated git worktree, but that worktree's base snapshot was stale (pre-dated M1/M2 - based on 3038b9d, not the then-current feature_chore_bug_005 HEAD) - a real limitation of this session's worktree isolation mechanism worth remembering: it can silently reuse an earlier repo snapshot rather than the live current branch when a new worktree agent is launched later in the same session. Concretely this meant the M3 agent never saw M1's LlmProvider::complete() trait method addition, so AnthropicProvider::complete() was left as M1's not-yet-implemented stub even though M3 finished summarize/suggest_tags/status for real. Caught this by grepping the worktree for 'fn complete' before merging (found nothing) rather than trusting the agent's done report, then implemented AnthropicProvider::complete_with_key (non-streaming POST to /v1/messages, mirrors suggest_tags_with_key) myself as part of merge reconciliation. Also resolved three merge conflicts: llm/mod.rs (the complete() gap above), and two lucide-icon-import-list conflicts in Settings.svelte/SummaryPanel.svelte (M2 and M3 each added their own icon imports at the same insertion point) - unioned both, verified every icon is actually used before committing. What M3 built: AnthropicProvider real implementation (x-api-key + anthropic-version headers, SSE streaming for summarize, non-streaming for tags/complete), set_llm_provider storing the Anthropic key only in the OS credential store (never settings.json/DB, tested) and adding api.anthropic.com to the egress allowlist only when actually configured, HostedAiBanner.svelte one-time third-party-egress acknowledgment component, active-provider indicator + per-use quick-switch in SummaryPanel. Verified independently end to end on the fully merged M1+M2+M3 tree: cargo test --features mcp = 165 passed/0 failed, clippy clean (default + --features mcp), cargo fmt clean (only the same pre-existing unrelated audio/mod.rs drift as before), svelte-check 0 errors. Lesson for future milestone builds in this repo: after any worktree-isolated agent finishes, grep for the specific trait methods/functions the previous milestone added before trusting 'this builds on M<n-1>' claims - isolation snapshots can silently drift stale mid-session.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-07T13:16:21*
|
||||
|
||||
### WhispAssist meeting title + tags features (2026-07...
|
||||
|
||||
WhispAssist meeting title + tags features (2026-07-06, commits 2efe295/e68e888 and 68c32ff/16fcb15): (1) meetings previously had NO way to rename from 'Untitled meeting' anywhere in the UI - added an inline-editable title header above the transcript/notes pane (TranscriptNotes.svelte) backed by a new rename_meeting command; (2) attach_meeting_to_event now also mirrors the linked calendar event's subject onto the meeting's title when it has one, so linking 'Jerry / Daniel - Weekly 1:1' auto-renames the meeting; (3) added a 'Generate tags' feature mirroring 'Generate summary' - new LlmProvider::suggest_tags trait method (implemented for Ollama/OpenAI-compatible/Anthropic-stub), reads the transcript via the same build_prompt assembly, non-streamed, returns 1-8 tags merged into (not replacing) the existing tag list; (4) replaced the old comma-separated text-input tags UI with GitHub-topics-style removable/clickable chips (new shared TagChip.svelte component using existing --accent/--accent-soft tokens) - clicking a chip's label calls meetings.filterByTag() which filters the sidebar meeting list, an 'x' removes it.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T20:40:53*
|
||||
|
||||
### WhispAssist Phase 9a increment 1 DONE (branch feat...
|
||||
|
||||
WhispAssist Phase 9a increment 1 DONE (branch feature_another_one): WebDAV target management + connection test, validated end-to-end against a live wsgidav server (file physically uploaded via PROPFIND->MKCOL->PUT->HEAD). Added: storage SyncTargetRow CRUD (sqlx FromRow); sync::WebDavTarget real impl; sync::credentials keyring wrapper (service 'WhispAssist-sync', secret keyed by credential_ref, never in DB); TLS enforcement (enforce_transport: https always, http only for LAN+opt-in); commands list/add/update/remove/test_sync_target + set_sync_enabled (take State now); privacy_self_check wired to real targets. Enabled 'sync' in default cargo features. Anonymous targets supported (basic_auth only sent when a secret exists). All gates green: clippy -D, fmt, 74 tests. Local test server: python -m wsgidav.server.server_cli --host 127.0.0.1 --port 8899 --root <dir> --auth anonymous; test env WA_WEBDAV_URL/USER/PASS, run 'cargo test webdav_round_trip -- --ignored'. STILL TODO increment 2: durable queue + pump/backoff + sync_meeting + finalize-hook enqueue + sync://job events + sync_status/retry_sync_job (currently still not_implemented) + Settings sync UI. Increment 3: 9b OAuth (OneDrive/Dropbox/Box). 9c encryption on hold (needs T8.8 vault).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-03T01:17:19*
|
||||
|
||||
### M4.2 (multi-language transcription, T8.7, FR-TRX-4...
|
||||
|
||||
M4.2 (multi-language transcription, T8.7, FR-TRX-4) SHIPPED 2026-07-07 on branch feature_chore_bug_005 (large commit chain: 6bc0949 add multilingual/language fields to ModelInfo+Settings, 37a6dd1 multilingual model catalog entries, 7f88101 whisper language catalog for Settings dropdown, 496a8aa wire whisper language param through Transcriber trait, 66d9eff persist requested language at meeting creation, 75ca3df track resolved per-meeting language on RecordingSession, 7490ff5 wire language selection through recording/reprocess/recovery, 34fff3f register list_whisper_languages command, plus a full UI chain (4c00496/4892719/dc969df/e6e995e/7bff392/353d582) for a Settings language picker + per-meeting badge + reprocess override, and a same-day bugfix 40709b2 making resolve_language normalize case-insensitive 'en' matches so the English-only-model guard fires correctly). Delivers: multilingual model option, whisper language param (select/auto-detect), per-meeting language persisted, Settings dropdown + reprocess picker in the UI.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-08T02:27:34*
|
||||
|
||||
### WhispAssist Phase 9a increment 2 DONE: durable upl...
|
||||
|
||||
WhispAssist Phase 9a increment 2 DONE: durable upload queue (sync_jobs CRUD, pump w/ backoff, finalize hook, startup pump, sync://job events). 78 tests green. TODO: Upload-now UI. Increment 3: 9b OAuth; 9c on hold.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-03T02:57:46*
|
||||
|
||||
### WhispAssist in-memory recording playback (2026-07-...
|
||||
|
||||
WhispAssist in-memory recording playback (2026-07-06, branch feature_chore_bug_003): Replaced the file-based player (which decrypted vault-sealed audio.wav to a plaintext audio.play.wav on disk, weakening encryption-at-rest) with an in-memory custom Tauri protocol. commands::serve_recording backs a registered 'waaudio' uri scheme (URL http://waaudio.localhost/<meeting_id> on Windows): reads audio.wav, vault::open decrypts in RAM, streams audio/wav with Range support (parse_byte_range) for seeking; 404 no file, 403 sealed+locked, path-traversal guarded (id must be alnum/hyphen). recording_playback_path now returns that URL after a cheap sealed-prefix + is_unlocked precheck and deletes stale audio.play.wav. commands::cleanup_playback_temp() sweeps all meetings/*/audio.play.wav at startup (called in lib.rs setup). Removed assetProtocol config + tauri protocol-asset feature + convertFileSrc; CSP media-src now 'self' http://waaudio.localhost. UI <audio> has controlsList=nodownload noplaybackrate + oncontextmenu preventDefault so the decrypted audio can't be saved to disk. Verified: clippy clean, svelte-check clean, full build, startup sweep removed leftover play-temp files.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T23:41:52*
|
||||
|
||||
### WhispAssist runtime bundles switched to 7z from re...
|
||||
|
||||
WhispAssist runtime bundles switched to 7z from repo raw URLs (2026-07-06, branch chore_debug, commits 27ba4f9/e6f1916). The NPU (OpenVINO) and DirectML runtimes are now downloaded as .7z (LZMA2+BCJ) from gitea RAW paths: NPU=https://git.dou.bet/iamdoubz/WhispAssist/raw/branch/main/runtime/openvino.7z (SHA ca0be9fc52c78ee623b152f790450b3d4020c5a7ebe99d27736455b308782191), DirectML=https://git.dou.bet/iamdoubz/WhispAssist/raw/branch/main/runtime/directml.7z (SHA 34369222fcc1be2e72a957b868b1976a90150ba704a06c9e8992c34ee368926b). REPLACED the zip crate with sevenz-rust2 (pinned 0.7.0 — newer needs rustc>1.77 MSRV; optional + npu-gated). extract_zip_flat -> extract_7z_flat (uses decompress_file_with_extract_fn, flattens basenames — archives nest DLLs under directml/ and openvino/ folders). stage_directml_runtime now defaults to DIRECTML_RUNTIME_URL const (no longer requires WA_DIRECTML_RUNTIME_URL env). Env overrides WA_NPU_RUNTIME_URL / WA_DIRECTML_RUNTIME_URL still honored. sevenz-rust2 0.7.0 confirmed to decode LZMA2+BCJ (has src/bcj/x86.rs); validated by test extract_7z_flat_unpacks_the_directml_bundle (extracts ../runtime/directml.7z, asserts onnxruntime.dll == 17253408 bytes, flattened) — PASSES. clippy -D warnings clean on default/shipped build; 104+ tests green. NOTE: raw URLs point to branch/main, so they only resolve once runtime/openvino.7z + runtime/directml.7z are committed to MAIN. As of now those .7z files are UNTRACKED (left for the user to commit — plain git add vs Git LFS decision; ~27MB total). The old .zip package-registry URL (git.dou.bet/api/packages/.../npu-runtime/...) is retired. archives were created with 7z LZMA2:24m BCJ.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T13:26:14*
|
||||
|
||||
### WhispAssist M1 (feature briefs) + M2 (MCP server) ...
|
||||
|
||||
WhispAssist M1 (feature briefs) + M2 (MCP server) SHIPPED (2026-07-07, branch feature_chore_bug_005, merge commit 2582d89). Built in parallel by two agents in isolated git worktrees, then merged sequentially (M1 first, ff-merge; M2 second, conflict-merged). M1: Store methods for feature_briefs (insert/list/get_row/set_exposed), LlmProvider::complete() primitive (Ollama+OpenAI-compat), FeatureBriefBuilder distiller (briefs/mod.rs, keyword-overlap grounding), the 4 create/list/get_feature_brief+set_brief_exposed commands, SummaryPanel.svelte UI, golden-transcript tests. M2: mcp module on rmcp (loopback bind + token gate, Streamable HTTP + stdio adapter), 4 tools (list_recent_meetings/get_transcript/get_action_items/get_feature_brief), scope control (none|selected|all), mcp_status/set_mcp_enabled lifecycle (token in OS credential store), disclosure UI + mcp_access_log audit, privacy panel integration. Gated behind an optional mcp Cargo feature. Merge required resolving one real conflict (storage/mod.rs trait+impl interleaving) plus one real cross-branch integration bug: M2's handler.rs called commands::get_feature_brief(id) with the pre-M1 stub arity; fixed by extracting get_feature_brief_core(store, id) shared between the Tauri command and the MCP handler, and wired the previously-stubbed selected-scope exposed-flag check (scope::brief_visible) that M2 had left as a documented KNOWN GAP. Verified independently (not just trusting agent reports): cargo test --features mcp = 154 passed/0 failed, clippy clean (default + --features mcp), cargo fmt clean (only pre-existing unrelated audio/mod.rs drift), svelte-check 0 errors. Known environment gotcha hit repeatedly during verification: whisper-rs-sys native build intermittently fails with MSVC error C1056 'cannot update time date stamp' - Trend Micro AV interference, same class as the previously-recorded model-loading hang; resolved by retrying the build, not a code defect. Also: building in a deeply-nested git worktree path (.claude/worktrees/agent-id/...) can overflow Windows MAX_PATH during whisper.cpp's CMake TryCompile scratch dirs - work around with a short CARGO_TARGET_DIR (e.g. C:/wa-build-x) when testing worktrees directly. M2's one incomplete acceptance item: no compiled end-to-end MCP wire-protocol client test (blocked on an rmcp reqwest-0.13-vs-0.12 version conflict pulling in aws-lc-rs; agent reverted the attempt cleanly rather than leave it unverified) - unit-level loopback-bind-refusal and scope-enforcement tests substitute for now. Next: M3 (hosted AI providers, Anthropic) per user pre-authorization to skip the usage checkpoint and go straight to a scheduled resume.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-07T06:17:52*
|
||||
|
||||
### M4.3 (Dropbox/Box upload targets, T9.10, FR-SYNC-9...
|
||||
|
||||
M4.3 (Dropbox/Box upload targets, T9.10, FR-SYNC-9) SHIPPED 2026-07-07 on branch feature_chore_bug_005 (commit 6991c1d), same day as M4.1/M4.2/M4.4 but a separate prior session. Implements DropboxTarget and BoxTarget SyncTarget impls (OAuth PKCE was already wired for both providers). DropboxTarget: path-addressed like OneDrive/WebDAV; create_folder_v2 creates the whole intermediate path in one call; upload-session chunking above LARGE_FILE_THRESHOLD (start/append_v2/finish). BoxTarget: Box addresses items by numeric ID not path, so ensure_dir/exists/put all walk (and lazily create) the folder chain from root ('0') by listing each level's children; always uploads via Box's session API regardless of file size (its session API takes plain PUT bodies, consistent with every other target, needs no new reqwest feature, and Box computes/returns each part's digest so no local hashing needed). Both follow the same documented ceiling as OneDriveTarget's put_chunked (M4.1): the upload-session id lives only for one put() call, not persisted across process restarts — a crash mid-chunked-upload restarts that file's upload from scratch rather than resuming (unlike WebDAV's chunking-v2 which is genuinely resumable).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-08T02:27:46*
|
||||
|
||||
### WhispAssist T3.4 NPU acceleration COMPLETE (steps ...
|
||||
|
||||
WhispAssist T3.4 NPU acceleration COMPLETE (steps 1-6, feature_npu branch). Dispatch: commands.rs load_transcriber() routes BackendId::Npu -> OnnxNpuTranscriber (onnx model dir), else whisper.cpp, with graceful CPU fall-through; used by streaming worker and batch reprocess. run_streaming_worker now takes &dyn Transcriber (T: ?Sized). hardware_status returns npu:{present,runtimeReady,modelInstalled}. download_npu_package command + npu://download progress events; startup auto-fetches ONNX model in background if NPU present && model missing. Settings>Hardware shows NPU detected + download indicator. All gates green first try: fmt, clippy default+npu -D warnings, 69 default tests, 77 npu tests, svelte-check 0 errors, eslint. Real NPU inference re-verified post-refactor. KNOWN GAP: OpenVINO runtime staging (stage_npu_runtime) copies DLLs from local dirs in env WA_NPU_RUNTIME_SRC (';'-separated) not a hosted download - no hosted runtime bundle URL yet. Upgrade path: host versioned ORT+OpenVINO bundle, download+unzip into paths::npu_runtime_dir(). Repo frontend NOT prettier-clean (117 files pre-existing); only touched files formatted.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-02T22:17:13*
|
||||
|
||||
### WhispAssist merged single installer (2026-07-06, b...
|
||||
|
||||
WhispAssist merged single installer (2026-07-06, branch feature_chore_bug_003): Combined the two installers (13MB default/NPU-DirectML at src-tauri/target vs 67MB Vulkan at C:\wt) into ONE universal Vulkan installer. Verified facts: default exe 13MB imports no vulkan-1.dll (runs anywhere, GPU via runtime DirectML); Vulkan exe 67MB imports vulkan-1.dll at LOAD time (dumpbin) so it won't launch without the loader. Fix (option A = bundle the loader): build.rs stage_vulkan_loader() runs when CARGO_FEATURE_VULKAN set — copies vulkan-1.dll from %VULKAN_SDK%\Bin (fallback C:\Windows\System32) next to the exe (OUT_DIR ancestors nth(3) = target/<profile>, correct under CARGO_TARGET_DIR=C:\wt) AND into src-tauri/ (gitignored) for bundling. New src-tauri/tauri.vulkan.conf.json overlay adds bundle.resources ['vulkan-1.dll']. Release build cmd: npm run tauri build -- --features vulkan --config src-tauri/tauri.vulkan.conf.json (needs VULKAN_SDK, CMAKE_GENERATOR=Ninja, CARGO_TARGET_DIR=C:\wt). VERIFIED end-to-end: built MSI+NSIS 0.1.6; WiX main.wxs shows vulkan-1.dll as a Component in the same install dir as whispassist.exe + sibling DLLs (onnxruntime/sherpa/whispassist_lib), so the loader finds it. DirectML stays hidden when vulkan compiled (directml_would_help returns false). STILL TO TEST BY USER: launch the merged installer on a clean VM with NO vulkan-1.dll / no GPU driver to confirm graceful CPU fallback before retiring the 13MB build.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-07T00:06:25*
|
||||
|
||||
### WhispAssist 5-feature batch (2026-07-06, branch fe...
|
||||
|
||||
WhispAssist 5-feature batch (2026-07-06, branch feature_chore_bug_003): (1) Notes single-pane Editor/Preview toggle in TranscriptNotes.svelte (one button flips label Preview<->Editor). (2) Recording playback FR-REC-5: command recording_playback_path decrypts vault-sealed wav to audio.play.wav; enabled tauri protocol-asset feature + assetProtocol scope [$LOCALDATA/WhispAssist/meetings/**] + media-src CSP; SummaryPanel <audio controls> via convertFileSrc. (3) Smaller wav FR-CAP-8: wav_spec_for now forces 16-bit Int at native rate; write_wav_bytes quantizes f32->i16 via f32_to_i16 (clamp*i16::MAX) — halves 205MB->~103MB. Kept native rate (48k), did NOT resample to 44.1k (marginal, needs multichannel resampler in hot path). (4) Cancel recording FR-CAP-9: cancel_recording command stops loopback+mic, joins worker, store.delete_meeting (row+folder), emits recording://state state:cancelled; App.svelte Cancel button with confirm. (5) Live sync progress FR-SYNC-11: sync put() streams body via futures_util::stream::unfold + reqwest wrap_stream + Content-Length, sends incremental (sent,total); upload_job drains on a std thread (throttled 250ms) calling on_progress; pump_sync emits live sync://job; SummaryPanel <progress> bar. All clippy-clean, svelte-check clean, vite+full cargo build pass.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T22:57:29*
|
||||
|
||||
### WhispAssist 0.1.6 release build (2026-07-06): Vulk...
|
||||
|
||||
WhispAssist 0.1.6 release build (2026-07-06): Vulkan build (npm run tauri build -- --features vulkan; VULKAN_SDK=C:\VulkanSDK\1.4.350.0, CMAKE_GENERATOR=Ninja, CARGO_TARGET_DIR=C:\wt, use QUOTED 'set "VAR=val"' to avoid trailing-space bug). Headline fix vs 0.1.5: keyring windows-native (OS credential store was a no-op mock; sync/AI creds never persisted). Artifacts in C:\wt\release\bundle\: msi\WhispAssist_0.1.6_x64_en-US.msi (27MB, SHA256 ffbe32d9b53f2feaaa4b8a6a858b2f283cc5520b7e77814bc5cf7a41e04b5301), nsis\WhispAssist_0.1.6_x64-setup.exe (8.8MB, SHA256 669147d9578b2644b0838de346dda9ce7edd2b614a18f63ca5f61ffd64c6b526). SHA256SUMS.txt written to C:\wt\release\bundle\. Upload the .msi + -setup.exe + SHA256SUMS.txt; NOT the .7z runtime bundles (hosted in-repo via Git LFS, pulled from /media/branch/main/runtime/). 27MB MSI confirms Vulkan (CPU-only=11MB). Commits since 0.1.5: keyring fix 33c0397, version bump 6a4b832, plus sync-target-edit, Nextcloud server-URL auto-build, dark-theme fixes, About page, 7z runtime download.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T18:14:30*
|
||||
|
||||
### WhispAssist 0.1.5 shipped (2026-07-06, branch chor...
|
||||
|
||||
WhispAssist 0.1.5 shipped (2026-07-06, branch chore_debug): two UI fixes + version bump, 6 commits (035abb9..0d62416). (1) Settings panel horizontal-scroll/close-button-cutoff FIXED: the tab <nav> in Settings.svelte didn't wrap inside the fixed-width panel (width:min(720px,92vw)), overflowing and pushing the .close (X) button off-edge + adding a horizontal scrollbar. Fix: nav { flex:1 1 auto; min-width:0; flex-wrap:wrap }, header align-items:flex-start, .close flex:0 0 auto. (2) New ABOUT page (Settings ▸ About, Info icon tab): shows version (env!(CARGO_PKG_VERSION)) + build commit hash + a source link to https://git.dou.bet/iamdoubz/WhispAssist. Commit hash baked at build time via build.rs (git rev-parse --short HEAD -> cargo:rustc-env=WA_GIT_HASH, rerun-if-changed=../.git/logs/HEAD). Two new Tauri commands: app_info()->{version,commit}, open_url(url) (validates http(s), Windows-only via 'explorer <url>' — no shell injection, reuses installed toolchain instead of adding tauri-plugin-opener). api.ts got AppInfo type + appInfo()/openUrl() bindings. NOTE the section {#if}/{:else if} chain in Settings.svelte: privacy was the catch-all {:else} — adding an About branch required converting privacy to {:else if section==="privacy"} because {:else if} can't follow {:else}. Version bumped 0.1.4->0.1.5 in package.json, src-tauri/Cargo.toml, src-tauri/tauri.conf.json, Cargo.lock (package-lock.json tracks app version as 0.0.0, untouched). clippy -D warnings + svelte-check + eslint(my files) all clean.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T12:48:09*
|
||||
|
||||
### WhispAssist P2 DirectML GREEN end-to-end (2026-07-...
|
||||
|
||||
WhispAssist P2 DirectML GREEN end-to-end (2026-07-05): staged Microsoft.ML.OnnxRuntime.DirectML v1.24.1 (EXACT match to the OpenVINO bundle's ORT 1.24.1, from nuget flat-container api.nuget.org/v3-flatcontainer/microsoft.ml.onnxruntime.directml/1.24.1/...nupkg) into %LOCALAPPDATA%/WhispAssist/runtime/directml/ = onnxruntime.dll (17MB, DML EP baked in) + onnxruntime_providers_shared.dll. DirectML.dll NOT in the nuget — the Win11 System32 DirectML.dll (v1.15.5) satisfied it. Ran directml_transcribes_speech spike (no ORT_DYLIB_PATH; ensure_runtime_env pointed ort at runtime/directml/onnxruntime.dll + prepended its dir to PATH): backend=Intel (Arc iGPU via DirectMLExecutionProvider device_id 0), load=1222ms, infer=453ms, transcript='(gentle music)' (test wav was music; non-empty => PASS). So the full P0+P2 DirectML path is proven working on real GPU hardware, infer time on par with Vulkan/NPU. Repro: nuget version MUST be >= the ORT the OpenVINO bundle ships (1.24.x) for ABI/symbol match with ort rc.10. Older DirectML nugets (1.20-1.23) would risk GetProcAddress misses.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T02:30:59*
|
||||
|
||||
### WhispAssist sync-target EDIT feature added (2026-0...
|
||||
|
||||
WhispAssist sync-target EDIT feature added (2026-07-06, commits 149f2c9/45b2acb/496a337/fb79009). Root cause of the user's Nextcloud auth failure: the saved target's base_url was missing the /remote.php/dav/files/<user>/ path (they entered just the host) -> PROPFIND hit a non-DAV path -> 401. Credentials + app password were CORRECT (verified via curl PROPFIND returning 207 + X-User-Id). The app had NO way to edit a saved target — only add/delete/toggle-enabled — so they couldn't fix the URL. FIX: the backend update_sync_target command + store + api.updateSyncTarget ALREADY existed (was only used by the enable toggle); added the missing UI. Changes: (1) SyncTargetInfo (models.rs + api.ts) gained upload_transcript/notes/summary/recording, trigger_on_finalize, allow_plaintext_lan, encrypt_before_upload + row_to_info populates them (secret still never exposed, FR-SYNC-6); (2) settings.svelte.ts store.updateTarget(); (3) Settings.svelte: per-webdav-target Edit button -> startEdit() loads it into the add-form (secret blank), heading/submit become 'Edit target'/'Save changes', kind tabs hidden during edit, Cancel button. Password left blank on save = keep stored (update only rotates the credential when a non-empty secret is provided). Test-connection in edit mode tests the TYPED values (form has no id) so it needs the password re-entered; the simpler fix-and-save path preserves the secret. Correct Nextcloud WebDAV base_url = https://HOST/remote.php/dav/files/USERNAME/ . clippy + svelte-check + eslint all clean.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T15:41:28*
|
||||
|
||||
### WhispAssist 0.1.5 release build (2026-07-06, first...
|
||||
|
||||
WhispAssist 0.1.5 release build (2026-07-06, first public release): Vulkan build via 'npm run tauri build -- --features vulkan' with env VULKAN_SDK=C:\VulkanSDK\1.4.350.0, CMAKE_GENERATOR=Ninja (ninja 1.10.2 at C:\Tools\Standalone), CARGO_TARGET_DIR=C:\wt, vcvars64 loaded. GOTCHA that failed the first attempt: cmd 'set CARGO_TARGET_DIR=C:\wt && ...' captured a TRAILING SPACE (C:\wt ) -> 'failed to create directory C:\wt \release'; fix = quoted set: set "CARGO_TARGET_DIR=C:\wt". Artifacts in C:\wt\release\bundle\: msi\WhispAssist_0.1.5_x64_en-US.msi (27MB, SHA256 ed83ad0001c654221f3e5787088d922a1211d2722acbd5c2b4523f4f98c745e6), nsis\WhispAssist_0.1.5_x64-setup.exe (8.8MB, SHA256 dfa3a3acf7e9fdfe6d104527d55f660f0b9c0c1364da0cf665113aad60c61422). SHA256SUMS.txt written to C:\wt\release\bundle\. 27MB MSI size confirms Vulkan (CPU-only was 11MB). Release page should upload: the .msi, the -setup.exe, and SHA256SUMS.txt — NOT the .7z runtime bundles (those are hosted in-repo via Git LFS, pulled on-demand from /media/branch/main/runtime/).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T15:03:01*
|
||||
|
||||
### WhispAssist microphone-capture feature FR-CAP-7 (2...
|
||||
|
||||
WhispAssist microphone-capture feature FR-CAP-7 (2026-07-06, branch feature_chore_bug_002): WA now optionally captures the user's mic alongside loopback and mixes both 16kHz-mono streams into the single transcription worker via audio::spawn_mixer (Mixer struct sums+clamps aligned samples, forwards survivor when one source stalls/ends). New: WasapiCapture::start_microphone (Direction::Capture, no WAV), audio::list_capture_devices, list_input_devices command, Settings.microphone_enabled(default true)+audio_input_device. capture_loop generalized: wav_path Option, direction param, emit_level only for loopback. RecordingSession.mic_capture Option; stop/pause/resume handle both. Settings>Hardware>Audio Devices got a Microphone picker (Off/Default/devices). Loopback WAV stays byte-accurate native; mic is transcript-only (not in WAV/diarization) - tracked ponytail limitation.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T21:34:29*
|
||||
|
||||
### WhispAssist P0+P2 GPU acceleration SHIPPED (2026-0...
|
||||
|
||||
WhispAssist P0+P2 GPU acceleration SHIPPED (2026-07-05, branch chore_debug). P0 (2 commits e9567b8,7fe0d87): AccelPath enum {WhisperCpu,WhisperVulkan,WhisperCuda,OnnxOpenVino,OnnxDirectML} + resolve_accel(_with) in hardware/mod.rs = single source of truth; BackendInfo.available now derived from it (DXGI + NPU), closing the no-op-GPU detection gap; load_transcriber drives engine choice from it. 3 new pure resolver unit tests, all green. P2 (DirectML for AMD/Intel non-Vulkan): ort gains 'directml' feature; OnnxNpuTranscriber RENAMED to OnnxTranscriber (file still npu.rs) — load() now picks OpenVINO(NPU) vs DirectML(Amd/Intel, device_id 0) EP by BackendId over the SAME onnx artifacts; ensure_runtime_env takes the runtime dll path; paths::directml_runtime_dir/dll/ready added (C:\Users\dadous\AppData\Local/WhispAssist/runtime/directml/onnxruntime.dll); download_and_extract_runtime parameterized (sha+ready) and reused by new stage_directml_runtime + download_directml_package command (registered in lib.rs); hardware_status reports a directml field. 104 lib tests + clippy -D warnings all green across feature sets. TESTED end-to-end: directml_transcribes_speech spike (ignored test in npu.rs, env WA_DML_MODEL_DIR/WA_DML_TEST_WAV/WA_DML_BACKEND) run with ORT_DYLIB_PATH=the existing OpenVINO onnxruntime.dll -> FAILED as expected with 'GetProcAddress OrtSessionOptionsAppendExecutionProvider_DML failed' = the OpenVINO ORT build has NO DirectML EP. This PROVES the DML dispatch path selects the EP and fails LOUDLY (error_on_failure) instead of silently. REMAINING for a GREEN GPU run: stage a real DirectML-EP onnxruntime.dll (Microsoft.ML.OnnxRuntime.DirectML) into runtime/directml/, VERSION-MATCHED to ort rc.10 / ORT ~1.24.x (OpenVINO bundle is ORT 1.24.1). No hosted DirectML bundle published yet; WA_DIRECTML_RUNTIME_URL env drives download, empty SHA. CUDA (P1) NOT done yet (needs NVIDIA hw/CI).
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-06T00:45:47*
|
||||
|
||||
### WhispAssist DirectML Settings toggle WIRED (2026-0...
|
||||
|
||||
WhispAssist DirectML Settings toggle WIRED (2026-07-06, branch chore_debug, 4 commits 281ccf0/ceb7e59/1b743da/1b74e0b). Backend: hardware::directml_would_help() (pub, in hardware/mod.rs) = cfg!(feature=npu) && !cfg!(feature=vulkan) && a present AMD/Intel GPU (or NVIDIA when !cfg!(cuda)) via dxgi::enumerate_gpus() presence — gates the card so it stays HIDDEN on the shipping Vulkan build (Vulkan already covers all GPUs) and only shows on CUDA/non-Vulkan builds where a GPU lacks coverage. hardware_status now returns directml:{applicable,runtimeReady,modelInstalled} (mirrors the npu:{} field). Frontend: api.ts HardwareStatus gained directml?:{applicable,runtimeReady,modelInstalled} + api.downloadDirectmlPackage(); Settings.svelte has a 'GPU acceleration (DirectML)' card mirroring the NPU package card (reuses .npu-package CSS), shown when directml.applicable, Ready badge when runtimeReady&&modelInstalled else a Download button -> downloadDirectmlPackage() -> loadHardware(). Progress is await-driven (busy flag), NO % listener — DirectML runtime/model progress still emits on the cosmetic npu://download channel (only 'done' on directml://download). It's a package-DOWNLOAD action, not a persistent on/off toggle; the real 'use this GPU' switch is the existing Preferred-backend dropdown (which now enables AMD/Intel once staged, via the P0 availability fix). clippy -D warnings + svelte-check both clean (fixed a needless_return in directml_would_help by using the cfg-block tail-expression pattern like npu_hardware_present).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T12:27:50*
|
||||
|
||||
### WhispAssist release 0.1.4 = first Vulkan-enabled b...
|
||||
|
||||
WhispAssist release 0.1.4 = first Vulkan-enabled build. Artifacts: C:\wt\release\bundle\msi\WhispAssist_0.1.4_x64_en-US.msi (27MB), C:\wt\release\bundle\nsis\WhispAssist_0.1.4_x64-setup.exe (8.7MB), C:\wt\release\whispassist.exe (64MB) — sizes jumped from 11MB/4.1MB/13MB (0.1.3 CPU-only) because the Vulkan backend + embedded SPIR-V shaders are statically compiled in. Built via 'npm run tauri build -- --features vulkan' with the [[whispassist-vulkan-build-recipe]] env. REMAINING WIRING GAPS (not yet done): (1) detection-honesty gating — mark GPU backends 'available' only when a vulkan/cuda feature is compiled (cfg!(feature=...)), else best() routes to a no-op GPU; (2) make Vulkan the standing release build flag instead of manual --features vulkan; (3) preferred_backend UI so the user can force Intel GPU (currently best() picks NPU rank-0 over Intel rank-3 on this machine, so the app uses NPU not Vulkan unless overridden).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-05T22:04:13*
|
||||
|
||||
### WhispAssist Nextcloud sync UX overhaul (2026-07-06...
|
||||
|
||||
WhispAssist Nextcloud sync UX overhaul (2026-07-06, commits 8f6e804/9c670f9). ROOT CAUSE of user's confusion (dwdoubet@box.dou.bet): they were editing with the password field BLANK. WebDavTarget.test() does PROPFIND on base_url+remote_base_path. Bare host https://box.dou.bet + blank pw -> PROPFIND https://box.dou.bet/WhispAssist = NON-DAV path -> 404 (no auth needed) -> test treats 404 as success -> FALSE 'Connected'. Full DAV URL + blank pw -> real DAV endpoint -> 401 -> 'auth failed'. So bare-host 'Connected' was a false positive. FIX 1: WebDavTarget gained provider_hint field; for nextcloud/owncloud, dav_root() derives origin (scheme+host+port) from base_url and builds {origin}/remote.php/dav/files/{username}/ — so users enter ONLY the server URL (https://box.dou.bet) and the app builds the canonical DAV path; a pasted full path is normalized via origin(). Other providers (seafile /seafdav, synology /dav, cloudreve, generic) still use base_url verbatim. url_for uses dav_root(). FIX 2: test_sync_target — for an EXISTING webdav target (id present), it now builds WebDavTarget from the stored row's credential_ref (STORED password) + applies form overrides (base_url/username/remote_base_path/provider_hint/allow_plaintext_lan), unless the user typed a NEW password (temp cred). So Test works after a URL fix WITHOUT re-entering the password. Frontend: davAutoPath derived (provider is nextcloud/owncloud) drives the Server URL placeholder/hint; testConnection passes id in edit mode. Test nextcloud_builds_dav_path_from_server_url added. Correct Nextcloud username here = dwdoubet, host box.dou.bet. NOTE: their existing saved target may just start working after this (url_for rebuilds path from origin+username) if provider_hint=nextcloud. clippy --all-targets + svelte-check + eslint clean; 13 sync tests pass.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T16:11:14*
|
||||
|
||||
---
|
||||
|
||||
## Errors
|
||||
|
||||
*Failure records, bugs, and lessons learned from mistakes.*
|
||||
|
||||
### WhispAssist crash to INVESTIGATE LATER (2026-07-06...
|
||||
|
||||
WhispAssist crash to INVESTIGATE LATER (2026-07-06, dev build, branch feature_chore_bug_002): app crashed with exit code 0x80000003 (STATUS_BREAKPOINT) around DirectML use on an Intel GPU. Repro sequence: NPU recording worked fine (mic capture confirmed working); user then switched to test DirectML, downloaded the DirectML components, then hit Record and it crashed — crash may have occurred DURING the component download or immediately after starting recording. Log evidence at crash: WARN 'ONNX engine load failed (model load failed: Error attempting to load symbol OrtSessionOptionsAppendExecutionProvider_DML from dynamic library: GetProcAddress failed); falling back to CPU', then whisper_model_load loading ggml-small.en-q5_1.bin, then process exited 0x80000003. CONFIRMED unrelated to the microphone/mixer feature (FR-CAP-7) — it's in the DirectML/ONNX + whisper model-load path. Suspect: DirectML runtime download/activation or DML EP symbol-load failure interacting with recording start. Next: reproduce by enabling DirectML on Intel GPU + start recording; check GetProcAddress DML symbol load and whether crash is during download vs whisper load.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-06T21:59:37*
|
||||
|
||||
### WhispAssist build gotcha (cost ~1hr this session, ...
|
||||
|
||||
WhispAssist build gotcha (cost ~1hr this session, masqueraded as a Node 26 incompatibility): NEVER use PowerShell 'Set-Content -Encoding utf8' on package.json / tauri.conf.json / any JSON or TOML — Windows PowerShell 5.1 writes UTF-8 WITH a BOM. The BOM in package.json breaks vite (fails 'type:module' detection -> 'This package is ESM only but was loaded by require' for @sveltejs/vite-plugin-svelte) AND vitefu (JSON.parse chokes: 'Unexpected token, not valid JSON' -> 'Unable to read package.json'), which fails 'npm run build' / the whole tauri build. Fix: use the Edit tool, or sed, or [System.IO.File]::WriteAllText. Strip an existing BOM with: sed -i '1s/^\xef\xbb\xbf//' file. Node was v26.3.0 at C:\Tools\node but Node was NOT the cause.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-05T22:04:14*
|
||||
|
||||
### Critical recurring issue: WhispAssist debug (dev) ...
|
||||
|
||||
Critical recurring issue: WhispAssist debug (dev) builds hang/stall during whisper.cpp model loading on this machine, apparently due to Trend Micro AV behavior-monitoring interfering with ZwWriteVirtualMemory calls made during model load. Confirmed reproducible even in a bare standalone Rust example binary with zero Tauri/webview involvement, so it is specific to whisper.cpp model loading in a debug-profile binary, not the app shell. Release (optimized+stripped) builds do NOT hit this - confirmed by the user and by direct testing. Escalated to IT, unresolved as of 2026-07-02. Workaround: use release builds for real testing/spot-checking; expect dev-mode launches to sometimes hang at model load and need force-killing.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-02T20:14:14*
|
||||
|
||||
### WhispAssist CRITICAL BUG FOUND + FIXED (2026-07-06...
|
||||
|
||||
WhispAssist CRITICAL BUG FOUND + FIXED (2026-07-06): the OS credential store was a NO-OP the entire time. keyring 3.x feature-gates its platform backends and they are OFF by default; 'keyring = { version = "3", optional = true }' had NO backend feature, so on Windows keyring silently used its MOCK keystore. The mock does NOT persist across keyring::Entry instances, so credentials::set (Entry A) appeared to succeed while credentials::get (a fresh Entry B, same service+name) always returned NoEntry -> has_secret=false -> no HTTP Basic auth sent -> 401. This broke ALL sync WebDAV auth, and would break MCP/hosted-AI keys + OAuth tokens too (anything via sync::credentials, SERVICE='WhispAssist-sync'). FIX: keyring = { version = "3", optional = true, features = ["windows-native"] } (pulls dep:windows-sys = real Windows Credential Manager). Confirmed: WA_SYNC_TEST_DIAG went from status=401 has_secret=false to status=207 has_secret=true. DIAGNOSIS JOURNEY (Nextcloud box.dou.bet user dwdoubet): symptom 'auth failed'; the user's credentials + full DAV URL were valid (curl PROPFIND 207). Red herrings: (a) earlier the Nextcloud base_url was missing /remote.php/dav/files/<user>/; (b) a bare-host test gave a FALSE 'Connected' because a non-DAV path 404 is treated as success; (c) blank password in edit test. Real root cause was keyring. TOOLING NOTE: diagnosed via temporary tracing::info! logs (WA_SYNC_CMD_DIAG in test_sync_target = has_id+secret_len; WA_SYNC_SET_DIAG in add branch; WA_SYNC_TEST_DIAG in WebDavTarget::test() = url+status+has_secret) read live from the 'npm run tauri dev' output (tracing filter is 'info' in lib.rs). Also learned keyring feature name = windows-native, and the app's tracing default level is info. These temp diagnostics MUST be removed before release.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T18:06:13*
|
||||
|
||||
### WhispAssist debug-CRT assertion (found 2026-07 whi...
|
||||
|
||||
WhispAssist debug-CRT assertion (found 2026-07 while running the non-vulkan CPU baseline in DEBUG): the whispassist_lib debug test binary throws MSVC Debug Assertion 'Expression: _osfile(fh) & FOPEN' at ucrt read.cpp:381 = a read() on a CLOSED/invalid file handle. It pops a MODAL Abort/Retry/Ignore dialog that HANGS the test (this is what stalled the overnight non-vulkan baseline run for 8 hours). Only fires under the debug CRT (-MDd); the RELEASE 0.1.4 build does NOT assert (release CRT skips the check), so shipping is unaffected — but the underlying 'read on a closed handle' is latent UB worth root-causing. Likely in the transcription file-read path (whisper.cpp model load or our audio read_wav_mono_16k / vault::open passthrough). ADD to the CPU testing round: investigate this handle bug alongside the ~90s transcribe_file slowness (may or may not be related). Workaround to get a clean non-vulkan CPU number: run 'cargo test --release' (no debug CRT dialog), not plain 'cargo test'.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-05T22:07:51*
|
||||
|
||||
---
|
||||
|
||||
*End of memory export.*
|
||||
@@ -11,36 +11,39 @@ on-device acceleration (**NPU → GPU → CPU**), labels speakers, structures th
|
||||
Markdown notes, and optionally augments them with a locally hosted LLM (Ollama). Audio and
|
||||
transcripts **never leave the machine** unless you explicitly configure a destination.
|
||||
|
||||
> **Status: working application (v0.5.2).** Capture (system audio **+ your microphone**, with a
|
||||
> live **dual level meter**), transcription (CPU / Intel NPU / Vulkan GPU) with a **fluid live
|
||||
> transcript**, speaker diarization, storage + crash recovery, local-LLM summaries, AI tags,
|
||||
> **Status: working application (v0.7.3).** Capture (system audio **+ your microphone**, with a
|
||||
> live **dual level meter** and an **M-key mic mute**), transcription (CPU / Intel NPU / Vulkan
|
||||
> GPU) with a **fluid live transcript**, speaker diarization that labels **You** live from your
|
||||
> own mic channel, storage + crash recovery, local-LLM summaries, AI tags, **AI-enhanced notes**,
|
||||
> opt-in recording with **in-app playback**, at-rest encryption, self-hosted sync, **Outlook
|
||||
> `.pst`/calendar import with optional auto-record**, **importing an existing recording from a
|
||||
> file or URL**, and a loopback **MCP server** for coding-agent handoff are all implemented and
|
||||
> ship as a single signed **MSI + NSIS** universal installer. Build order and remaining tasks
|
||||
> are in [`docs/05-roadmap.md`](docs/05-roadmap.md).
|
||||
> `.pst`/calendar import with optional auto-record**, **background import of an existing
|
||||
> recording from a file or URL** (with a step-by-step progress tracker), **close-to-tray**, and a
|
||||
> loopback **MCP server** for coding-agent handoff are all implemented and ship as a single
|
||||
> **MSI + NSIS** universal installer. On Intel Core Ultra machines, transcription runs with the
|
||||
> **CPU near-idle**: Whisper's encoder on the **NPU**, its decoder on the **iGPU**. Build order
|
||||
> and remaining tasks are in [`docs/05-roadmap.md`](docs/05-roadmap.md).
|
||||
|
||||
## Why WhispAssist — Granola vs Meetily vs WhispAssist
|
||||
|
||||
| | **Granola** | **Meetily** | **WhispAssist** |
|
||||
|---|---|---|---|
|
||||
| **Positioning** | Cloud AI notepad | Open-source self-hosted assistant | Local, Windows-native assistant |
|
||||
| **Platform** | macOS, Windows | macOS, Windows, Linux | Windows 10/11 |
|
||||
| **Bot-free system-audio capture** | ✅ | ✅ | ✅ WASAPI loopback |
|
||||
| **Transcription** | ☁️ cloud | ✅ local (Whisper) | ✅ local (whisper.cpp) |
|
||||
| **NPU / GPU auto-acceleration** | n/a (cloud) | ⚠️ CPU/GPU, manual | ✅ **NPU→NVIDIA→AMD→Intel→CPU** ladder, one binary |
|
||||
| **Speaker diarization** | ✅ cloud | ⚠️ limited | ✅ offline (sherpa-onnx) |
|
||||
| **Summaries / notes AI** | ☁️ cloud LLM | ✅ local (Ollama) / BYO | ✅ local (Ollama, localhost **or LAN**) + optional hosted |
|
||||
| **Interface localization (UI language)** | ⚠️ English-only UI (multilingual transcription) | ⚠️ English-only UI (more planned) | ✅ **20 languages** + RTL (Arabic/Urdu) |
|
||||
| **Default data egress** | ☁️ audio + notes to cloud | 🔒 local (cloud optional) | 🔒 **none** — everything off by default, allowlist-enforced |
|
||||
| **Calendar / Outlook `.pst` context** | ✅ cloud calendar | ❌ | ✅ local `.pst` import + **auto-record on events** |
|
||||
| **Import an existing recording (file/URL)** | ❌ live capture only | ❌ | ✅ any file or URL (ffmpeg + yt-dlp) |
|
||||
| **Opt-in recording + consent notice** | ⚠️ partial | ❌ | ✅ off by default, one-time consent |
|
||||
| **At-rest encryption** | ☁️ server-side | ❌ | ✅ vault: Argon2id + XChaCha20-Poly1305 |
|
||||
| **Self-hosted sync w/ client-side encryption** | ❌ | ⚠️ | ✅ WebDAV + OAuth, **encrypt-before-upload** |
|
||||
| **Coding-agent (MCP) server** | ✅ **cloud** MCP (notes via their servers) | ❌ | ✅ **local, loopback-only**, token-gated, adds no egress |
|
||||
| **License** | Proprietary | Open source (MIT) | Open source (MIT / Apache-2.0) |
|
||||
| **Cost** | Subscription | Free | Free |
|
||||
| | **Granola** | **Meetily** | **WhispAssist** |
|
||||
| ---------------------------------------------- | ----------------------------------------------- | --------------------------------- | ----------------------------------------------------------- |
|
||||
| **Positioning** | Cloud AI notepad | Open-source self-hosted assistant | Local, Windows-native assistant |
|
||||
| **Platform** | macOS, Windows | macOS, Windows, Linux | Windows 10/11 |
|
||||
| **Bot-free system-audio capture** | ✅ | ✅ | ✅ WASAPI loopback |
|
||||
| **Transcription** | ☁️ cloud | ✅ local (Whisper) | ✅ local (whisper.cpp) |
|
||||
| **NPU / GPU auto-acceleration** | n/a (cloud) | ⚠️ CPU/GPU, manual | ✅ **NPU→NVIDIA→AMD→Intel→CPU** ladder, one binary |
|
||||
| **Speaker diarization** | ✅ cloud | ⚠️ limited | ✅ offline (sherpa-onnx) |
|
||||
| **Summaries / notes AI** | ☁️ cloud LLM | ✅ local (Ollama) / BYO | ✅ local (Ollama, localhost **or LAN**) + optional hosted |
|
||||
| **Interface localization (UI language)** | ⚠️ English-only UI (multilingual transcription) | ⚠️ English-only UI (more planned) | ✅ **20 languages** + RTL (Arabic/Urdu) |
|
||||
| **Default data egress** | ☁️ audio + notes to cloud | 🔒 local (cloud optional) | 🔒 **none** — everything off by default, allowlist-enforced |
|
||||
| **Calendar / Outlook `.pst` context** | ✅ cloud calendar | ❌ | ✅ local `.pst` import + **auto-record on events** |
|
||||
| **Import an existing recording (file/URL)** | ❌ live capture only | ❌ | ✅ any file or URL (ffmpeg + yt-dlp) |
|
||||
| **Opt-in recording + consent notice** | ⚠️ partial | ❌ | ✅ off by default, one-time consent |
|
||||
| **At-rest encryption** | ☁️ server-side | ❌ | ✅ vault: Argon2id + XChaCha20-Poly1305 |
|
||||
| **Self-hosted sync w/ client-side encryption** | ❌ | ⚠️ | ✅ WebDAV + OAuth, **encrypt-before-upload** |
|
||||
| **Coding-agent (MCP) server** | ✅ **cloud** MCP (notes via their servers) | ❌ | ✅ **local, loopback-only**, token-gated, adds no egress |
|
||||
| **License** | Proprietary | Open source (MIT) | Open source (MIT / Apache-2.0) |
|
||||
| **Cost** | Subscription | Free | Free |
|
||||
|
||||
<sub>Comparison reflects each project's public positioning as of mid-2026. Granola now ships an
|
||||
MCP server too, but it is **cloud-hosted** — an agent reaching it pulls your notes through
|
||||
@@ -56,29 +59,36 @@ accelerated, zero-egress-by-default** option: it exploits the NPU/GPU in modern
|
||||
everything on the device unless you opt in, and adds Windows-specific context (Outlook) and a
|
||||
coding-agent handoff.
|
||||
|
||||
## What's built (v0.5.2)
|
||||
## What's built (v0.7.3)
|
||||
|
||||
- **Bot-free capture — now both sides, with a live dual meter** — WASAPI loopback records the
|
||||
system mix (all participants), and an optional **microphone** path captures your own voice,
|
||||
mixed into both the live transcript and the saved recording. While recording, a **level meter
|
||||
overlays the system and microphone signals in two colours** so you can see both sides are being
|
||||
picked up. Pick a specific output/input or turn the mic off in **Settings ▸ Hardware**. No
|
||||
- **Bot-free capture — now both sides, with a live dual meter and mic mute** — WASAPI loopback
|
||||
records the system mix (all participants), and an optional **microphone** path captures your
|
||||
own voice, mixed into both the live transcript and the saved recording. While recording, a
|
||||
**level meter overlays the system and microphone signals in two colours** so you can see both
|
||||
sides are being picked up, and the **M key (or the mic button) mutes/unmutes your mic
|
||||
mid-meeting**. Pick a specific output/input or turn the mic off in **Settings ▸ Hardware**. No
|
||||
meeting bot, no per-app plumbing.
|
||||
- **Local transcription with a hardware ladder — and a fluid live transcript** — whisper.cpp via
|
||||
`whisper-rs` on CPU; the **Intel NPU** via ONNX Runtime + OpenVINO; **GPU via Vulkan** (a single
|
||||
binary that runs on NVIDIA, AMD, and Intel). WA detects the hardware, picks the best backend
|
||||
(**NPU → NVIDIA → AMD → Intel → CPU**), and shows it in the UI. The **live transcript streams a
|
||||
growing line that refreshes ~once a second and commits at natural pauses** — words appear as
|
||||
they're spoken instead of in fixed multi-second blocks, so sentences aren't chopped across
|
||||
lines.
|
||||
- **Speaker diarization** — `sherpa-onnx` (pyannote segmentation + speaker-embedding clustering),
|
||||
fully offline. Install the two diarization models in **Settings ▸ Hardware** and finished
|
||||
recordings are split by speaker (Speaker 1, Speaker 2, …); the microphone speaker is
|
||||
auto-labelled from a short voiceprint.
|
||||
- **Import an existing recording** — add a meeting from a **local audio/video file or a URL**
|
||||
(YouTube, a streaming page, or a direct media link). WhispAssist transcribes and diarizes it
|
||||
just like a live recording. Uses **`ffmpeg`** (and **`yt-dlp`** for URLs), which you install
|
||||
yourself — neither is bundled.
|
||||
(**NPU → NVIDIA → AMD → Intel → CPU**), and shows it in the UI. On NPU machines the engine
|
||||
**splits Whisper across accelerators — encoder on the NPU, KV-cache decoder on the Intel iGPU —
|
||||
so transcription leaves the CPU near-idle**. The **live transcript streams a growing line that
|
||||
refreshes ~once a second and commits at natural pauses** — words appear as they're spoken
|
||||
instead of in fixed multi-second blocks, so sentences aren't chopped across lines.
|
||||
- **Speaker diarization that knows who _you_ are** — `sherpa-onnx` (pyannote segmentation +
|
||||
speaker-embedding clustering), fully offline. Install the two diarization models in
|
||||
**Settings ▸ Hardware** and recordings are split by speaker (You, Speaker 2, …). With the mic
|
||||
enabled, your voice is recorded on its **own channel**, so your lines are labelled **You** live
|
||||
— from the first pass, not just after the meeting — and attributed by per-channel voice
|
||||
activity rather than clustering guesswork. Renaming a speaker updates the transcript **and the
|
||||
generated notes**.
|
||||
- **Import an existing recording — in the background, with a progress tracker** — add a meeting
|
||||
from a **local audio/video file or a URL** (YouTube, a streaming page, or a direct media link);
|
||||
it transcribes quietly in the background behind a four-step tracker (_Transcode → Transcribe →
|
||||
Identify speakers → Finalize_) while you keep working. Uses **`ffmpeg`** (and **`yt-dlp`** for
|
||||
URLs), which you install yourself — neither is bundled, and one-click download buttons are in
|
||||
the Import dialog.
|
||||
- **Notes & summaries** — Markdown notes; local-LLM summaries via **Ollama** on `localhost`
|
||||
**or a private LAN endpoint** (RFC-1918), with a full advanced-parameter panel (system prompt,
|
||||
`think`, `keep_alive`, `num_ctx`, sampling/repetition/mirostat, etc.).
|
||||
@@ -90,8 +100,10 @@ coding-agent handoff.
|
||||
recordings are decrypted **in memory on the fly** (nothing plaintext is written to disk).
|
||||
Recordings are 16-bit for roughly half the size, and an accidental recording can be **cancelled**
|
||||
(audio + transcript deleted).
|
||||
- **Notes, summaries & AI tags** — Markdown notes with an **Editor/Preview** toggle; local-LLM
|
||||
summaries and one-click **tag generation** with a chip-based tag editor and tag filtering.
|
||||
- **Notes, summaries & AI tags** — Markdown notes with an **Editor/Preview** toggle, a formatting
|
||||
toolbar, and **slash commands** (`/todo`, `/h1`, `/quote`, …); **AI-enhance** turns rough notes
|
||||
into clean structured Markdown via your local LLM (with one-step Undo); local-LLM summaries and
|
||||
one-click **tag generation** with a chip-based tag editor and tag filtering.
|
||||
- **At-rest encryption vault** — Argon2id key derivation + XChaCha20-Poly1305; transcripts,
|
||||
notes, summaries, and recordings sealed on disk; startup unlock gate; keys zeroized on lock.
|
||||
- **Self-hosted sync (optional, off by default)** — WebDAV (Nextcloud, ownCloud, Cloudreve,
|
||||
@@ -103,7 +115,11 @@ coding-agent handoff.
|
||||
- **Outlook `.pst` / calendar context, with auto-record** — import events and attendees from a
|
||||
local Outlook `.pst` backup (read-only, range-limited, de-duplicated, with cleanup), attach
|
||||
meetings to events, and — opt-in — **auto-start recording when a calendar event begins** while
|
||||
the app is open (a one-shot timer, no background polling).
|
||||
the app is open (a one-shot timer, no background polling). The import shells out to `readpst`
|
||||
(libpst) — **Settings ▸ Calendar** has a one-click download button for it.
|
||||
- **Close to tray** — closing the window keeps WhispAssist running in the background; reopen it
|
||||
from the tray icon, quit fully from the tray menu. On by default, toggleable in
|
||||
**Settings ▸ Recording**.
|
||||
- **Local MCP server for coding-agent handoff** — hand meeting context to your own coding agents
|
||||
(Claude, Codex, Copilot, OpenCode) over a **loopback-only, token-gated** MCP server that is off
|
||||
by default, scope-limited, audited, and **adds no egress** — data leaves only via the agent's
|
||||
@@ -116,9 +132,10 @@ coding-agent handoff.
|
||||
Sinhala (සිංහල), Spanish — Spain & Mexico (Español), Swedish (Svenska), Tamil (தமிழ்), and
|
||||
Urdu (اردو). Selecting Arabic or Urdu flips the interface to **right-to-left**; any untranslated
|
||||
string falls back to English rather than showing a raw key. Adding a language is a one-file job.
|
||||
- **One universal installer** — a single signed MSI and NSIS `-setup.exe` that covers every
|
||||
machine: Vulkan for all GPUs, the Intel NPU path (+ DirectML fallback), and CPU. The Vulkan
|
||||
loader is bundled so it launches even on machines without a GPU driver.
|
||||
- **One universal installer** — a single MSI and NSIS `-setup.exe` that covers every machine:
|
||||
Vulkan for all GPUs, the Intel NPU path (+ DirectML fallback), and CPU. The Vulkan loader is
|
||||
bundled so it launches even on machines without a GPU driver. Every release ships **SHA-256
|
||||
checksums** in its release notes.
|
||||
|
||||
**In progress:** Microsoft Graph calendar (cloud calendar via OAuth) and an optional CUDA
|
||||
(NVIDIA-only) build variant.
|
||||
@@ -132,30 +149,39 @@ during setup).
|
||||
1. Download the latest **`WhispAssist_<version>_x64_en-US.msi`** from the
|
||||
[releases page](https://git.dou.bet/iamdoubz/WhispAssist/releases).
|
||||
2. Double-click the `.msi` and follow the prompts, accepting the UAC prompt to install. The
|
||||
installer is signed; if Windows SmartScreen appears, choose **More info → Run anyway**.
|
||||
installers are currently unsigned, so if Windows SmartScreen appears, choose
|
||||
**More info → Run anyway** (verify the download against the SHA-256 checksums in the release
|
||||
notes first).
|
||||
3. Launch **WhispAssist** from the Start menu.
|
||||
|
||||
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
|
||||
|
||||
If you do not have these installed, WhispAssist will still work, but some features will be unavailable.
|
||||
If you do not have these installed, WhispAssist will still work, but some features will be
|
||||
unavailable. The app links to each one where it's needed: `ffmpeg`/`yt-dlp` in the Import dialog,
|
||||
`libpst` in **Settings ▸ Calendar**.
|
||||
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp/releases)
|
||||
- [ffmpeg](https://www.ffmpeg.org/download.html#build-windows)
|
||||
- [libpst](https://sourceforge.net/projects/ezwinports/files/libpst-0.6.63-w32-bin.zip/)
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp/releases) — importing from URLs
|
||||
- [ffmpeg](https://www.ffmpeg.org/download.html#build-windows) — importing files/URLs
|
||||
- [libpst](https://sourceforge.net/projects/ezwinports/files/libpst-0.6.63-w32-bin.zip/) — Outlook `.pst` calendar import
|
||||
|
||||
## Technology
|
||||
|
||||
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 +202,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.
|
||||
@@ -0,0 +1,94 @@
|
||||
# WhispAssist v0.7.3
|
||||
|
||||
**Privacy-first, Windows-native meeting assistant — everything on-device, nothing leaves unless you say so.**
|
||||
|
||||
A performance release: on Intel Core Ultra machines, transcription now leaves the **CPU nearly
|
||||
idle**. The Whisper encoder keeps running on the **NPU**; the decoder — previously the dominant
|
||||
CPU cost — now decodes with a **KV cache** and runs on the **Intel iGPU**. Nothing here changes
|
||||
the privacy posture: the one new download is the upgraded transcription model, fetched from the
|
||||
same source as before.
|
||||
|
||||
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**.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Faster, cooler transcription on NPU systems
|
||||
|
||||
### KV-cache decoding
|
||||
The NPU engine previously re-processed the entire token sequence for every new token it
|
||||
generated. It now uses the upgraded *merged* Whisper decoder and caches attention state between
|
||||
steps, so each step does a small, constant amount of work. This benefits **every** machine using
|
||||
the NPU/DirectML engine, whatever device the decoder runs on.
|
||||
|
||||
### Decoder on the Intel iGPU
|
||||
On NPU systems the decoder now runs on the **Intel iGPU** via the same OpenVINO runtime already
|
||||
installed for the NPU — no new runtime download. Division of labor while transcribing:
|
||||
|
||||
- **Encoder** → NPU (the short periodic spikes on Task Manager's NPU graph)
|
||||
- **Decoder** → Intel iGPU
|
||||
- **CPU** → audio plumbing only
|
||||
|
||||
Measured on a Core Ultra 5 135U (base.en): decoding on the iGPU ran **~4.7× faster** than the
|
||||
same KV-cache decode on the CPU (1.29 s vs 6.10 s for a 19 s clip), with identical transcripts —
|
||||
and that work no longer occupies the CPU at all.
|
||||
|
||||
**Fallbacks:** no usable Intel iGPU → the decoder automatically drops back to the CPU (still
|
||||
with the KV-cache speedup), and the log says so. Machines with an NVIDIA/AMD GPU can keep using
|
||||
the Vulkan backend (Settings ▸ Hardware), which runs the whole model on that GPU. The startup
|
||||
log names both devices: `transcription engine: ONNX base.en (encoder EP: OpenVINO/NPU, decoder
|
||||
EP: OpenVINO/GPU)`.
|
||||
|
||||
### Model update (automatic, one-time)
|
||||
The engine's decoder artifact changed to the merged export (`decoder_model_merged.onnx`,
|
||||
~208 MB, same trusted source). WhispAssist fetches it automatically in the background on first
|
||||
launch after updating; until it arrives, transcription simply uses the whisper.cpp engine, as on
|
||||
a fresh install. The superseded decoder file is cleaned up automatically.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Small addition
|
||||
|
||||
**Settings ▸ Calendar** now has a one-click **libpst (readpst)** download button — the same style
|
||||
as the Import dialog's `ffmpeg`/`yt-dlp` buttons — linking to the ezwinports Windows build.
|
||||
`.pst` calendar import shells out to `readpst`, which isn't bundled; the button just opens the
|
||||
download page in your browser.
|
||||
|
||||
---
|
||||
|
||||
## 📦 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 links to both.
|
||||
|
||||
1. Download **`WhispAssist_0.7.3_x64_en-US.msi`** (or the NSIS **`WhispAssist_0.7.3_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)
|
||||
|
||||
```
|
||||
91a32a17addc31fbc00f38966d52402ca0c5e9e7a581a0431c383ca5a60532bd WhispAssist_0.7.3_x64_en-US.msi
|
||||
764b758544b075b5aefe9dde37b04e4966df528912fb884ed1be5bd57905ecdb WhispAssist_0.7.3_x64-setup.exe
|
||||
```
|
||||
|
||||
Verify after download:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\WhispAssist_0.7.3_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,78 @@
|
||||
# WhispAssist v0.7.4
|
||||
|
||||
**Privacy-first, Windows-native meeting assistant — everything on-device, nothing leaves unless you say so.**
|
||||
|
||||
A small, focused performance release for **CPU transcription**. If WhispAssist runs the Whisper
|
||||
engine on your CPU — either because your machine has no usable GPU/NPU, or because you selected
|
||||
the CPU backend — this update takes it from far-slower-than-real-time to comfortably real-time.
|
||||
Machines on the NPU or GPU backends are unaffected in behavior and simply pick up a
|
||||
better-optimized build. Nothing here changes the privacy posture; there are no new downloads.
|
||||
|
||||
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**.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ CPU transcription fixed (up to ~90× faster on hybrid Intel CPUs)
|
||||
|
||||
Whisper-on-CPU previously spawned one worker thread per logical CPU. On modern hybrid Intel
|
||||
processors (P-cores + E-cores + low-power E-cores) the workers synchronize in lock-step, so the
|
||||
two slow low-power cores stalled *every* step while the remaining threads burned CPU waiting —
|
||||
transcription ran ~20× slower than real time while looking like it was using the whole machine.
|
||||
|
||||
WhispAssist now caps Whisper's CPU decode at **4 threads** (the whisper.cpp upstream default).
|
||||
Measured on a Core Ultra 5 135U over a 4-minute test corpus, same accuracy:
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| Speed vs real time | 20× **slower** | **4.6× faster** (RTF 0.218) |
|
||||
| CPU cost per second of audio | 232.6 cpu-sec | 0.83 cpu-sec |
|
||||
|
||||
Live (streaming) transcription shares this code path and benefits equally. If you want to
|
||||
experiment, the `WA_WHISPER_THREADS` environment variable overrides the cap.
|
||||
|
||||
## 🔧 Native code now built fully optimized
|
||||
|
||||
A build-toolchain issue caused the bundled native libraries (including whisper.cpp's CPU
|
||||
kernels) to be compiled **without compiler optimization**. That's fixed; this release ships
|
||||
optimized native code across the board. (For CPU transcription the thread fix above is the
|
||||
dominant win; this one is belt-and-suspenders for everything else.)
|
||||
|
||||
---
|
||||
|
||||
## 📦 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 links to both.
|
||||
|
||||
1. Download **`WhispAssist_0.7.4_x64_en-US.msi`** (or the NSIS **`WhispAssist_0.7.4_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)
|
||||
|
||||
```
|
||||
5c4e6d6b23a4b404c6fa200b6491e74f143c3a886042dc3bef3c2861168ef17c WhispAssist_0.7.4_x64_en-US.msi
|
||||
eb2ececff06373e9cbb43ee6455c433c1b88785e9060b68ef7bed193191d9761 WhispAssist_0.7.4_x64-setup.exe
|
||||
```
|
||||
|
||||
Verify after download:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\WhispAssist_0.7.4_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,4 @@
|
||||
contender,file,audio_secs,load_ms,wall_ms,cpu_ms,wer_errors,ref_words,hypothesis
|
||||
w2-vulkan-base,bank_deposits,61.85,324,1398,765,135,4,"Bank deposits are not securities and are not covered by the securities investor protection corporation. Funds used to purchase or sweep to a bank deposit are SIPC protected until deposited to a program bank at which time funds may be eligible for FDIC insurance. Customers are responsible for monitoring their total deposits at each program bank to determine the extent of available FDIC insurance. Refer to the bank deposit details section which appears later in this statement for information on the banks holding your deposits. If your account was established on the last business day of the month, your statement will not include a bank deposit detailed section. The interest rate below is the interest rate effective for cash balances and your FDIC insured bank deposit suite on the last day of the statement period."
|
||||
w4-npu-onnx,bank_deposits,61.85,5709,3396,2938,137,4,"Bank deposits are not securities and are not covered by the Securities Investor Protection Corporation. Funds used to purchase or sweep to a bank deposit or SIPC protected until deposited to a program bank at which time funds may be eligible for FDIC insurance. Customers are responsible for monitoring their total deposits at each program bank to determine the extent of available FDIC insurance. Refer to the Bank Deposit D-Tales section, which appears later in this statement for information on the banks holding your deposits. If your account was established on the last business day of the month, your statement will not include a Bank Deposit D-Tales section. The interest rate below is the interest rate effective for cash balances in your FDIC insured bank deposit suite on the last day of the state. period."
|
||||
p1-parakeet-t4,bank_deposits,61.85,5244,3570,14047,135,4," Bank deposits are not securities and are not covered by the Securities Investor Protection Corporation. Funds used to purchase or sweep to a bank deposit are SIPC protected until deposited to a program bank at which time funds may be eligible for FDIC insurance. Customers are responsible for monitoring their total deposits at each program bank to determine the extent of available FDIC insurance. Refer to the bank deposit details section, which appears later in this statement, for information on the banks holding your deposits. If your account was established on the last business day of the month, your statement will not include a bank deposit detail section. The interest rate below is the interest rate effective for cash balances in your FDIC insured bank deposit suite on the last day of the statement period."
|
||||
|
@@ -0,0 +1,31 @@
|
||||
contender,file,audio_secs,load_ms,wall_ms,cpu_ms,wer_errors,ref_words,hypothesis
|
||||
w1-cpu-base,1089-134686-0000,10.44,163,2606,8219,1,28,"He hoped there would be stew for dinner, turnips and carrots and bruised potatoes and fat mutton pieces to be ladled out in thick, peppered, flour-fatten sauce."
|
||||
w1-cpu-base,1089-134686-0001,3.27,163,2111,7469,1,8,"Stuff it into you, his belly counseled him."
|
||||
w1-cpu-base,1089-134686-0002,6.62,163,2067,7360,0,18,"After early nightfall the yellow lamps would light up here and there the squalid quarter of the brothels."
|
||||
w1-cpu-base,1089-134686-0003,2.68,163,1844,6999,0,7,"Hello Bertie, any good in your mind?"
|
||||
w1-cpu-base,1089-134686-0004,5.22,163,1941,7250,1,11,"Number 10. Fresh Nelly is waiting on you. Good night husband."
|
||||
w1-cpu-base,1089-134686-0005,9.63,163,1778,6719,0,22,"The music came nearer and he recalled the words, the words of Shelley's fragment upon the moon wandering companionless pale for weariness."
|
||||
w1-cpu-base,1089-134686-0006,10.55,163,1860,7000,2,24,"The dull light fell more faintly upon the page, where on another equation began to unfold itself slowly, and to spread abroad its widening tail."
|
||||
w1-cpu-base,1089-134686-0007,4.28,163,1659,6500,0,8,"A cold, lucid indifference reigned in his soul."
|
||||
w1-cpu-base,1089-134686-0008,6.73,163,1727,6594,1,15,"The chaos in which his order extinguished itself was a cold, indifferent knowledge of himself."
|
||||
w1-cpu-base,1089-134686-0009,10.57,163,1779,6890,0,27,"At most, by an alms given to a beggar whose blessing he fled from, he might hope wearily to win for himself some measure of actual grace."
|
||||
w1-cpu-base,1089-134686-0010,4.41,163,1673,6547,0,14,"""Well now, Ennis, I declare you have a head and so has my stick."""
|
||||
w1-cpu-base,1089-134686-0011,12.45,163,1835,7000,2,39,"On Saturday mornings when the so dallity met in the chapel to recite the little office, his place was a cushioned kneeling desk at the right of the altar from which he led his wing of boys through the responses."
|
||||
w1-cpu-base,1089-134686-0012,11.64,163,1713,6312,0,28,"Her eyes seemed to regard him with mild pity. Her holiness, a strange light glowing faintly upon her frail flesh, did not humiliate the sinner who approached her."
|
||||
w1-cpu-base,1089-134686-0013,7.92,163,1684,6423,1,25,"If ever he was impelled to cast sin from him and to repent, the impulse that moved him was the wish to be her night."
|
||||
w1-cpu-base,1089-134686-0014,2.23,163,1573,6155,0,8,"He tried to think how it could be."
|
||||
w1-cpu-base,1089-134686-0015,5.82,163,1615,6298,0,14,"but the dusk deepening in the schoolroom covered over his thoughts. The bell rang."
|
||||
w1-cpu-base,1089-134686-0016,3.54,163,1568,6125,1,10,"Then you can ask him questions on the Catechism Daedalus."
|
||||
w1-cpu-base,1089-134686-0017,8.87,163,1640,6359,0,24,"Stephen, leaning back and drawing idly on his scribbler, listened to the talk about him which Heron checked from time to time by saying,"
|
||||
w1-cpu-base,1089-134686-0018,15.72,163,1697,6641,0,41,"It was strange, too, that he found an arid pleasure in following up to the end the rigid lines of the doctrines of the church and penetrating into obscure silences only to hear and feel the more deeply his own condemnation."
|
||||
w1-cpu-base,1089-134686-0019,13.89,163,1713,6593,1,39,"The sentence of St. James, which says that he who offends against one commandment becomes guilty of all, had seemed to him first a swollen phrase, until he had begun to grope in the darkness of his own state."
|
||||
w1-cpu-base,1089-134686-0020,16.79,163,1806,6922,0,50,"If a man had stolen a pound in his youth and had used that pound to amass a huge fortune, how much was he obliged to give back? The pound he had stolen only, or the pound together with the compound interest accruing upon it, or all his huge fortune."
|
||||
w1-cpu-base,1089-134686-0021,6.55,163,1689,6656,1,17,"If a layman in giving baptism poor the water before saying the words is the child baptized."
|
||||
w1-cpu-base,1089-134686-0022,11.18,163,1651,6470,2,32,"How comes it that while the first beattitude promises the kingdom of heaven to the poor of heart, the second beattitude promises also to the meek that they shall possess the land?"
|
||||
w1-cpu-base,1089-134686-0023,13.28,163,1706,6734,0,36,"Why was the sacrament of the Eucharist instituted under the two species of bread and wine if Jesus Christ be present, body and blood, soul and divinity in the bread alone and in the wine alone?"
|
||||
w1-cpu-base,1089-134686-0024,11.65,163,1757,6829,0,30,"If the wine change into vinegar and the host crumble into corruption after they have been consecrated, is Jesus Christ still present under their species as God and as man?"
|
||||
w1-cpu-base,1089-134686-0025,6.61,163,1618,6280,0,18,"A gentle kick from the tall boy in the bench behind urged Stephen to ask a difficult question."
|
||||
w1-cpu-base,1089-134686-0026,4.01,163,1591,6110,0,13,"The rector did not ask for a catechism to hear the lesson from."
|
||||
w1-cpu-base,1089-134686-0027,2.71,163,1549,5937,0,9,"He clasped his hands on the desk and said,"
|
||||
w1-cpu-base,1089-134686-0028,7.83,163,1602,6204,2,18,"The retreat will begin on Wednesday afternoon in honor of St. Francis Xavier, whose feast day is Saturday."
|
||||
w1-cpu-base,1089-134686-0029,4.67,163,1601,6280,0,11,"On Friday, confession will be heard all the afternoon after beads."
|
||||
|
@@ -0,0 +1,31 @@
|
||||
contender,file,audio_secs,load_ms,wall_ms,cpu_ms,wer_errors,ref_words,hypothesis
|
||||
w1-cpu-base,1089-134686-0000,10.44,112,216077,2510125,1,28,"He hoped there would be stew for dinner, turnips and carrots and bruised potatoes and fat mutton pieces to be ladled out in thick, peppered, flour-fatten sauce."
|
||||
w1-cpu-base,1089-134686-0001,3.27,112,82717,961829,1,8,"Stuff it into you, his belly counseled him."
|
||||
w1-cpu-base,1089-134686-0002,6.62,112,148497,1736531,0,18,"After early nightfall the yellow lamps would light up here and there the squalid quarter of the brothels."
|
||||
w1-cpu-base,1089-134686-0003,2.68,112,77434,887500,0,7,"Hello Bertie, any good in your mind?"
|
||||
w1-cpu-base,1089-134686-0004,5.22,112,99168,1158844,1,11,"Number 10. Fresh Nelly is waiting on you. Good night husband."
|
||||
w1-cpu-base,1089-134686-0005,9.63,112,167320,1939858,0,22,"The music came nearer and he recalled the words, the words of Shelley's fragment upon the moon wandering companionless pale for weariness."
|
||||
w1-cpu-base,1089-134686-0006,10.55,112,175803,2025703,2,24,"The dull light fell more faintly upon the page, where on another equation began to unfold itself slowly, and to spread abroad its widening tail."
|
||||
w1-cpu-base,1089-134686-0007,4.28,112,81833,956001,0,8,"A cold, lucid indifference reigned in his soul."
|
||||
w1-cpu-base,1089-134686-0008,6.73,112,116962,1353344,1,15,"The chaos in which his order extinguished itself was a cold, indifferent knowledge of himself."
|
||||
w1-cpu-base,1089-134686-0009,10.57,112,194254,2274906,0,27,"At most, by an alms given to a beggar whose blessing he fled from, he might hope wearily to win for himself some measure of actual grace."
|
||||
w1-cpu-base,1089-134686-0010,4.41,112,116123,1357843,0,14,"""Well now, Ennis, I declare you have a head and so has my stick."""
|
||||
w1-cpu-base,1089-134686-0011,12.45,112,266344,3091765,2,39,"On Saturday mornings when the so dallity met in the chapel to recite the little office, his place was a cushioned kneeling desk at the right of the altar from which he led his wing of boys through the responses."
|
||||
w1-cpu-base,1089-134686-0012,11.64,112,216072,2501111,0,28,"Her eyes seemed to regard him with mild pity. Her holiness, a strange light glowing faintly upon her frail flesh, did not humiliate the sinner who approached her."
|
||||
w1-cpu-base,1089-134686-0013,7.92,112,169507,1978264,1,25,"If ever he was impelled to cast sin from him and to repent, the impulse that moved him was the wish to be her night."
|
||||
w1-cpu-base,1089-134686-0014,2.23,112,70139,820125,0,8,"He tried to think how it could be."
|
||||
w1-cpu-base,1089-134686-0015,5.82,112,120268,1383735,0,14,"but the dusk deepening in the schoolroom covered over his thoughts. The bell rang."
|
||||
w1-cpu-base,1089-134686-0016,3.54,112,108330,1241142,1,10,"Then you can ask him questions on the Catechism Daedalus."
|
||||
w1-cpu-base,1089-134686-0017,8.87,112,194063,2182906,0,24,"Stephen, leaning back and drawing idly on his scribbler, listened to the talk about him which Heron checked from time to time by saying,"
|
||||
w1-cpu-base,1089-134686-0018,15.72,112,305117,3388062,0,41,"It was strange, too, that he found an arid pleasure in following up to the end the rigid lines of the doctrines of the church and penetrating into obscure silences only to hear and feel the more deeply his own condemnation."
|
||||
w1-cpu-base,1089-134686-0019,13.89,112,280828,3258859,1,39,"The sentence of St. James, which says that he who offends against one commandment becomes guilty of all, had seemed to him first a swollen phrase, until he had begun to grope in the darkness of his own state."
|
||||
w1-cpu-base,1089-134686-0020,16.79,112,341186,3965438,0,50,"If a man had stolen a pound in his youth and had used that pound to amass a huge fortune, how much was he obliged to give back? The pound he had stolen only, or the pound together with the compound interest accruing upon it, or all his huge fortune."
|
||||
w1-cpu-base,1089-134686-0021,6.55,112,121607,1409156,1,17,"If a layman in giving baptism poor the water before saying the words is the child baptized."
|
||||
w1-cpu-base,1089-134686-0022,11.18,112,227716,2617797,2,32,"How comes it that while the first beattitude promises the kingdom of heaven to the poor of heart, the second beattitude promises also to the meek that they shall possess the land?"
|
||||
w1-cpu-base,1089-134686-0023,13.28,112,242491,2812172,0,36,"Why was the sacrament of the Eucharist instituted under the two species of bread and wine if Jesus Christ be present, body and blood, soul and divinity in the bread alone and in the wine alone?"
|
||||
w1-cpu-base,1089-134686-0024,11.65,112,195153,2262469,0,30,"If the wine change into vinegar and the host crumble into corruption after they have been consecrated, is Jesus Christ still present under their species as God and as man?"
|
||||
w1-cpu-base,1089-134686-0025,6.61,112,124006,1425344,0,18,"A gentle kick from the tall boy in the bench behind urged Stephen to ask a difficult question."
|
||||
w1-cpu-base,1089-134686-0026,4.01,112,111022,1272891,0,13,"The rector did not ask for a catechism to hear the lesson from."
|
||||
w1-cpu-base,1089-134686-0027,2.71,112,70567,820750,0,9,"He clasped his hands on the desk and said,"
|
||||
w1-cpu-base,1089-134686-0028,7.83,112,139096,1612905,2,18,"The retreat will begin on Wednesday afternoon in honor of St. Francis Xavier, whose feast day is Saturday."
|
||||
w1-cpu-base,1089-134686-0029,4.67,112,87495,1024704,0,11,"On Friday, confession will be heard all the afternoon after beads."
|
||||
|
@@ -0,0 +1,181 @@
|
||||
contender,file,audio_secs,load_ms,wall_ms,cpu_ms,wer_errors,ref_words,hypothesis
|
||||
w1-cpu-base,1089-134686-0000,10.44,188,235064,2608516,1,28,"He hoped there would be stew for dinner, turnips and carrots and bruised potatoes and fat mutton pieces to be ladled out in thick, peppered, flour-fatten sauce."
|
||||
w1-cpu-base,1089-134686-0001,3.27,188,82564,917655,1,8,"Stuff it into you, his belly counseled him."
|
||||
w1-cpu-base,1089-134686-0002,6.62,188,141113,1583500,0,18,"After early nightfall the yellow lamps would light up here and there the squalid quarter of the brothels."
|
||||
w1-cpu-base,1089-134686-0003,2.68,188,73065,818720,0,7,"Hello Bertie, any good in your mind?"
|
||||
w1-cpu-base,1089-134686-0004,5.22,188,109834,1145937,1,11,"Number 10. Fresh Nelly is waiting on you. Good night husband."
|
||||
w1-cpu-base,1089-134686-0005,9.63,188,191016,2067515,0,22,"The music came nearer and he recalled the words, the words of Shelley's fragment upon the moon wandering companionless pale for weariness."
|
||||
w1-cpu-base,1089-134686-0006,10.55,188,229757,2240907,2,24,"The dull light fell more faintly upon the page, where on another equation began to unfold itself slowly, and to spread abroad its widening tail."
|
||||
w1-cpu-base,1089-134686-0007,4.28,188,90484,890719,0,8,"A cold, lucid indifference reigned in his soul."
|
||||
w1-cpu-base,1089-134686-0008,6.73,188,149468,1484047,1,15,"The chaos in which his order extinguished itself was a cold, indifferent knowledge of himself."
|
||||
w1-cpu-base,1089-134686-0009,10.57,188,245502,2490203,0,27,"At most, by an alms given to a beggar whose blessing he fled from, he might hope wearily to win for himself some measure of actual grace."
|
||||
w1-cpu-base,1089-134686-0010,4.41,188,138481,1458718,0,14,"""Well now, Ennis, I declare you have a head and so has my stick."""
|
||||
w1-cpu-base,1089-134686-0011,12.45,188,297210,3179094,2,39,"On Saturday mornings when the so dallity met in the chapel to recite the little office, his place was a cushioned kneeling desk at the right of the altar from which he led his wing of boys through the responses."
|
||||
w1-cpu-base,1089-134686-0012,11.64,188,213355,2401078,0,28,"Her eyes seemed to regard him with mild pity. Her holiness, a strange light glowing faintly upon her frail flesh, did not humiliate the sinner who approached her."
|
||||
w1-cpu-base,1089-134686-0013,7.92,188,178175,2032875,1,25,"If ever he was impelled to cast sin from him and to repent, the impulse that moved him was the wish to be her night."
|
||||
w1-cpu-base,1089-134686-0014,2.23,188,66047,748750,0,8,"He tried to think how it could be."
|
||||
w1-cpu-base,1089-134686-0015,5.82,188,110044,1269406,0,14,"but the dusk deepening in the schoolroom covered over his thoughts. The bell rang."
|
||||
w1-cpu-base,1089-134686-0016,3.54,188,88080,1015922,1,10,"Then you can ask him questions on the Catechism Daedalus."
|
||||
w1-cpu-base,1089-134686-0017,8.87,188,195530,2211422,0,24,"Stephen, leaning back and drawing idly on his scribbler, listened to the talk about him which Heron checked from time to time by saying,"
|
||||
w1-cpu-base,1089-134686-0018,15.72,188,264086,3047875,0,41,"It was strange, too, that he found an arid pleasure in following up to the end the rigid lines of the doctrines of the church and penetrating into obscure silences only to hear and feel the more deeply his own condemnation."
|
||||
w1-cpu-base,1089-134686-0019,13.89,188,253612,2945906,1,39,"The sentence of St. James, which says that he who offends against one commandment becomes guilty of all, had seemed to him first a swollen phrase, until he had begun to grope in the darkness of his own state."
|
||||
w1-cpu-base,1089-134686-0020,16.79,188,329473,3803813,0,50,"If a man had stolen a pound in his youth and had used that pound to amass a huge fortune, how much was he obliged to give back? The pound he had stolen only, or the pound together with the compound interest accruing upon it, or all his huge fortune."
|
||||
w1-cpu-base,1089-134686-0021,6.55,188,103520,1196969,1,17,"If a layman in giving baptism poor the water before saying the words is the child baptized."
|
||||
w1-cpu-base,1089-134686-0022,11.18,188,225421,2617859,2,32,"How comes it that while the first beattitude promises the kingdom of heaven to the poor of heart, the second beattitude promises also to the meek that they shall possess the land?"
|
||||
w1-cpu-base,1089-134686-0023,13.28,188,239276,2776860,0,36,"Why was the sacrament of the Eucharist instituted under the two species of bread and wine if Jesus Christ be present, body and blood, soul and divinity in the bread alone and in the wine alone?"
|
||||
w1-cpu-base,1089-134686-0024,11.65,188,185962,2162656,0,30,"If the wine change into vinegar and the host crumble into corruption after they have been consecrated, is Jesus Christ still present under their species as God and as man?"
|
||||
w1-cpu-base,1089-134686-0025,6.61,188,113562,1311750,0,18,"A gentle kick from the tall boy in the bench behind urged Stephen to ask a difficult question."
|
||||
w1-cpu-base,1089-134686-0026,4.01,188,102779,1173296,0,13,"The rector did not ask for a catechism to hear the lesson from."
|
||||
w1-cpu-base,1089-134686-0027,2.71,188,69835,812876,0,9,"He clasped his hands on the desk and said,"
|
||||
w1-cpu-base,1089-134686-0028,7.83,188,129944,1503437,2,18,"The retreat will begin on Wednesday afternoon in honor of St. Francis Xavier, whose feast day is Saturday."
|
||||
w1-cpu-base,1089-134686-0029,4.67,188,86115,1006610,0,11,"On Friday, confession will be heard all the afternoon after beads."
|
||||
w2-vulkan-base,1089-134686-0000,10.44,248,1388,1375,2,28,"He hoped there would be stew for dinner, turnips and carrots and bruised potatoes and fat mutton pieces to be ladled out in thick, peppered flower-faton sauce."
|
||||
w2-vulkan-base,1089-134686-0001,3.27,248,491,172,1,8,"Stuff it into you, his belly counseled him."
|
||||
w2-vulkan-base,1089-134686-0002,6.62,248,771,297,0,18,"After early nightfall the yellow lamps would light up here and there the squalid quarter of the brothels."
|
||||
w2-vulkan-base,1089-134686-0003,2.68,248,546,140,0,7,"Hello Bertie, any good in your mind?"
|
||||
w2-vulkan-base,1089-134686-0004,5.22,248,371,219,1,11,"Number 10. Fresh Nelly is waiting on you. Good night husband."
|
||||
w2-vulkan-base,1089-134686-0005,9.63,248,535,375,0,22,"The music came nearer and he recalled the words, the words of Shelley's fragment upon the moon wandering companionless pale for weariness."
|
||||
w2-vulkan-base,1089-134686-0006,10.55,248,316,141,2,24,"The dull light fell more faintly upon the page, where on another equation began to unfold itself slowly, and to spread abroad its widening tail."
|
||||
w2-vulkan-base,1089-134686-0007,4.28,248,261,125,0,8,"A cold, lucid indifference reigned in his soul."
|
||||
w2-vulkan-base,1089-134686-0008,6.73,248,423,202,1,15,"The chaos in which his order extinguished itself was a cold, indifferent knowledge of himself."
|
||||
w2-vulkan-base,1089-134686-0009,10.57,248,525,219,0,27,"At most, by an alms given to a beggar whose blessing he fled from, he might hope wearily to win for himself some measure of actual grace."
|
||||
w2-vulkan-base,1089-134686-0010,4.41,248,268,78,0,14,"""Well now, Ennis, I declare you have a head and so has my stick."""
|
||||
w2-vulkan-base,1089-134686-0011,12.45,248,390,234,2,39,"On Saturday mornings when the so-dality met in the chapel to recite the little office, his place was a cushioned kneeling desk at the right of the altar from which he led his wing of boys through the responses."
|
||||
w2-vulkan-base,1089-134686-0012,11.64,248,761,375,0,28,"Her eyes seemed to regard him with mild pity. Her holiness, a strange light glowing faintly upon her frail flesh, did not humiliate the sinner who approached her."
|
||||
w2-vulkan-base,1089-134686-0013,7.92,248,771,344,1,25,"If ever he was impelled to cast sin from him and to repent, the impulse that moved him was the wish to be her night."
|
||||
w2-vulkan-base,1089-134686-0014,2.23,248,377,126,0,8,"He tried to think how it could be."
|
||||
w2-vulkan-base,1089-134686-0015,5.82,248,419,108,0,14,"but the dusk deepening in the schoolroom covered over his thoughts. The bell rang."
|
||||
w2-vulkan-base,1089-134686-0016,3.54,248,252,78,1,10,"Then you can ask him questions on the Catechism Daedalus."
|
||||
w2-vulkan-base,1089-134686-0017,8.87,248,320,157,0,24,"Stephen, leaning back and drawing idly on his scribbler, listened to the talk about him which Heron checked from time to time by saying,"
|
||||
w2-vulkan-base,1089-134686-0018,15.72,248,454,157,0,41,"It was strange, too, that he found an arid pleasure in following up to the end the rigid lines of the doctrines of the church and penetrating into obscure silences only to hear and feel the more deeply his own condemnation."
|
||||
w2-vulkan-base,1089-134686-0019,13.89,248,756,468,1,39,"The sentence of St. James, which says that he who offends against one commandment becomes guilty of all, had seemed to him first a swollen phrase, until he had begun to grope in the darkness of his own state."
|
||||
w2-vulkan-base,1089-134686-0020,16.79,248,540,172,0,50,"If a man had stolen a pound in his youth and had used that pound to amass a huge fortune, how much was he obliged to give back? The pound he had stolen only, or the pound together with the compound interest accruing upon it, or all his huge fortune."
|
||||
w2-vulkan-base,1089-134686-0021,6.55,248,664,234,1,17,"If a layman in giving baptism poor the water before saying the words is the child baptized."
|
||||
w2-vulkan-base,1089-134686-0022,11.18,248,926,391,2,32,"How comes it that while the first beattitude promises the kingdom of heaven to the poor of heart, the second beattitude promises also to the meek that they shall possess the land?"
|
||||
w2-vulkan-base,1089-134686-0023,13.28,248,849,344,0,36,"Why was the sacrament of the Eucharist instituted under the two species of bread and wine if Jesus Christ be present, body and blood, soul and divinity in the bread alone and in the wine alone?"
|
||||
w2-vulkan-base,1089-134686-0024,11.65,248,883,327,0,30,"If the wine change into vinegar and the host crumble into corruption after they have been consecrated, is Jesus Christ still present under their species as God and as man?"
|
||||
w2-vulkan-base,1089-134686-0025,6.61,248,629,234,0,18,"A gentle kick from the tall boy in the bench behind urged Stephen to ask a difficult question."
|
||||
w2-vulkan-base,1089-134686-0026,4.01,248,649,236,0,13,"The rector did not ask for a catechism to hear the lesson from."
|
||||
w2-vulkan-base,1089-134686-0027,2.71,248,603,186,0,9,"He clasped his hands on the desk and said,"
|
||||
w2-vulkan-base,1089-134686-0028,7.83,248,590,204,2,18,"The retreat will begin on Wednesday afternoon in honor of St. Francis Xavier, whose feast day is Saturday."
|
||||
w2-vulkan-base,1089-134686-0029,4.67,248,553,172,0,11,"On Friday, confession will be heard all the afternoon after beads."
|
||||
w3-vulkan-medium,1089-134686-0000,10.44,724,5211,2187,0,28,"He hoped there would be stew for dinner, turnips and carrots and bruised potatoes and fat mutton pieces to be ladled out in thick peppered flour-fattened sauce."
|
||||
w3-vulkan-medium,1089-134686-0001,3.27,724,2691,625,1,8,"""Stuff it into you,"" his belly counseled him."
|
||||
w3-vulkan-medium,1089-134686-0002,6.62,724,3736,813,0,18,"After early nightfall the yellow lamps would light up here and there the squalid quarter of the brothels."
|
||||
w3-vulkan-medium,1089-134686-0003,2.68,724,1895,484,0,7,"Hello Bertie, any good in your mind?"
|
||||
w3-vulkan-medium,1089-134686-0004,5.22,724,2074,578,1,11,"Number ten! Fresh Nellie is waiting on you. Good night, husband."
|
||||
w3-vulkan-medium,1089-134686-0005,9.63,724,2304,656,0,22,"The music came nearer and he recalled the words, the words of Shelley's fragment upon the moon wandering companionless, pale for weariness."
|
||||
w3-vulkan-medium,1089-134686-0006,10.55,724,2267,641,0,24,"The dull light fell more faintly upon the page whereon another equation began to unfold itself slowly and to spread abroad its widening tail."
|
||||
w3-vulkan-medium,1089-134686-0007,4.28,724,1917,500,0,8,"A cold lucid indifference reigned in his soul."
|
||||
w3-vulkan-medium,1089-134686-0008,6.73,724,2074,516,1,15,"The chaos in which his ardor extinguished itself was a cold indifferent knowledge of himself."
|
||||
w3-vulkan-medium,1089-134686-0009,10.57,724,2433,640,0,27,"At most, by an alms given to a beggar whose blessing he fled from, he might hope wearily to win for himself some measure of actual grace."
|
||||
w3-vulkan-medium,1089-134686-0010,4.41,724,2078,594,0,14,"""Well now, Ennis, I declare you have a head and so has my stick."""
|
||||
w3-vulkan-medium,1089-134686-0011,12.45,724,2687,735,0,39,"On Saturday mornings when the Sodality met in the chapel to recite the little office, his place was a cushioned kneeling desk at the right of the altar from which he led his wing of boys through the responses."
|
||||
w3-vulkan-medium,1089-134686-0012,11.64,724,2495,718,0,28,"Her eyes seemed to regard him with mild pity; her holiness, a strange light glowing faintly upon her frail flesh, did not humiliate the sinner who approached her."
|
||||
w3-vulkan-medium,1089-134686-0013,7.92,724,2318,673,0,25,"If ever he was impelled to cast sin from him and to repent, the impulse that moved him was the wish to be her knight."
|
||||
w3-vulkan-medium,1089-134686-0014,2.23,724,1847,453,0,8,"He tried to think how it could be."
|
||||
w3-vulkan-medium,1089-134686-0015,5.82,724,2144,468,0,14,"but the dusk, deepening in the schoolroom, covered over his thoughts. The bell rang."
|
||||
w3-vulkan-medium,1089-134686-0016,3.54,724,2009,657,1,10,"Then you can ask him questions on the catechism, Daedalus."
|
||||
w3-vulkan-medium,1089-134686-0017,8.87,724,2558,874,7,24,"Stephen, leaning back and drawing idly on his scribbler, listened to the talk about him which Harren checked from time to time by saying, ""I'm not going to do it."""
|
||||
w3-vulkan-medium,1089-134686-0018,15.72,724,2722,876,0,41,"It was strange, too, that he found an arid pleasure in following up to the end the rigid lines of the doctrines of the Church and penetrating into obscure silences only to hear and feel the more deeply his own condemnation."
|
||||
w3-vulkan-medium,1089-134686-0019,13.89,724,2754,735,1,39,"The sentence of St. James, which says that he who offends against one commandment becomes guilty of all, had seemed to him first a swollen phrase, until he had begun to grope in the darkness of his own state."
|
||||
w3-vulkan-medium,1089-134686-0020,16.79,724,3047,1265,0,50,"If a man had stolen a pound in his youth, and had used that pound to amass a huge fortune, how much was he obliged to give back? The pound he had stolen only, or the pound together with the compound interest accruing upon it, or all his huge fortune?"
|
||||
w3-vulkan-medium,1089-134686-0021,6.55,724,2168,547,0,17,"If a layman, in giving baptism, pour the water before saying the words, ""Is the child baptized?"""
|
||||
w3-vulkan-medium,1089-134686-0022,11.18,724,2522,687,0,32,"How comes it that while the first beatitude promises the kingdom of heaven to the poor of heart, the second beatitude promises also to the meek that they shall possess the land?"
|
||||
w3-vulkan-medium,1089-134686-0023,13.28,724,2674,812,0,36,"""Why was the sacrament of the Eucharist instituted under the two species of bread and wine if Jesus Christ be present body and blood, soul and divinity, in the bread alone and in the wine alone?"""
|
||||
w3-vulkan-medium,1089-134686-0024,11.65,724,2397,735,0,30,"If the wine change into vinegar and the host crumble into corruption after they have been consecrated, is Jesus Christ still present under their species as God and as man?"
|
||||
w3-vulkan-medium,1089-134686-0025,6.61,724,2080,578,0,18,"A gentle kick from the tall boy in the bench behind urged Stephen to ask a difficult question."
|
||||
w3-vulkan-medium,1089-134686-0026,4.01,724,2033,578,0,13,"The rector did not ask for a catechism to hear the lesson from."
|
||||
w3-vulkan-medium,1089-134686-0027,2.71,724,2145,515,7,9,"He clasped his hands on the desk and said, ""I'm not going to go to bed."""
|
||||
w3-vulkan-medium,1089-134686-0028,7.83,724,2140,516,2,18,"The retreat will begin on Wednesday afternoon in honor of St. Francis Xavier whose feast day is Saturday."
|
||||
w3-vulkan-medium,1089-134686-0029,4.67,724,1954,672,0,11,"On Friday, confession will be heard all the afternoon after beads."
|
||||
w4-npu-onnx,1089-134686-0000,10.44,4723,975,1062,2,28,"He hoped there would be stew for dinner, turnips and carrots and bruised potatoes and fat mutton pieces to be ladled out in thick, peppered flower-faten sauce."
|
||||
w4-npu-onnx,1089-134686-0001,3.27,4723,286,189,3,8,"Stuffered into you, his belly counseled him."
|
||||
w4-npu-onnx,1089-134686-0002,6.62,4723,409,328,0,18,"After early nightfall the yellow lamps would light up here and there the squalid quarter of the brothels."
|
||||
w4-npu-onnx,1089-134686-0003,2.68,4723,262,172,0,7,"Hello Bertie, any good in your mind?"
|
||||
w4-npu-onnx,1089-134686-0004,5.22,4723,356,265,1,11,"Number 10. Fresh Nelly is waiting on you. Good night, husband."
|
||||
w4-npu-onnx,1089-134686-0005,9.63,4723,475,422,0,22,"The music came nearer, and he recalled the words, the words of Shelley's fragment upon the moon wandering companionless, pale for weariness."
|
||||
w4-npu-onnx,1089-134686-0006,10.55,4723,471,390,2,24,"The dull light fell more faintly upon the page, where on another equation began to unfold itself slowly, and to spread abroad its widening tail."
|
||||
w4-npu-onnx,1089-134686-0007,4.28,4723,261,188,0,8,"A cold, lucid indifference reigned in his soul."
|
||||
w4-npu-onnx,1089-134686-0008,6.73,4723,354,297,1,15,"The chaos in which his order extinguished itself was a cold, indifferent knowledge of himself."
|
||||
w4-npu-onnx,1089-134686-0009,10.57,4723,524,469,0,27,"At most, by an alms given to a beggar whose blessing he fled from, he might hope wearily to win for himself some measure of actual grace."
|
||||
w4-npu-onnx,1089-134686-0010,4.41,4723,372,296,0,14,"""Well now, Ennis, I declare you have a head, and so has my stick."""
|
||||
w4-npu-onnx,1089-134686-0011,12.45,4723,682,626,2,39,"On Saturday mornings, when the so-dality met in the chapel to recite the little office, his place was a cushioned kneeling desk at the right of the altar from which he led his wing of boys through the responses."
|
||||
w4-npu-onnx,1089-134686-0012,11.64,4723,558,468,0,28,"Her eyes seemed to regard him with mild pity. Her holiness, a strange light glowing faintly upon her frail flesh, did not humiliate the sinner who approached her."
|
||||
w4-npu-onnx,1089-134686-0013,7.92,4723,461,376,1,25,"If ever he was impelled to cast sin from him and to repent, the impulse that moved him was the wish to be her night."
|
||||
w4-npu-onnx,1089-134686-0014,2.23,4723,242,156,0,8,"He tried to think how it could be."
|
||||
w4-npu-onnx,1089-134686-0015,5.82,4723,339,282,0,14,"but the dusk deepening in the schoolroom covered over his thoughts. The bell rang."
|
||||
w4-npu-onnx,1089-134686-0016,3.54,4723,338,249,1,10,"Then you can ask him questions on the Catechism Daedalus."
|
||||
w4-npu-onnx,1089-134686-0017,8.87,4723,491,438,0,24,"Stephen, leaning back and drawing idly on his scribbler, listened to the talk about him which Heron checked from time to time by saying,"
|
||||
w4-npu-onnx,1089-134686-0018,15.72,4723,717,624,0,41,"It was strange, too, that he found an arid pleasure in following up to the end the rigid lines of the doctrines of the church and penetrating into obscure silences only to hear and feel the more deeply his own condemnation."
|
||||
w4-npu-onnx,1089-134686-0019,13.89,4723,711,626,1,39,"The sentence of St. James, which says that he who offends against one commandment becomes guilty of all, had seemed to him first a swollen phrase, until he had begun to grope in the darkness of his own state."
|
||||
w4-npu-onnx,1089-134686-0020,16.79,4723,869,796,1,50,"If a man had stolen a pound in his youth, and had used that pound to amass a huge fortune, how much was he obliged to give back? The pound he had stolen only, were the pound together with the compound interest accruing upon it, or all his huge fortune."
|
||||
w4-npu-onnx,1089-134686-0021,6.55,4723,368,281,1,17,"If a layman in giving baptism poor the water before saying the words is the child baptized,"
|
||||
w4-npu-onnx,1089-134686-0022,11.18,4723,593,531,2,32,"How comes it that while the first beattitude promises the kingdom of heaven to the poor of heart, the second beattitude promises also to the meek that they shall possess the land?"
|
||||
w4-npu-onnx,1089-134686-0023,13.28,4723,636,547,0,36,"Why was the sacrament of the Eucharist instituted under the two species of bread and wine if Jesus Christ be present, body and blood, soul and divinity in the bread alone and in the wine alone?"
|
||||
w4-npu-onnx,1089-134686-0024,11.65,4723,567,469,0,30,"If the wine change into vinegar and the host crumble into corruption after they have been consecrated, is Jesus Christ still present under their species as God and as man?"
|
||||
w4-npu-onnx,1089-134686-0025,6.61,4723,371,312,0,18,"A gentle kick from the tall boy in the bench behind urged Stephen to ask a difficult question."
|
||||
w4-npu-onnx,1089-134686-0026,4.01,4723,338,266,0,13,"The rector did not ask for a catechism to hear the lesson from."
|
||||
w4-npu-onnx,1089-134686-0027,2.71,4723,269,203,0,9,"He clasped his hands on the desk and said,"
|
||||
w4-npu-onnx,1089-134686-0028,7.83,4723,371,298,2,18,"The retreat will begin on Wednesday afternoon in honor of St. Francis Xavier whose feast day is Saturday."
|
||||
w4-npu-onnx,1089-134686-0029,4.67,4723,296,233,0,11,"On Friday, confession will be heard all the afternoon after beads."
|
||||
p1-parakeet-t8,1089-134686-0000,10.44,4576,494,4234,0,28," He hoped there would be stew for dinner turnips and carrots and bruised potatoes and fat mutton pieces to be ladled out in thick, peppered, flour fattened sauce."
|
||||
p1-parakeet-t8,1089-134686-0001,3.27,4576,185,1719,0,8," Stuff it into you, his belly counselled him."
|
||||
p1-parakeet-t8,1089-134686-0002,6.62,4576,332,2797,0,18," After early nightfall the yellow lamps would light up, here and there, the squalid quarter of the brothels."
|
||||
p1-parakeet-t8,1089-134686-0003,2.68,4576,164,1344,0,7," Hello, Bertie. Any good in your mind?"
|
||||
p1-parakeet-t8,1089-134686-0004,5.22,4576,229,1844,0,11," Number ten Fresh Nelly is waiting on you. Good night, husband."
|
||||
p1-parakeet-t8,1089-134686-0005,9.63,4576,379,3172,0,22," The music came nearer and he recalled the words the words of Shelley's fragment upon the moon wandering companionless, pale for weariness."
|
||||
p1-parakeet-t8,1089-134686-0006,10.55,4576,401,3499,0,24," The dull light fell more faintly upon the page, whereon another equation began to unfold itself slowly and to spread abroad its widening tail."
|
||||
p1-parakeet-t8,1089-134686-0007,4.28,4576,265,2454,0,8," A cold, lucid indifference reigned in his soul."
|
||||
p1-parakeet-t8,1089-134686-0008,6.73,4576,336,2875,0,15," The chaos in which his ardour extinguished itself was a cold, indifferent knowledge of himself,"
|
||||
p1-parakeet-t8,1089-134686-0009,10.57,4576,431,3578,0,27," At most, by an alms given to a beggar whose blessing he fled from, he might hope wearily to win for himself some measure of actual grace."
|
||||
p1-parakeet-t8,1089-134686-0010,4.41,4576,309,2891,0,14," Well now, Ennis, I declare you have a head, and so has my stick"
|
||||
p1-parakeet-t8,1089-134686-0011,12.45,4576,599,5406,0,39," On Saturday mornings when the sodality met in the chapel to recite the little office, his place was a cushioned kneeling desk at the right of the altar from which he led his wing of boys through the responses."
|
||||
p1-parakeet-t8,1089-134686-0012,11.64,4576,512,4172,0,28," Her eyes seemed to regard him with mild pity her holiness, a strange light glowing faintly upon her frail flesh did not humiliate the sinner who approached her."
|
||||
p1-parakeet-t8,1089-134686-0013,7.92,4576,318,2671,0,25," If ever he was impelled to cast sin from him and to repent, the impulse that moved him was the wish to be her knight."
|
||||
p1-parakeet-t8,1089-134686-0014,2.23,4576,112,860,0,8," He tried to think how it could be"
|
||||
p1-parakeet-t8,1089-134686-0015,5.82,4576,268,2250,0,14," but the dusk, deepening in the schoolroom, covered over his thoughts. The bell rang"
|
||||
p1-parakeet-t8,1089-134686-0016,3.54,4576,223,2015,1,10," Then you can ask him questions on the Catechism, Daedalus."
|
||||
p1-parakeet-t8,1089-134686-0017,8.87,4576,317,2562,0,24," Stephen leaning back and drawing idly on his scribbler, listened to the talk about him, which Heron checked from time to time by saying"
|
||||
p1-parakeet-t8,1089-134686-0018,15.72,4576,738,6234,0,41," It was strange, too, that he found an arid pleasure in following up to the end the rigid lines of the doctrines of the Church, and penetrating into obscure silences only to hear and feel the more deeply his own condemnation."
|
||||
p1-parakeet-t8,1089-134686-0019,13.89,4576,705,5515,0,39," The sentence of Saint James, which says that he who offends against one commandment becomes guilty of all, had seemed to him first a swollen phrase, until he had begun to grope in the darkness of his own state."
|
||||
p1-parakeet-t8,1089-134686-0020,16.79,4576,764,6156,0,50," If a man had stolen a pound in his youth, and had used that pound to amass a huge fortune, how much was he obliged to give back the pound he had stolen only, or the pound together with the compound interest accruing upon it, or all his huge fortune?"
|
||||
p1-parakeet-t8,1089-134686-0021,6.55,4576,308,2437,0,17," If a layman, in giving baptism, pour the water before saying the words, is the child baptized?"
|
||||
p1-parakeet-t8,1089-134686-0022,11.18,4576,566,4797,0,32," How comes it that while the first Beatitude promises the kingdom of heaven to the poor of heart, the second Beatitude promises also to the meek that they shall possess the land?"
|
||||
p1-parakeet-t8,1089-134686-0023,13.28,4576,567,4922,0,36," Why was the sacrament of the Eucharist instituted under the two species of bread and wine, if Jesus Christ be present body and blood, soul and divinity, in the bread alone, and in the wine alone?"
|
||||
p1-parakeet-t8,1089-134686-0024,11.65,4576,458,3719,0,30," If the wine change into vinegar, and the host crumble into corruption, after they have been consecrated, is Jesus Christ still present under their species as God and as man?"
|
||||
p1-parakeet-t8,1089-134686-0025,6.61,4576,308,2640,0,18," A gentle kick from the tall boy in the bench behind urged Stephen to ask a difficult question."
|
||||
p1-parakeet-t8,1089-134686-0026,4.01,4576,238,2406,0,13," The rector did not ask for a catechism to hear the lesson from"
|
||||
p1-parakeet-t8,1089-134686-0027,2.71,4576,162,1391,0,9," He clasped his hands on the desk and said"
|
||||
p1-parakeet-t8,1089-134686-0028,7.83,4576,407,3437,1,18," The retreat will begin on Wednesday afternoon in honour of Saint Xavier, whose feast day is Saturday."
|
||||
p1-parakeet-t8,1089-134686-0029,4.67,4576,209,1798,1,11," On Friday confession will be heard all the afternoon after Bedes."
|
||||
p1-parakeet-t4,1089-134686-0000,10.44,4552,509,2203,0,28," He hoped there would be stew for dinner turnips and carrots and bruised potatoes and fat mutton pieces to be ladled out in thick, peppered, flour fattened sauce."
|
||||
p1-parakeet-t4,1089-134686-0001,3.27,4552,175,968,0,8," Stuff it into you, his belly counselled him."
|
||||
p1-parakeet-t4,1089-134686-0002,6.62,4552,347,1640,0,18," After early nightfall the yellow lamps would light up, here and there, the squalid quarter of the brothels."
|
||||
p1-parakeet-t4,1089-134686-0003,2.68,4552,159,829,0,7," Hello, Bertie. Any good in your mind?"
|
||||
p1-parakeet-t4,1089-134686-0004,5.22,4552,247,1187,0,11," Number ten Fresh Nelly is waiting on you. Good night, husband."
|
||||
p1-parakeet-t4,1089-134686-0005,9.63,4552,446,2140,0,22," The music came nearer and he recalled the words the words of Shelley's fragment upon the moon wandering companionless, pale for weariness."
|
||||
p1-parakeet-t4,1089-134686-0006,10.55,4552,465,2172,0,24," The dull light fell more faintly upon the page, whereon another equation began to unfold itself slowly and to spread abroad its widening tail."
|
||||
p1-parakeet-t4,1089-134686-0007,4.28,4552,232,1157,0,8," A cold, lucid indifference reigned in his soul."
|
||||
p1-parakeet-t4,1089-134686-0008,6.73,4552,325,1515,0,15," The chaos in which his ardour extinguished itself was a cold, indifferent knowledge of himself,"
|
||||
p1-parakeet-t4,1089-134686-0009,10.57,4552,466,2062,0,27," At most, by an alms given to a beggar whose blessing he fled from, he might hope wearily to win for himself some measure of actual grace."
|
||||
p1-parakeet-t4,1089-134686-0010,4.41,4552,210,1140,0,14," Well now, Ennis, I declare you have a head, and so has my stick"
|
||||
p1-parakeet-t4,1089-134686-0011,12.45,4552,603,2734,0,39," On Saturday mornings when the sodality met in the chapel to recite the little office, his place was a cushioned kneeling desk at the right of the altar from which he led his wing of boys through the responses."
|
||||
p1-parakeet-t4,1089-134686-0012,11.64,4552,532,2422,0,28," Her eyes seemed to regard him with mild pity her holiness, a strange light glowing faintly upon her frail flesh did not humiliate the sinner who approached her."
|
||||
p1-parakeet-t4,1089-134686-0013,7.92,4552,373,1704,0,25," If ever he was impelled to cast sin from him and to repent, the impulse that moved him was the wish to be her knight."
|
||||
p1-parakeet-t4,1089-134686-0014,2.23,4552,133,781,0,8," He tried to think how it could be"
|
||||
p1-parakeet-t4,1089-134686-0015,5.82,4552,278,1328,0,14," but the dusk, deepening in the schoolroom, covered over his thoughts. The bell rang"
|
||||
p1-parakeet-t4,1089-134686-0016,3.54,4552,206,1031,1,10," Then you can ask him questions on the Catechism, Daedalus."
|
||||
p1-parakeet-t4,1089-134686-0017,8.87,4552,448,1984,0,24," Stephen leaning back and drawing idly on his scribbler, listened to the talk about him, which Heron checked from time to time by saying"
|
||||
p1-parakeet-t4,1089-134686-0018,15.72,4552,764,3359,0,41," It was strange, too, that he found an arid pleasure in following up to the end the rigid lines of the doctrines of the Church, and penetrating into obscure silences only to hear and feel the more deeply his own condemnation."
|
||||
p1-parakeet-t4,1089-134686-0019,13.89,4552,638,2578,0,39," The sentence of Saint James, which says that he who offends against one commandment becomes guilty of all, had seemed to him first a swollen phrase, until he had begun to grope in the darkness of his own state."
|
||||
p1-parakeet-t4,1089-134686-0020,16.79,4552,865,3688,0,50," If a man had stolen a pound in his youth, and had used that pound to amass a huge fortune, how much was he obliged to give back the pound he had stolen only, or the pound together with the compound interest accruing upon it, or all his huge fortune?"
|
||||
p1-parakeet-t4,1089-134686-0021,6.55,4552,337,1485,0,17," If a layman, in giving baptism, pour the water before saying the words, is the child baptized?"
|
||||
p1-parakeet-t4,1089-134686-0022,11.18,4552,514,2296,0,32," How comes it that while the first Beatitude promises the kingdom of heaven to the poor of heart, the second Beatitude promises also to the meek that they shall possess the land?"
|
||||
p1-parakeet-t4,1089-134686-0023,13.28,4552,609,2688,0,36," Why was the sacrament of the Eucharist instituted under the two species of bread and wine, if Jesus Christ be present body and blood, soul and divinity, in the bread alone, and in the wine alone?"
|
||||
p1-parakeet-t4,1089-134686-0024,11.65,4552,532,2406,0,30," If the wine change into vinegar, and the host crumble into corruption, after they have been consecrated, is Jesus Christ still present under their species as God and as man?"
|
||||
p1-parakeet-t4,1089-134686-0025,6.61,4552,360,1734,0,18," A gentle kick from the tall boy in the bench behind urged Stephen to ask a difficult question."
|
||||
p1-parakeet-t4,1089-134686-0026,4.01,4552,214,1078,0,13," The rector did not ask for a catechism to hear the lesson from"
|
||||
p1-parakeet-t4,1089-134686-0027,2.71,4552,153,812,0,9," He clasped his hands on the desk and said"
|
||||
p1-parakeet-t4,1089-134686-0028,7.83,4552,395,1751,1,18," The retreat will begin on Wednesday afternoon in honour of Saint Xavier, whose feast day is Saturday."
|
||||
p1-parakeet-t4,1089-134686-0029,4.67,4552,270,1375,1,11," On Friday confession will be heard all the afternoon after Bedes."
|
||||
|
@@ -0,0 +1,2 @@
|
||||
contender,file,audio_secs,load_ms,wall_ms,cpu_ms,wer_errors,ref_words,hypothesis
|
||||
p1-parakeet-t8,npu-test,6.92,6863,339,2734,2,16," The quick brown fox jumps over the lazy dog. Wisp assist is testing the neural processing unit."
|
||||
|
@@ -84,10 +84,14 @@ issues from feature briefs.
|
||||
4. Provisional speaker turns (from cheap segmentation) tag segments live; the user may name a
|
||||
speaker, applied immediately to past/future segments (FR-SPK-2).
|
||||
5. The notes view renders Markdown that updates as segments arrive (FR-NOTE-1, FR-NOTE-6).
|
||||
6. On `stop_recording`: finalize the WAV; run full `diarization`, align speaker IDs to segments,
|
||||
apply name mappings; persist transcript JSON + metadata via `storage` (FR-STORE-1). If "Record
|
||||
this meeting" is **off**, delete the working WAV **after** the transcript is finalized; if **on**,
|
||||
keep it as `audio.wav` (ADR-0009, FR-REC-1/4).
|
||||
6. On `stop_recording`: finalize the WAV; run `diarization`, align speaker IDs to segments,
|
||||
apply name mappings; persist transcript JSON + metadata via `storage` (FR-STORE-1). When the mic
|
||||
is on, `audio.wav` is a **dual-channel split** (left = mic, right = loopback; ADR-0005 Phase 3.5,
|
||||
FR-SPK), so attribution is **per-stream**: sherpa clusters the **right channel only** →
|
||||
`Speaker N`, and "You" comes from **left-channel** voice activity. Blind whole-signal clustering
|
||||
+ voiceprint is the mic-off (`summed`) fallback. If "Record this meeting" is **off**, delete the
|
||||
working WAV **after** the transcript is finalized; if **on**, keep it as `audio.wav` (ADR-0009,
|
||||
FR-REC-1/4).
|
||||
7. If an LLM provider is configured: `llm` builds a prompt (transcript + metadata + template),
|
||||
streams a summary/decisions/action-items into the summary panel (FR-LLM-2/4).
|
||||
8. Action items are parsed and presented for confirmation; confirmed ones persist and may raise
|
||||
|
||||
+24
-1
@@ -14,7 +14,8 @@ Default root: `%LOCALAPPDATA%\WhispAssist\` (user-configurable, FR-STORE-2).
|
||||
│ └── spk-eres2net.onnx
|
||||
└── meetings\
|
||||
└── <meeting_id>\ # one folder per meeting (uuid)
|
||||
├── audio.wav # canonical recording — present ONLY if "Record" was on (ADR-0009)
|
||||
├── audio.wav # canonical recording — present ONLY if "Record" was on (ADR-0009). Mic on → stereo split (L=mic/"You", R=loopback/"Speaker", `audio_layout=split`); mic off → summed. Playback folds to mono; export folds to dual-mono (ADR-0005 Phase 3.5)
|
||||
├── voiceprint.wav # mic-only 16kHz sample for "You" re-matching on reprocess of a SUMMED recording (FR-SPK); lives/dies with audio.wav
|
||||
├── transcript.json # canonical transcript (segments+speakers+timings)
|
||||
├── manual_notes.json # raw user-authored notes captured live during recording
|
||||
├── notes.md # the final notes document: manual notes + transcript, merged at finalize
|
||||
@@ -86,6 +87,7 @@ CREATE TABLE meetings (
|
||||
language TEXT, -- detected/selected language code
|
||||
backend_used TEXT, -- npu|nvidia|amd|intel|cpu
|
||||
model_used TEXT, -- e.g. whisper-base
|
||||
audio_layout TEXT, -- split|summed|NULL; how audio.wav's channels are laid out (0009, ADR-0005 Phase 3.5)
|
||||
calendar_event_id TEXT, -- FK -> calendar_events.id (nullable)
|
||||
template_id TEXT, -- note-template id (T8.1, FR-NOTE-5); catalog is
|
||||
-- a built-in Rust list (notes::templates), not a table
|
||||
@@ -380,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).
|
||||
@@ -387,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,9 +201,11 @@ 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)
|
||||
"transcript://segment" { meetingId, segment: TranscriptSegment } // live segments (FR-TRX-2)
|
||||
"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 }
|
||||
"diarization://updated" { meetingId, speakers: SpeakerInfo[] } // after post-pass (FR-SPK)
|
||||
"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
|
||||
"model://progress" { id, receivedBytes, totalBytes }
|
||||
@@ -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 }
|
||||
|
||||
@@ -44,3 +44,20 @@ Detection mapping:
|
||||
## Revisit if
|
||||
Windows ML supersedes DirectML for our models, or a vendor SDK gives materially better NPU
|
||||
throughput than the ONNX/DirectML path.
|
||||
|
||||
## Update (2026-07-16): NPU path uses OpenVINO; decoder split off the CPU
|
||||
|
||||
As built (T3.4 + follow-up), the NPU tier runs on **ONNX Runtime + the OpenVINO EP**
|
||||
(`device_type=NPU`), not DirectML — Intel's prebuilt OpenVINO ORT runtime is downloaded on
|
||||
demand (DirectML remains the *GPU* fallback EP for AMD/Intel in non-Vulkan builds). The engine
|
||||
splits Whisper across devices:
|
||||
|
||||
- **encoder** (fixed shape): OpenVINO **NPU**;
|
||||
- **decoder** (autoregressive, KV-cache greedy via Optimum's merged export): OpenVINO **GPU**
|
||||
(Intel iGPU, same runtime bundle) when available, else the CPU EP —
|
||||
`WA_ONNX_DECODER_DEVICE=cpu|gpu` overrides.
|
||||
|
||||
Rationale: the dynamic-shaped decode loop is a poor NPU fit, and pre-KV-cache it re-ran the full
|
||||
token prefix every step on the CPU — the dominant CPU cost while transcribing. With the merged
|
||||
decoder + iGPU placement, sustained CPU during transcription is near-idle on NPU+iGPU systems.
|
||||
The engine logs both EPs at load (`transcription engine: ONNX …`).
|
||||
|
||||
@@ -32,6 +32,54 @@ labels in the post-meeting pass; this keeps latency low while improving final ac
|
||||
- Speaker IDs (`S1`, `S2`, …) are internal and stable per meeting; name mappings live in the DB
|
||||
and are applied at render/export time, never destructively rewritten onto segments.
|
||||
|
||||
### Phase 3 refinement — per-stream "You" attribution (FR-SPK, 2026-07-14)
|
||||
|
||||
The single-pass-over-the-whole-recording model above blind-clusters a **summed mono** signal
|
||||
(mic + loopback), then guesses which cluster is the user via a voiceprint match. In practice that
|
||||
clustering is unreliable in both directions on the summed signal — it over-split a 2-speaker call
|
||||
into 83 clusters at one threshold and merged two clearly distinct voices into one at another — and
|
||||
the voiceprint can only *label* a cluster, never *create* the separation.
|
||||
|
||||
So when the **microphone is enabled**, WA no longer relies on clustering to find the user:
|
||||
|
||||
- A live **mic-activity timeline** (`audio::MicActivity`) records, per 100 ms of `audio.wav` frame
|
||||
time, when the mic was speech-level — captured in the loopback writer, the one place the mic and
|
||||
loopback exist separately in the recording's own timebase. Those ranges become **"You"** spans
|
||||
directly (no clustering, no embedding).
|
||||
- sherpa clustering is then run over the recording with the "You" ranges **masked out** (zeroed),
|
||||
so it only ever splits the **far side** into `Speaker N`. The mic can never pollute or merge into
|
||||
a far-side cluster.
|
||||
- The timeline is persisted as `mic_activity.json` (retained meetings) so `reprocess_transcript`
|
||||
re-attributes identically without the live capture.
|
||||
|
||||
The original blind-clustering + voiceprint pass **remains the fallback** for mic-off recordings and
|
||||
imports (no timeline). Naming stays uniform (`build_name_map`: "You", then `Speaker 2…`). Segment
|
||||
IDs and the names-in-DB rule are unchanged; only the *source* of the spans changes.
|
||||
|
||||
### Phase 3.5 refinement — dual-channel capture (supersedes Phase 3's timeline, 2026-07-14)
|
||||
|
||||
The Phase 3 timeline above reconstructed the mic/far-side split *after the fact* from a summed-mono
|
||||
`audio.wav` plus a `mic_activity.json` sidecar, masking the mic ranges before clustering. That was
|
||||
reliable at stop but **fragile on reprocess** (re-aligning a sidecar against a mono mix — it
|
||||
collapsed to a single speaker) and carried a parallel persistence path.
|
||||
|
||||
**Decision:** when the mic is enabled, record `audio.wav` as **stereo with the streams separated —
|
||||
left = microphone ("You"), right = system/loopback ("Speaker")** instead of summing them. The
|
||||
separation is then intrinsic to the recording:
|
||||
|
||||
- Diarization runs on the **right channel only** → `Speaker N`; "You" comes from **left-channel**
|
||||
voice activity (`vad_spans`). The mic is never clustered, by construction.
|
||||
- Reprocess recomputes both from the file — no sidecar, no masking; stop and reprocess agree.
|
||||
- Transcription still downmixes (L+R) to the same summed mono; playback folds to mono and bundle
|
||||
export folds to dual-mono so shared/played audio is normal.
|
||||
- A `meetings.audio_layout` flag (`summed` | `split`, carried in the bundle manifest) distinguishes
|
||||
recordings; existing `summed` recordings keep the blind-clustering + voiceprint path. On a stereo
|
||||
render endpoint this is **size-neutral** (the file was already 2ch with the mic summed into both).
|
||||
|
||||
This **retires** `MicActivity` / `mic_activity.json` / masked diarization from Phase 3 (see the
|
||||
retirement note at `attribute_split` in `commands.rs`); the capture-silence handling,
|
||||
`diarize_samples`, and `build_name_map` naming are retained. Segment IDs and names-in-DB unchanged.
|
||||
|
||||
## Revisit if
|
||||
A single model gives joint ASR + diarization with better accuracy, or whisper.cpp gains
|
||||
production diarization.
|
||||
|
||||
@@ -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,148 @@
|
||||
# Parakeet vs Whisper — default-model bake-off (2026-07-16)
|
||||
|
||||
Pre-registered comparison (criteria fixed before any numbers existed — see the plan on branch
|
||||
`test_parakeet`) between WhispAssist's shipping Whisper `base.en` paths and NVIDIA Parakeet
|
||||
TDT 0.6B v2, to decide whether the default transcription model should change.
|
||||
|
||||
## Provenance
|
||||
|
||||
- **Machine:** Intel Core Ultra 5 135U (Meteor Lake: 12C/14T CPU, Intel Arc iGPU, Intel AI Boost
|
||||
NPU, driver 32.0.100.4724), Windows 11 Pro 26200. App version under test: 0.7.3.
|
||||
- **Harness:** `src-tauri/examples/asr_bench.rs` — drives the *production* transcribers
|
||||
(`WhisperTranscriber`, `OnnxTranscriber`) and a sherpa-onnx `TransducerRecognizer`; one
|
||||
contender per process (clean CPU accounting). WER = word-level Levenshtein after
|
||||
lowercase/punctuation-strip normalization (self-tested).
|
||||
- **Corpus:** first 30 LibriSpeech test-clean utterances (241.7 s, 644 reference words), 16 kHz
|
||||
mono, staged by `scripts/bench/fetch-corpus.ps1`. Live latency: sequential 10 s windows over a
|
||||
3-minute concatenation (16 windows).
|
||||
- **Models:** ggml `base.en-q5_1`, `medium.en-q5_0`; WA ONNX `base.en` (merged decoder, encoder
|
||||
on OpenVINO/NPU + decoder on OpenVINO/GPU — EP confirmed in-run);
|
||||
`sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8` (encoder 652 MB + decoder 7 MB + joiner 2 MB).
|
||||
- **Raw data:** `bench-results/raw.csv`, `bench-results/raw-w1-novulkan.csv` (committed).
|
||||
|
||||
## Results — corpus (accuracy + cost)
|
||||
|
||||
| Contender | Engine / device | WER | RTF (wall) | CPU-sec per audio-sec | Load |
|
||||
|---|---|---|---|---|---|
|
||||
| W1 base.en (as shipped, 14 thr) | whisper.cpp, CPU | 2.48 % | ⚠ 20.1 | ⚠ 232.6 | 112 ms |
|
||||
| W1 base.en (thread-cap fix) | whisper.cpp, CPU | 2.48 % | 0.218 | 0.827 | 163 ms |
|
||||
| W2 base.en | whisper.cpp Vulkan, iGPU | 2.64 % | 0.071 | **0.033** | 248 ms |
|
||||
| W3 medium.en | whisper.cpp Vulkan, iGPU | 3.26 % | 0.304 | 0.088 | 724 ms |
|
||||
| W4 base.en (default) | ort ONNX, NPU + iGPU | 3.11 % | 0.058 | 0.049 | ~4.7 s |
|
||||
| **P1 parakeet int8, t8** | sherpa-onnx, CPU | **0.47 %** | **0.047** | 0.396 | ~4.6 s |
|
||||
| **P1 parakeet int8, t4** | sherpa-onnx, CPU | **0.47 %** | 0.049 | 0.224 | ~4.6 s |
|
||||
|
||||
- **Parakeet: 3 errors in 644 words**, vs 20 for the current default — an **85 % relative WER
|
||||
improvement** — while also posting the best RTF in the field, on the CPU alone.
|
||||
- Parakeet at 4 threads costs ≈0.22 CPU-cores sustained (~1.6 % of this machine's 14 threads);
|
||||
the NPU+iGPU Whisper path costs ≈0.05.
|
||||
- ✅ **W1 anomaly RESOLVED (2026-07-16 follow-up):** root cause was `n_threads =
|
||||
available_parallelism()` (14) putting ggml spin-barrier workers on the two slow LP E-cores —
|
||||
every graph-node barrier waits on the slowest worker while the rest burn CPU. 12 threads is
|
||||
healthy (RTF 0.200), 14 collapses (RTF ~22). The original suspect (`opt-level = "z"` /
|
||||
unoptimized ggml) was **disproven**: a verified `/O2` build was equally slow. Fix: default
|
||||
capped at 4 threads, whisper.cpp's own upstream default (`WA_WHISPER_THREADS` env overrides);
|
||||
re-run row above — RTF 20.1 → 0.218 (92×), CPU 232.6 → 0.827 (281×), WER identical.
|
||||
(The build *was* also silently unoptimized — the cmake crate clobbers MSVC `/O2` when no
|
||||
generator is set; fixed via `CMAKE_GENERATOR=Ninja` in `.cargo/config.toml` as build hygiene,
|
||||
though it wasn't the bottleneck.) Raw data: `bench-results/raw-w1-fixed.csv`.
|
||||
- W3 (medium) scoring *worse* than base on this corpus (21 vs 17 errors) is small-sample noise
|
||||
on clean read speech; treat W3's WER as "≈base" here, not as medium being worse in general.
|
||||
|
||||
## Results — live windows (10 s, n=16)
|
||||
|
||||
| Contender | p50 | p95 | max |
|
||||
|---|---|---|---|
|
||||
| W2 Vulkan base.en | 1055 ms | 3722 ms | 4148 ms |
|
||||
| W4 NPU+iGPU (default) | 519 ms | 630 ms | 928 ms |
|
||||
| **P1 parakeet t8 (CPU)** | **450 ms** | **497 ms** | **514 ms** |
|
||||
|
||||
All hold real-time; Parakeet is fastest **and** near-flat (64 ms p50→max spread), which would
|
||||
make the live transcript feel noticeably steadier.
|
||||
|
||||
## Evidence sample (file 1089-134686-0000, 10.4 s)
|
||||
|
||||
> **Reference:** HE HOPED THERE WOULD BE STEW FOR DINNER TURNIPS AND CARROTS AND BRUISED
|
||||
> POTATOES AND FAT MUTTON PIECES TO BE LADLED OUT IN THICK PEPPERED FLOUR FATTENED SAUCE
|
||||
|
||||
- **W4 (default):** "He hoped there would be stew for dinner, turnips and carrots and bruised
|
||||
potatoes and fat mutton pieces to be ladled out in thick peppered **flour-fatted** sauce." (2 err)
|
||||
- **P1 (parakeet):** " He hoped there would be stew for dinner turnips and carrots and bruised
|
||||
potatoes and fat mutton pieces to be ladled out in thick peppered flour fattened sauce" (0 err)
|
||||
|
||||
Note the trade visible even here: Whisper punctuates richly; Parakeet's int8 output carries
|
||||
casing but **sparse punctuation** — a real consideration for notes.md readability (see
|
||||
integration costs).
|
||||
|
||||
## Qualitative pass — real recording (2026-07-16 follow-up)
|
||||
|
||||
`bench-corpus/test_wavs/bank_deposits.wav` (62 s real recording, 48 kHz stereo — financial
|
||||
disclosure read aloud; downmixed/resampled by the production reader). No reference transcript,
|
||||
so transcripts were compared by eye; raw hypotheses in `bench-results/raw-qual-bank-deposits.csv`
|
||||
(its WER column is against a placeholder — ignore it).
|
||||
|
||||
- **P1 (parakeet t4):** cleanest of the three — zero audible-word errors, and the **best
|
||||
punctuation**, including commas around a parenthetical clause ("…the bank deposit details
|
||||
section, which appears later in this statement, for information…"). The "sparse punctuation"
|
||||
concern from the LibriSpeech sample did **not** reproduce on this longer real clip.
|
||||
- **W2 (Vulkan base.en):** near-perfect; one function-word slip ("effective for cash balances
|
||||
*and* your FDIC insured…" — W4 and P1 both hear "*in* your").
|
||||
- **W4 (NPU ONNX, shipping default):** worst of the three — "details" → "**D-Tales**" twice,
|
||||
"are SIPC protected" → "**or** SIPC protected", and a segmentation break at the end
|
||||
("statement period" → "**state. period.**").
|
||||
|
||||
One clip, one speaker, clean audio — directionally consistent with the corpus WERs
|
||||
(P1 ≥ W2 > W4), and it removes punctuation as an argument against Parakeet. Multi-speaker
|
||||
crosstalk remains untested.
|
||||
|
||||
## Pre-registered criteria, evaluated
|
||||
|
||||
| Criterion | Result |
|
||||
|---|---|
|
||||
| (a) WER ≥30 % relatively better than W4 | **PASS** — 85 % better (0.47 % vs 3.11 %) |
|
||||
| (b) CPU rung: beat W1 on WER **and** CPU-seconds | **PASS** — vs the *fixed* W1 (follow-up): 5.3× better WER (0.47 % vs 2.48 %), 3.7× less CPU (0.224 vs 0.827 cpu-sec/audio-sec) |
|
||||
| (b) NPU rung: accelerator execution or CPU ≤ W4 | **FAIL (strictly)** — 0.224 vs 0.049 CPU-sec/audio-sec; accelerated Parakeet not achieved (below) |
|
||||
| (c) Live windows hold real-time | **PASS** — best in field, p95 497 ms |
|
||||
|
||||
**Phase-4 finding (accelerated Parakeet, timeboxed):** the sherpa route is packaging-blocked —
|
||||
`sherpa-rs`'s `directml` feature is mutually exclusive with `download-binaries`
|
||||
(`compile_error!` in `sherpa-rs-sys` 0.6.8), and escaping via `SHERPA_LIB_PATH` + a
|
||||
version-matched prebuilt DirectML archive would require replacing the base dependency
|
||||
configuration for the whole branch, with transducer-on-DirectML performance unproven (and a
|
||||
prior DirectML-on-Intel-GPU crash on record). The genuine route is a TDT decode loop on our own
|
||||
ort/OpenVINO stack (istupakov-style ONNX exports) — estimated **days, not hours**, and only
|
||||
worth it if the NPU rung must flip. The `bench-directml` cargo feature + `p2-parakeet-dml`
|
||||
contender remain in the harness as the documented attempt.
|
||||
|
||||
## Recommendation (per hardware rung)
|
||||
|
||||
1. **CPU-only machines: change the default to Parakeet TDT 0.6B v2 int8.** Even against the
|
||||
*fixed* W1 (thread cap, follow-up above) it is ~5× more accurate and 3.7× cheaper in
|
||||
CPU-seconds at comparable wall RTF. This is the unambiguous win — just no longer by three
|
||||
orders of magnitude, since the W1 pathology itself is fixed.
|
||||
2. **NPU+iGPU machines (this machine): the pre-registered rule says keep Whisper-ONNX** —
|
||||
Parakeet-on-CPU costs 4.6× the CPU of the current near-idle path. The override argument the
|
||||
numbers support: 0.22 cores is still objectively tiny, and it buys 85 % fewer errors plus the
|
||||
steadiest live latency. That trade is the user's call, not the benchmark's; if low-CPU stays
|
||||
the hard constraint, the NPU rung flips only after the ort/OpenVINO TDT work lands.
|
||||
3. **GPU (Vulkan) machines without NPU:** W2 is the cheapest-CPU option (0.033), but Parakeet's
|
||||
accuracy gap applies here too — same judgment call as the NPU rung, cheaper to revisit after
|
||||
the integration items below.
|
||||
|
||||
**Integration costs before any default flip** (not solved by this bench): timestamped segments
|
||||
(sherpa-rs's `transducer` API returns text only; the C API exposes timestamps — small upstream
|
||||
PR or direct `-sys` call), punctuation restoration (sparse on short LibriSpeech clips, though
|
||||
the real-recording qualitative pass showed *good* punctuation on longer audio — may be a
|
||||
non-issue), English-only v2 (v3 is multilingual — the whisper multilingual path must remain for
|
||||
non-English), a ~660 MB model download, and the model catalog/dispatch work (same shape as the
|
||||
0.7.3 merged-decoder swap).
|
||||
|
||||
## Caveats
|
||||
|
||||
- LibriSpeech is clean read speech; WERs are comparative, not absolute product claims. The
|
||||
qualitative pass over a real recording was run as a follow-up (section above) — still
|
||||
single-speaker; multi-speaker meeting audio with crosstalk remains untested.
|
||||
- int8 quantization slightly penalizes Parakeet vs published fp32 figures — it *still* won by
|
||||
85 %; the fp32 bundle exists if a tighter number is ever needed.
|
||||
- Single machine, single run per cell (30 files each). Differences here are far larger than
|
||||
run-to-run noise, but treat third-decimal RTF differences as noise.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,243 @@
|
||||
# Phase 3 design sketch: per-stream speaker attribution (FR-SPK)
|
||||
|
||||
Status: **signed off & in progress**, 2026-07-13. Deepens Phase 3 of
|
||||
[`2026-07-13-diarization-speaker-accuracy.md`](./2026-07-13-diarization-speaker-accuracy.md).
|
||||
The §§1–8 below are the original sketch (ADR-0005 review + alternatives); the **finalized
|
||||
design** the user signed off on is at the top and supersedes the RMS-dominance heuristic.
|
||||
|
||||
---
|
||||
|
||||
## Finalized design (signed off 2026-07-13)
|
||||
|
||||
Simpler than the sketch's dominance heuristic, because the user records on **headphones** so the
|
||||
mic never hears the far side (no bleed):
|
||||
|
||||
> **The microphone stream is "You" by definition. Diarization runs on the loopback (speaker)
|
||||
> audio only, never the mic. Any loopback speaker is "Speaker N".**
|
||||
|
||||
Mechanism, all in **`audio.wav` frame time** (so You-spans and sherpa-spans share one clock):
|
||||
|
||||
1. **Mic-activity timeline** — captured in the loopback writer, the one place mic and loopback
|
||||
exist separately in `audio.wav`'s timebase (`capture_loop`, where `bridge.pull()` is folded
|
||||
in). Per 100 ms, flag whether the pulled mic chunk was speech-level (reuse `VOICE_ENERGY_FLOOR`).
|
||||
Held in a shared `MicActivity` (sibling to `VoiceSample`); persisted for reprocess.
|
||||
2. **At stop / reprocess:**
|
||||
- Collapse active windows → **"You" spans** (dropping sub-`MIN_SPAN_MS` blips).
|
||||
- **Mask** the You ranges out of `audio.wav` (zero them) and run sherpa on the remainder → far
|
||||
side splits into **"Speaker N"** spans. Clustering never sees the mic, so the mic can't
|
||||
pollute or merge with a far-side cluster.
|
||||
- Merge span lists; `assign_by_overlap` unchanged.
|
||||
3. **Voiceprint (Phase 1/2) becomes the fallback** for meetings with no timeline (imports, older
|
||||
recordings). Live-tick voiceprint "You" is retired once the timeline drives labels.
|
||||
|
||||
Timebase note: with the capture-silence fix (FR-CAP-7) `audio.wav` is now continuous, so the
|
||||
frame-indexed timeline aligns cleanly with what sherpa reads back via `read_wav_mono_16k`.
|
||||
|
||||
Implementation status (all landed 2026-07-13/14): **(a)** `MicActivity` struct + capture wiring —
|
||||
done; **(b)** stop-path attribution + masked diarize (`phase3_attribute`) — done; **(c)** persist
|
||||
`mic_activity.json` + reprocess uses it (imports fall back, no timeline) — done; **(d)** ADR-0005 +
|
||||
`docs/02`/`03` amended — done. **Not yet done:** empirical user test; and the *live* 15s tick still
|
||||
uses the Phase 2 voiceprint, not the timeline (a later polish so live labels match the stop result).
|
||||
|
||||
---
|
||||
|
||||
_Original sketch (ADR-0005 review, risks, alternatives) follows._
|
||||
|
||||
Phases 0–2 shipped: cluster explosion tamed (0.7 + 700 ms floor), reprocess re-diarizes with a
|
||||
persistent `voiceprint.wav`, and live "You" reaches the UI. Those all still treat diarization as
|
||||
**blind clustering of a summed mono signal**, then guess which cluster is the mic. Phase 3
|
||||
removes the guess for the mic side by using information WA throws away today.
|
||||
|
||||
---
|
||||
|
||||
## 1. ADR-0005 review — what it commits us to
|
||||
|
||||
ADR-0005 (Accepted, 2026-06-30) decides: sherpa-onnx offline diarization (pyannote segmentation
|
||||
+ ERes2Net embedding + clustering), behind the `diarization::Diarizer` trait, run as a
|
||||
**post-processing pass over recorded audio**, aligned to whisper segments by **timestamp
|
||||
overlap**. Speaker IDs (`S1`…) are internal/stable; names map in the DB, applied at render time,
|
||||
**never rewritten onto segments**.
|
||||
|
||||
What Phase 3 must respect vs. what it may change:
|
||||
|
||||
- **Keeps (contract-level):** the `Diarizer` trait, post-pass-over-audio model, `SpeakerSpan`
|
||||
→ segment alignment by overlap (`assign_by_overlap`), stable internal labels, names-in-DB.
|
||||
Phase 3 produces *more* `SpeakerSpan`s from a better source; it does not rewrite segments or
|
||||
move naming.
|
||||
- **Bends (needs the ADR noted/updated):** ADR-0005 assumes one clustering pass over "the
|
||||
recorded audio." Phase 3 introduces a **second, non-clustered source of spans** (the mic
|
||||
activity timeline) and restricts clustering to the **far-side** audio. That is new enough to
|
||||
warrant an ADR amendment or a short ADR-0005a, because a future reader will otherwise expect
|
||||
all spans to come from sherpa.
|
||||
- **ADR's "Revisit if"** (joint ASR+diarization model, or whisper.cpp diarization) is unrelated
|
||||
— Phase 3 is orthogonal and does not trigger it.
|
||||
|
||||
Conclusion: Phase 3 fits inside the trait and the overlap-alignment contract. The only doc debt
|
||||
is recording that "mic-dominant spans bypass clustering," which is a genuine deviation from
|
||||
ADR-0005's single-source assumption. **Recommend: amend ADR-0005** (Consequences section) rather
|
||||
than a new ADR — same decision, refined.
|
||||
|
||||
---
|
||||
|
||||
## 2. The asset we currently discard
|
||||
|
||||
When the mic is enabled (FR-CAP-7), `start_recording` (`commands.rs:342`) wires two independent
|
||||
16 kHz-mono streams into `spawn_mixer` (`audio/mod.rs:1020`). The `Mixer` (`audio/mod.rs:979`)
|
||||
holds `loopback` and `mic` **time-aligned in the same buffer** and sums them sample-for-sample
|
||||
into the transcript stream — then the per-stream identity is **gone**. Diarization later reads
|
||||
`audio.wav` (the summed signal) and has to reverse-engineer which cluster was the mic. That
|
||||
reversal is the entire reason `voiceprint.rs` exists.
|
||||
|
||||
Two facts make this cheap to exploit:
|
||||
|
||||
1. **Per-stream RMS already exists.** `audio_level(mono, mic)` (`audio/mod.rs:522`) runs on every
|
||||
WASAPI chunk for *both* directions (`is_loopback` at :564, emitted at :678). We already know,
|
||||
per ~10 ms chunk, how loud each side is. We just don't persist it against time.
|
||||
2. **The two streams are already aligned at the mixer**, in the **same timebase the transcript
|
||||
segments derive from** (the streaming worker consumes the mixer's summed output; segment
|
||||
timestamps are cumulative-samples-fed / 16). So a timeline indexed by cumulative mixer-output
|
||||
samples shares the segments' clock — tighter than sherpa spans, which come from `audio.wav`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Core idea
|
||||
|
||||
> The mic stream is a **known** speaker. Don't cluster to find it — record when it's dominant,
|
||||
> call those spans "You", and run sherpa only on the **far-side** audio to split the *other*
|
||||
> participants.
|
||||
|
||||
Pipeline at stop (mic enabled + retained):
|
||||
|
||||
```
|
||||
mixer ─┬─► loopback samples ──► [far-side WAV] ──► sherpa cluster ──► Speaker 2..N spans
|
||||
│ │
|
||||
└─► mic vs loopback RMS per 100 ms ──► mic-dominant ranges ──► "You" spans
|
||||
│
|
||||
merge span lists ──► assign_by_overlap ──► segments
|
||||
```
|
||||
|
||||
The mic-dominant spans need no embedding, no clustering, no voiceprint — they are attribution by
|
||||
construction. Voiceprint (Phases 1–2) stays as the **fallback** for meetings without a timeline
|
||||
(imports, pre-Phase-3 recordings, mic-disabled meetings).
|
||||
|
||||
---
|
||||
|
||||
## 4. The two real risks (why this needs sign-off, not just a ticket)
|
||||
|
||||
### 4a. Acoustic bleed — the mic is not purely "You"
|
||||
|
||||
Without echo cancellation (WA has none — fully local, minimal), a user on **speakers** (not
|
||||
headphones) has the far side playing into the room and back into the mic. Naive "mic has signal →
|
||||
You" would attribute the far side to the user whenever the far side is loud.
|
||||
|
||||
Mitigation (this is why the parent plan says *mic-**dominant**,* not *mic-active*): a range is
|
||||
"You" only when **mic RMS meaningfully exceeds loopback RMS** in that window (e.g.
|
||||
`mic_rms > k · loopback_rms` with `k ≈ 2`, plus an absolute mic-VAD floor so silence isn't
|
||||
"You"). When both are comparably loud → treat as far-side/ambiguous, let sherpa/overlap decide.
|
||||
This degrades gracefully: headphone users get near-perfect mic isolation; speaker users get
|
||||
"You" only on clear self-speech and fall back to the old behavior during overlap. **`k` is a
|
||||
calibration knob, not a constant to bury** — real rooms differ.
|
||||
|
||||
### 4b. Timebase — three clocks, currently reconciled by tolerance
|
||||
|
||||
- **Segments** are in *mixer-output* time (streaming worker over the summed stream).
|
||||
- **sherpa spans** are in *`audio.wav`* time (loopback thread's byte-accurate WAV, mic bridged in
|
||||
at native rate then resampled to 16 kHz on read).
|
||||
- The **new mic-timeline** would be in *mixer-output* time.
|
||||
|
||||
Today `assign_by_overlap` already bridges segments↔sherpa-spans across the first two clocks and
|
||||
tolerates the small skew. Phase 3 merges a *third* source. The mic-timeline is actually the
|
||||
**closest** to segment time (shared origin), so aligning You-spans to segments is *more* reliable
|
||||
than sherpa's. The residual risk is only that You-spans (mixer time) and Speaker-2..N spans
|
||||
(`audio.wav` time) coexist in one merged list with a small relative skew at the boundaries. In
|
||||
practice that costs at most a fraction of a `MIN_SPAN_MS` at each turn boundary — below segment
|
||||
granularity. **Verification item, not a blocker.** If it proves visible, the clean fix is to run
|
||||
the far-side sherpa pass over the mixer's *loopback lane* buffered to a WAV rather than over
|
||||
`audio.wav`, putting everything on one clock; heavier, so deferred until measured.
|
||||
|
||||
---
|
||||
|
||||
## 5. Concrete design
|
||||
|
||||
**Capture (live, near-zero cost).** Add a mic-activity accumulator the mixer writes to. In
|
||||
`Mixer::drain_ready` (or the `mixer_loop`), for each emitted window compute `mic_rms` and
|
||||
`loopback_rms` over the aligned overlap (samples are already in hand there) and append a coarse
|
||||
flag per 100 ms:
|
||||
|
||||
```rust
|
||||
// audio/mod.rs — new, alongside Mixer
|
||||
struct MicActivity { flags: Vec<bool>, /* one per 100 ms of mixer output */ }
|
||||
// ponytail: Vec<bool> at 10 Hz = 10 bytes/s ≈ 36 KB/hour. No ring buffer, no config.
|
||||
```
|
||||
|
||||
Expose it via the same `Arc` sharing pattern `VoiceSample` uses so `stop_recording` can read it.
|
||||
Reuse `audio_level`'s RMS math — do **not** add a second RMS implementation.
|
||||
|
||||
**Persist (retained meetings).** At stop, alongside `voiceprint.wav`, write the timeline as
|
||||
`mic_activity.json` (or pack into an existing artifact) so **reprocess** can reuse it — same
|
||||
ADR-0009 retention/consent gate as `voiceprint.wav`, same lives-and-dies-with-`audio.wav` rule
|
||||
(delete it everywhere `audio.wav` is deleted). Tiny, plaintext-or-sealed to match.
|
||||
|
||||
**Attribute (stop + reprocess).**
|
||||
1. Collapse the flag timeline into `You` `SpeakerSpan`s (merge adjacent flags, drop runs
|
||||
`< MIN_SPAN_MS`, reuse Phase 0's floor).
|
||||
2. Build a **far-side-only** 16 kHz signal: zero out (or excise) mic-dominant ranges from the
|
||||
loopback lane, run the existing `SherpaDiarizer::diarize` over it → `Speaker 2..N` spans
|
||||
(label numbering starts at 2, mirroring `voiceprint::build_name_map`).
|
||||
3. Merge the two span lists; `assign_by_overlap` unchanged.
|
||||
4. Fallback chain: no timeline → today's Phase-1/2 voiceprint path; no models → raw `S1`.
|
||||
|
||||
**Contract/doc changes.**
|
||||
- Amend **ADR-0005** Consequences: "mic-dominant spans are attributed directly from a per-stream
|
||||
activity timeline and bypass clustering; sherpa runs on the far-side residual only."
|
||||
- `docs/02-architecture.md` data-flow: note the mixer emits a mic-activity side-channel.
|
||||
- `docs/03-data-model.md`: add `mic_activity.json` to the meeting-dir list (like `voiceprint.wav`).
|
||||
- No new egress; no new default-on setting; `k` threshold lives in code with a calibration comment.
|
||||
|
||||
**Tests.**
|
||||
- Unit: flag-run → span collapse (boundaries, sub-`MIN_SPAN_MS` drop) — pure, table-driven.
|
||||
- Unit: dominance classifier (`mic`, `loop` RMS pairs → You / far / ambiguous), incl. the bleed
|
||||
case (both loud → not You).
|
||||
- Manual: 2-person call on **headphones** → exactly "You" + 1 speaker, near-zero errors; repeat
|
||||
on **speakers** → verify bleed doesn't mislabel far-side as You.
|
||||
|
||||
---
|
||||
|
||||
## 6. Alternatives considered (and why not)
|
||||
|
||||
- **Do nothing / stop at Phase 2.** Legitimate. Phases 0–2 already fix the reported bugs
|
||||
(83-cluster explosion, reprocess collapse, live "You"). Phase 3 is an **accuracy ceiling
|
||||
raise**, not a bug fix. *If 2-person calls are the dominant case and Phase 2's voiceprint "You"
|
||||
tests well in the field, Phase 3 may not clear the cost/benefit bar — measure Phase 2 first.*
|
||||
This is the ponytail-honest recommendation: **verify Phase 2 empirically before building 3.**
|
||||
- **"Mic active → You" (no dominance test).** Simpler, but wrong under speaker bleed (4a). The
|
||||
dominance test is the minimum that survives real rooms.
|
||||
- **Full source separation / AEC.** Much larger, new deps, defeats "minimal + local." The
|
||||
dominance heuristic gets ~90 % of the benefit for ~5 % of the effort.
|
||||
- **Better voiceprint only** (bigger sample, re-match every tick — already done in Phase 2).
|
||||
Still bottlenecked on sherpa's ability to *form* a clean mic cluster in the summed signal;
|
||||
Phase 3's point is to stop relying on that.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions for sign-off
|
||||
|
||||
1. **Build it now, or verify Phase 2 first?** Recommendation: run the empirical 2-person
|
||||
re-diarization (outstanding for Phases 0–2) and a Phase-2 live "You" check **before**
|
||||
committing to Phase 3. If Phase 2 accuracy is acceptable, Phase 3 becomes optional.
|
||||
2. **Dominance threshold `k` and mic-VAD floor** — accept a code-constant default with a
|
||||
calibration comment (proposed), or expose a hidden setting? Recommendation: constant first.
|
||||
3. **Far-side signal construction** — zero-out mic-dominant ranges in the loopback lane (simple,
|
||||
preserves timeline) vs. excise-and-concat (shorter audio, shifts timestamps → needs remap).
|
||||
Recommendation: **zero-out**, keeps one timebase.
|
||||
4. **ADR: amend 0005 vs. new ADR-0005a?** Recommendation: amend.
|
||||
5. **Scope of `mic_activity.json`** — persist for reprocess (proposed) or compute live-only and
|
||||
accept that reprocess falls back to voiceprint? Persisting is cheap and keeps reprocess at
|
||||
parity.
|
||||
|
||||
## 8. Rough effort
|
||||
|
||||
Backend-only until the far-side pass; no frontend change (labels already flow via Phase 2). Est.
|
||||
~1–1.5 days: mixer side-channel + accumulator (½ day), span collapse + far-side pass + merge
|
||||
(½ day), persistence + reprocess wiring + docs/tests (½ day). Contained, reversible, behind the
|
||||
existing mic-enabled + retention gates.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Fix plan: speaker diarization accuracy ("You", live labels, 83-speaker explosion)
|
||||
|
||||
Status: planned 2026-07-13 (v0.5.2). Investigation confirmed in code; see memanto memories
|
||||
`a9e5f29e` (diagnosis) and the entry referencing this file (fix order). Execute phases **in
|
||||
order** — each phase is independently shippable and verifiable, and later phases assume
|
||||
earlier ones landed.
|
||||
|
||||
## Symptoms (user report, all reproduced against code)
|
||||
|
||||
1. Live recording shows `S1` for every line; the user's own speech should show "You".
|
||||
2. Post-stop diarization of a 2-person conversation produced **83** speakers.
|
||||
3. Re-transcribing (English Medium) collapsed every segment to speaker "You".
|
||||
|
||||
## Root causes (verified anchors)
|
||||
|
||||
| # | Cause | Anchor |
|
||||
|---|-------|--------|
|
||||
| A | Live segments always carry the pre-diarization `"S1"` placeholder | `src-tauri/src/transcription/mod.rs:222`, emitted at `src-tauri/src/commands.rs:480` |
|
||||
| B | The 15s provisional diarization tick relabels only the backend buffer and emits `diarization://updated`, which **no frontend code listens to** (`src/lib/api.ts` has no listener); live view renders raw labels with no name map (`src/lib/views/TranscriptNotes.svelte:527`) | `src-tauri/src/commands.rs:509-567` |
|
||||
| C | "You" (mic voiceprint match) runs only in `stop_recording`, never live | `src-tauri/src/commands.rs:666-695`, `src-tauri/src/diarization/voiceprint.rs` |
|
||||
| D | `reprocess_transcript` never diarizes: fresh segments all default to `"S1"`, then the meeting's **stale** name map (`S1 → "You"` from the original run) is reused, so the whole transcript renders "You" | `src-tauri/src/commands.rs:1646-1742` |
|
||||
| E | Clustering over the **summed mic+loopback mono** WAV with `num_clusters: -1, threshold: 0.5` over-clusters badly (overlapped speech → mixed embeddings; short pyannote chunks → unstable ERes2Net embeddings) | `src-tauri/src/diarization/mod.rs:68-75` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Tame the cluster explosion (do first; everything else is useless at 83 clusters)
|
||||
|
||||
Smallest possible change, lands before Phase 1 so re-diarization doesn't reproduce the
|
||||
83-speaker mess.
|
||||
|
||||
1. In `SherpaDiarizer::new` (`src-tauri/src/diarization/mod.rs:73`) raise `threshold` from
|
||||
`0.5` to `0.7` as a code constant with a comment naming the tuning evidence (83 clusters
|
||||
for 2 speakers at 0.5 on mixed mono audio). sherpa fast-clustering semantics: larger
|
||||
threshold → fewer clusters. Do **not** add a settings knob yet.
|
||||
2. In `segment_to_span`'s caller (`SherpaDiarizer::diarize`), drop spans shorter than
|
||||
700 ms before returning — sub-second chunks carry unstable embeddings and only cause
|
||||
label churn in `assign_by_overlap` (which already keeps a segment's prior label when no
|
||||
span overlaps).
|
||||
3. Unit test: spans under the minimum are filtered; existing overlap tests still pass.
|
||||
4. **Verify empirically**: re-run diarization over the user's retained 2-person recording
|
||||
(the 0.5.2 test meeting) and confirm the cluster count lands near 2–4, not 83. If 0.7
|
||||
still over-clusters, try 0.8 before considering per-meeting configurability.
|
||||
|
||||
Acceptance: 2-person retained recording diarizes to ≤4 speakers.
|
||||
|
||||
## Phase 1 — `reprocess_transcript` re-diarizes + persistent mic voiceprint (fixes "everything is You")
|
||||
|
||||
1. **Persist the voiceprint at stop.** In `stop_recording`
|
||||
(`src-tauri/src/commands.rs` after the transcription worker join), when
|
||||
`session.mic_voice_sample` exists **and** `session.retention` is on, write the mic
|
||||
sample as 16 kHz mono `voiceprint.wav` next to `audio.wav` in `meeting_dir(&meeting_id)`.
|
||||
ADR-0009 gate: it is retained audio of the user's voice, so it lives and dies with
|
||||
`audio.wav` — every code path that deletes `audio.wav` (retention off at finalize,
|
||||
delete-recording command) must delete `voiceprint.wav` too. Update
|
||||
`docs/03-data-model.md` (meeting dir file list).
|
||||
2. **Re-diarize on reprocess.** In `reprocess_transcript` (`src-tauri/src/commands.rs:1646`),
|
||||
after `transcribe_file`: build the diarizer via `diarizer_from_installed_models`
|
||||
(inside `spawn_blocking`, same as `stop_recording`), `diarize(&wav_path)`, then
|
||||
`assign(&mut segments, &spans)`. Missing models → skip gracefully (same degradation as
|
||||
live).
|
||||
3. **Rebuild the name map instead of reusing the stale one.** Old labels are meaningless
|
||||
after re-clustering. If `voiceprint.wav` exists, run
|
||||
`voiceprint::match_mic_speaker` against the new spans to get a fresh
|
||||
`You`/`Speaker N` map; otherwise use an empty map (raw `S1…` labels).
|
||||
Pass `speaker_infos_from_segments(&segments, &new_names)` to `finalize_meeting`
|
||||
instead of `meeting.speakers` (`src-tauri/src/commands.rs:1713`), and persist the new
|
||||
names via the store the same way `stop_recording` does. Deliberate policy: user-typed
|
||||
names from the original run are dropped on reprocess because they key to dead labels —
|
||||
note this in the command's doc comment.
|
||||
4. `import_media` reuses this path (`commands.rs:1825` "same batch path"); confirm imported
|
||||
meetings get diarized speakers too (they have no voiceprint — expect raw labels).
|
||||
5. Tests: unit test the fresh-name-map policy (stale map not reused); manual: re-transcribe
|
||||
the 0.5.2 meeting with English Medium → segments show distinct speakers, the user's own
|
||||
lines show "You".
|
||||
|
||||
Acceptance: re-transcription yields per-speaker labels again, with "You" on the mic
|
||||
speaker when a voiceprint exists; never a single-speaker collapse.
|
||||
|
||||
## Phase 2 — Live labels reach the UI, including live "You" (fixes "S1 for everyone")
|
||||
|
||||
Backend (`src-tauri/src/commands.rs`, the 15s tick at 509–567):
|
||||
|
||||
1. After `diarizer.assign` in the tick, **re-emit every committed segment whose speaker
|
||||
changed** via the existing `transcript://segment` event (ids are stable; the frontend
|
||||
store already replaces by id — `src/lib/stores/recording.svelte.ts:49-53`). No new
|
||||
event needed for relabeling.
|
||||
2. In the same tick, run `voiceprint::match_mic_speaker` with
|
||||
`session.mic_voice_sample.samples()` against the fresh spans (clusters re-shuffle every
|
||||
tick, so match every tick; candidate audio is already capped at 10 s per cluster).
|
||||
Merge results into `session.speaker_names` **without overwriting user-set names** —
|
||||
same already-named guard as the post-stop pass (`commands.rs:676-683`). The tick's
|
||||
existing `diarization://updated` emit then carries the "You" display name.
|
||||
3. Frontend: add an `onDiarizationUpdated` listener to `src/lib/api.ts` (payload
|
||||
`{ meetingId, speakers: SpeakerInfo[] }`, already documented in
|
||||
`docs/04-api-contracts.md:188`). Recording store gains a `speakers` state updated by
|
||||
it; live rendering at `TranscriptNotes.svelte:527` passes that list to `speakerName`
|
||||
(the finalized path at :344 already does this).
|
||||
4. Docs: note in `docs/04-api-contracts.md` that `transcript://segment` may re-emit a
|
||||
committed segment with an updated `speaker` (replace-by-id contract).
|
||||
5. Tests: frontend store test — a re-emitted segment with the same id replaces the old
|
||||
one; manual: during a live 2-person call, own speech flips to "You" within ~15–30 s.
|
||||
|
||||
Acceptance: during recording, labels differentiate speakers and the mic speaker shows
|
||||
"You" while talking (within one tick), not just after stop.
|
||||
|
||||
## Phase 3 — Per-stream attribution (accuracy end-game, larger change, needs design sign-off)
|
||||
|
||||
Blind clustering of a summed mono signal is the ceiling on accuracy. WA knows which
|
||||
samples are mic before `spawn_mixer` sums them (`commands.rs:342-371`,
|
||||
`src-tauri/src/audio/mod.rs` MicBridge/mixer). Design sketch — do NOT start without
|
||||
reviewing ADR-0005 and getting sign-off, since it changes the diarization contract:
|
||||
|
||||
- Record a coarse mic-activity timeline during capture (e.g. per-100 ms mic-RMS-dominant
|
||||
flags, negligible memory).
|
||||
- At stop: mic-dominant ranges become "You" spans directly; run sherpa only over the
|
||||
remaining (far-side) ranges to split the *other* participants; merge span lists before
|
||||
`assign_by_overlap`.
|
||||
- Voiceprint match remains as fallback for meetings without the timeline (imports, old
|
||||
recordings).
|
||||
- Update ADR-0005 (or add a new ADR) + `docs/02-architecture.md` data flow.
|
||||
|
||||
Acceptance: 2-person call yields exactly "You" + 1 speaker with near-zero attribution
|
||||
errors on non-overlapping speech; overlapping speech attributes to the dominant stream.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting rules for the executing agent
|
||||
|
||||
- CLAUDE.md applies in full: `cargo fmt` + `cargo clippy -- -D warnings`, no
|
||||
`unwrap()`/`expect()` on user-reachable paths, conventional commits referencing
|
||||
FR-SPK-*, commit each file right after finishing it (one commit per file), docs updated
|
||||
in the same change when contracts move.
|
||||
- Memory discipline: `memanto agent activate whispassist` first; `memanto remember` every
|
||||
decision/tuning result (especially the empirical threshold from Phase 0) with full
|
||||
metadata; record phase completion so the next agent knows where to resume.
|
||||
- No new egress, no new settings defaults ON; `voiceprint.wav` is local retained audio
|
||||
under the existing ADR-0009 consent/retention gate.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Plan: dual-channel capture (mic = left, loopback = right) — FR-SPK / FR-CAP
|
||||
|
||||
Status: **implemented** 2026-07-14 (all 3 sign-off answers: DB column; retire MicActivity with a
|
||||
code note; export → dual-mono). Migration 0009 + `write_wav_split` + `attribute_split` +
|
||||
`fold_wav` + ADR-0005 Phase 3.5. Supersedes the summed-mono + masked-diarize approach
|
||||
in [`2026-07-13-diarization-phase3-per-stream-attribution.md`](./2026-07-13-diarization-phase3-per-stream-attribution.md).
|
||||
The proposed **ADR-0005 amendment** is at the bottom — apply it on approval.
|
||||
|
||||
## Motivation
|
||||
|
||||
Every diarization problem this month traces to one root: **`audio.wav` is a summed mono mix** of
|
||||
mic + loopback, so the two sources are irrecoverable after the fact. Phase 3 works around that by
|
||||
(a) recording a separate `mic_activity.json` "You" timeline and (b) diarizing the mix with the You
|
||||
ranges masked out. It works at stop but is fragile on **reprocess** — the persisted timeline has to
|
||||
re-align against freshly-transcribed segments over a mono mix, and it currently collapses to a
|
||||
single "Speaker 2". The timelines are being written correctly (verified: real `you_spans`), so the
|
||||
failure is the masked-mono re-alignment itself, not the capture.
|
||||
|
||||
**Fix the root, not the instance:** keep both sources *physically separate in the recording* by
|
||||
recording **stereo — left channel = microphone ("You"), right channel = system/loopback
|
||||
("Speaker")**. The separation then lives in the file forever; reprocess and diarization always have
|
||||
both streams; no sidecar timeline, no masking, no summed-mono clustering.
|
||||
|
||||
## Key facts that make this cheap
|
||||
|
||||
- `audio.wav` is **already 2-channel** on a normal (stereo) render endpoint — today we *sum the mic
|
||||
into both channels*. Writing mic→L and loopback(downmixed)→R is the **same channel count, same
|
||||
bytes** — no size increase in the common case.
|
||||
- Transcription already downmixes to mono: `read_wav_mono_16k` averaging L+R of a split file yields
|
||||
`(mic + loopback)/2` = the same summed signal it gets today, so **transcription is unchanged**.
|
||||
- The mic↔loopback alignment machinery (`MicBridge`, the drain fix, the capture-silence fix) carries
|
||||
over unchanged — we just route the aligned samples to two channels instead of summing them.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Capture write path (`audio`)
|
||||
- Force the recording spec to **2ch, 16-bit, native rate** when the mic is enabled ("split"
|
||||
layout). Loopback-only (mic off) keeps today's behavior.
|
||||
- New writer (replacing `write_wav_bytes`'s summing for split recordings): per frame,
|
||||
**L = mic sample** (0 when the mic underran), **R = downmix(loopback channels)**.
|
||||
- Idle path (loopback silent): **L = drained mic, R = 0** — same silence-synthesis + device-paced
|
||||
drain we just fixed, now writing to L instead of summing.
|
||||
|
||||
### 2. Layout versioning (distinguish old "summed" from new "split")
|
||||
Content alone can't tell a summed-stereo file from a split-stereo one, so mark it explicitly:
|
||||
- Add `audio_layout TEXT` to the `meetings` row: `'summed'` (default / NULL for all existing rows
|
||||
and imports) or `'split'` (new mic-enabled recordings).
|
||||
- Include it in the **`MeetingBundle` manifest** so exported recordings stay interpretable on
|
||||
another machine.
|
||||
- Playback and diarization branch on it; old recordings keep every current code path untouched.
|
||||
|
||||
### 3. Read helpers (`audio`)
|
||||
- `read_wav_mono_16k` — **unchanged** (downmix all channels → mono 16k). Used for transcription of
|
||||
both layouts; on a split file it returns the summed signal, exactly as wanted.
|
||||
- New `read_wav_channel_16k(path, channel)` — extract one channel → mono 16k. Diarization reads the
|
||||
**right** channel (far side); "You" detection reads the **left** (mic).
|
||||
|
||||
### 4. Diarization / attribution (`commands`) — *simpler* than Phase 3
|
||||
For a **split** recording, one path replaces the timeline + masking:
|
||||
```
|
||||
far_spans = diarizer.diarize_samples( read_wav_channel_16k(wav, RIGHT) ) // clean far side
|
||||
you_spans = vad_spans( read_wav_channel_16k(wav, LEFT) ) // energy-windowed mic
|
||||
merge + assign_by_overlap + build_name_map("You") // You + Speaker N
|
||||
```
|
||||
- The mic is **never** in the clustering input — structurally, not procedurally.
|
||||
- `vad_spans` is the `MicActivity` windowing logic (energy floor + 100 ms windows) applied to the L
|
||||
channel from the file — so **stop and reprocess share one path** and produce identical results.
|
||||
- **This deletes `MicActivity`, `mic_activity.json` (persist + read + delete), and `mask_ranges`.**
|
||||
Net: less code than the current Phase 3.
|
||||
- Mic-off (`'summed'`) recordings keep the whole-signal pass + voiceprint fallback.
|
||||
|
||||
### 5. Playback (`commands::serve_recording`)
|
||||
- A split file plays mic-in-left / computer-in-right — wrong for a listener. On playback, **downmix
|
||||
L+R → mono** (dual-mono) so both sources come out both ears.
|
||||
- `serve_recording` already reads+decrypts the whole file into memory; add a downmix-to-mono step
|
||||
for `'split'` layout and serve that mono WAV (range/seek computed over the transcoded bytes).
|
||||
`'summed'` files stream as today.
|
||||
|
||||
### 6. Migration & portability
|
||||
- **Existing recordings** (`'summed'`): every current path unchanged. Their reprocess falls back to
|
||||
the voiceprint match (acceptable — they predate split). We may retire the `MicActivity`/mask code
|
||||
once split is default; the handful of recent test recordings lose Phase 3 on reprocess, which is
|
||||
fine.
|
||||
- **Bundle export**: export **downmixes the canonical split `audio.wav` to mono** so a shared file
|
||||
plays normally in any player; the working file stays split for WA's own re-processing.
|
||||
|
||||
## What this removes / simplifies
|
||||
|
||||
| Removed | Replaced by |
|
||||
|---|---|
|
||||
| `MicActivity` struct + capture wiring | L-channel VAD read from the file |
|
||||
| `mic_activity.json` (write/read/delete) | nothing — recompute from L channel |
|
||||
| `mask_ranges` + masked diarize | diarize the R channel directly |
|
||||
| stop vs reprocess divergence | one shared split-attribution path |
|
||||
|
||||
The capture-silence fix, the device-paced `drain`, `diarize_samples`, and the `build_name_map`
|
||||
naming all **stay**.
|
||||
|
||||
## Costs / risks (none fatal)
|
||||
- **New code:** split writer, `audio_layout` column + migration + manifest field, channel read
|
||||
helper, playback downmix, `vad_spans`, and the split-vs-summed branch. Bigger than a patch, but
|
||||
it *retires* comparable Phase 3 code.
|
||||
- **Loopback stereo image lost** (R = loopback downmixed to mono) — irrelevant for speech.
|
||||
- **Mono render endpoint** (rare): 1ch→2ch doubles that recording's size.
|
||||
- **Raw file plays split** in a third-party player until exported (export downmixes) — minor.
|
||||
- **Clock drift** between mic/loopback now shows as tiny L/R desync instead of dropped summed
|
||||
samples — harmless (diarization tolerates it; playback sums it away).
|
||||
|
||||
## Open questions for sign-off
|
||||
1. Layout marker: **DB column** (proposed) vs. a self-describing WAV chunk? DB column is simpler and
|
||||
travels via the bundle manifest; recommend it.
|
||||
2. Retire `MicActivity`/`mask_ranges` outright, or keep them for existing `'summed'` recordings'
|
||||
reprocess? Recommend **retire** (summed → voiceprint fallback) to avoid two live systems.
|
||||
3. Export downmix to mono — agree that's the right default for shared/synced copies?
|
||||
|
||||
## Implementation order
|
||||
1. `audio_layout` column + `MeetingBundle` field + read on `get_meeting` (no behavior yet).
|
||||
2. Split writer + force 2ch when mic on; set `audio_layout='split'`. (`audio` + `start_recording`.)
|
||||
3. `read_wav_channel_16k` + `vad_spans`; split-attribution path in stop + reprocess.
|
||||
4. Playback downmix for split; bundle-export downmix.
|
||||
5. Delete `MicActivity` / `mic_activity.json` / `mask_ranges`; update tests.
|
||||
6. Apply the ADR-0005 amendment + update `docs/02`/`03`.
|
||||
|
||||
---
|
||||
|
||||
## Proposed ADR-0005 amendment (apply on approval)
|
||||
|
||||
> ### Phase 3.5 refinement — dual-channel capture (FR-SPK/FR-CAP, 2026-07-14)
|
||||
>
|
||||
> The Phase 3 per-stream attribution (above) reconstructs the mic/far-side split *after the fact*
|
||||
> from a summed-mono `audio.wav` plus a `mic_activity.json` timeline, then masks the mic ranges
|
||||
> before clustering. This is reliable at stop but fragile on reprocess (re-aligning a sidecar
|
||||
> timeline against a mono mix), and it carries a parallel persistence path.
|
||||
>
|
||||
> **Decision:** when the microphone is enabled, record `audio.wav` as **stereo with the streams
|
||||
> separated — left = microphone ("You"), right = system/loopback ("Speaker")** — instead of summing
|
||||
> them. The separation is then intrinsic to the recording:
|
||||
> - Diarization runs on the **right channel only** → `Speaker N`; "You" comes from **left-channel**
|
||||
> voice activity. The mic is never clustered, by construction.
|
||||
> - Reprocess recomputes both from the file — no sidecar timeline, no masking.
|
||||
> - Transcription still downmixes (L+R) to the same summed mono it uses today; playback and bundle
|
||||
> export downmix to mono so shared/played audio is normal.
|
||||
> - A `meetings.audio_layout` flag (`summed` | `split`, in the bundle manifest for portability)
|
||||
> distinguishes recordings; existing `summed` recordings keep the original blind-clustering +
|
||||
> voiceprint path. On a stereo render endpoint this is **size-neutral** (the file was already 2ch
|
||||
> with the mic summed into both).
|
||||
>
|
||||
> This **supersedes** `MicActivity` / `mic_activity.json` / masked diarization from Phase 3; the
|
||||
> capture-silence handling, `diarize_samples`, and the `build_name_map` naming are retained. Segment
|
||||
> IDs and the names-in-DB rule are unchanged.
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "whispassist",
|
||||
"private": true,
|
||||
"version": "0.5.2",
|
||||
"version": "0.7.5",
|
||||
"type": "module",
|
||||
"description": "Privacy-first, fully local Windows meeting assistant.",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
@@ -11,7 +11,7 @@
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"tauri": "tauri",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"lint": "node scripts/check-i18n.mjs && prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Best-effort system-counter capture alongside a bench run: total CPU utility
|
||||
# + GPU compute-engine utilization (and NPU engines where the OS exposes them;
|
||||
# absent counters are skipped by typeperf). Primary CPU metric remains the
|
||||
# harness's in-process CPU-seconds — this is corroborating evidence.
|
||||
param(
|
||||
[string]$Out = "bench-results\counters.csv",
|
||||
[int]$Seconds = 120
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
New-Item -ItemType Directory -Force (Split-Path -Parent $Out) | Out-Null
|
||||
$counters = @(
|
||||
"\Processor Information(_Total)\% Processor Utility",
|
||||
"\GPU Engine(*engtype_Compute)\Utilization Percentage",
|
||||
"\NPU Engine(*)\Utilization Percentage"
|
||||
)
|
||||
# typeperf exits nonzero if ANY counter is invalid; probe each and keep the valid ones.
|
||||
$valid = @()
|
||||
foreach ($c in $counters) {
|
||||
typeperf $c -sc 1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) { $valid += $c }
|
||||
}
|
||||
if ($valid.Count -eq 0) { throw "no usable counters on this machine" }
|
||||
typeperf $valid -si 1 -sc $Seconds -f CSV -o $Out -y | Out-Null
|
||||
Write-Output "captured $($valid.Count) counter set(s) for ${Seconds}s -> $Out"
|
||||
@@ -0,0 +1,47 @@
|
||||
# Stages the WER corpus for asr_bench: the first N LibriSpeech test-clean
|
||||
# utterances as 16 kHz mono WAV + one reference .txt each, under bench-corpus\
|
||||
# at the repo root (git-ignored — audio never enters the repo).
|
||||
param([int]$Utterances = 30)
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
|
||||
throw "ffmpeg is required on PATH (same requirement as the app's Import feature)"
|
||||
}
|
||||
|
||||
$repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
|
||||
$corpus = Join-Path $repo "bench-corpus"
|
||||
$cache = "$env:LOCALAPPDATA\WhispAssist\models\bench-cache"
|
||||
New-Item -ItemType Directory -Force $corpus, $cache | Out-Null
|
||||
|
||||
$tarball = Join-Path $cache "test-clean.tar.gz"
|
||||
if (-not (Test-Path $tarball)) {
|
||||
Write-Output "downloading LibriSpeech test-clean (~346 MB)"
|
||||
curl.exe -sL -o "$tarball.part" "https://www.openslr.org/resources/12/test-clean.tar.gz"
|
||||
if ($LASTEXITCODE -ne 0) { throw "download failed ($LASTEXITCODE)" }
|
||||
Move-Item -Force "$tarball.part" $tarball
|
||||
}
|
||||
$extracted = Join-Path $cache "LibriSpeech\test-clean"
|
||||
if (-not (Test-Path $extracted)) {
|
||||
Write-Output "extracting"
|
||||
tar -xzf $tarball -C $cache
|
||||
if ($LASTEXITCODE -ne 0) { throw "extract failed ($LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
$flacs = Get-ChildItem -Recurse -Filter *.flac $extracted | Sort-Object FullName |
|
||||
Select-Object -First $Utterances
|
||||
foreach ($f in $flacs) {
|
||||
$name = $f.BaseName
|
||||
$wav = Join-Path $corpus "$name.wav"
|
||||
if (-not (Test-Path $wav)) {
|
||||
ffmpeg -v error -y -i $f.FullName -ar 16000 -ac 1 $wav
|
||||
if ($LASTEXITCODE -ne 0) { throw "ffmpeg failed on $name" }
|
||||
}
|
||||
$trans = Get-ChildItem -Path $f.DirectoryName -Filter *.trans.txt | Select-Object -First 1
|
||||
$line = Get-Content $trans.FullName | Where-Object { $_.StartsWith("$name ") } | Select-Object -First 1
|
||||
if (-not $line) { throw "no reference line for $name" }
|
||||
# Named parameters on purpose: positional binding alongside -NoNewline
|
||||
# silently wrote nothing under Windows PowerShell 5.1 (found the hard way);
|
||||
# the WER normalizer doesn't care about a trailing newline.
|
||||
Set-Content -Path (Join-Path $corpus "$name.txt") -Value $line.Substring($name.Length + 1) -Encoding utf8
|
||||
}
|
||||
Write-Output "staged $($flacs.Count) utterances in $corpus"
|
||||
@@ -0,0 +1,25 @@
|
||||
# Stages the Parakeet TDT 0.6B v2 int8 bundle (sherpa-onnx export) for the
|
||||
# asr_bench harness (test_parakeet branch). Bench-only artifact: lives next to
|
||||
# the app's models but under bench-parakeet\, never read by the app itself.
|
||||
$ErrorActionPreference = "Stop"
|
||||
$dest = "$env:LOCALAPPDATA\WhispAssist\models\bench-parakeet"
|
||||
$url = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2"
|
||||
$cache = "$env:LOCALAPPDATA\WhispAssist\models\bench-cache"
|
||||
New-Item -ItemType Directory -Force $cache | Out-Null
|
||||
$archive = Join-Path $cache "parakeet-tdt-0.6b-v2-int8.tar.bz2"
|
||||
|
||||
if (Test-Path (Join-Path $dest "tokens.txt")) {
|
||||
Write-Output "already staged: $dest"
|
||||
exit 0
|
||||
}
|
||||
if (-not (Test-Path $archive)) {
|
||||
Write-Output "downloading $url"
|
||||
curl.exe -sL -o "$archive.part" $url
|
||||
if ($LASTEXITCODE -ne 0) { throw "download failed ($LASTEXITCODE)" }
|
||||
Move-Item -Force "$archive.part" $archive
|
||||
}
|
||||
New-Item -ItemType Directory -Force $dest | Out-Null
|
||||
tar -xjf $archive -C $dest --strip-components=1
|
||||
if ($LASTEXITCODE -ne 0) { throw "extract failed ($LASTEXITCODE)" }
|
||||
Get-ChildItem $dest | Select-Object Name, Length
|
||||
Write-Output "staged: $dest"
|
||||
@@ -0,0 +1,22 @@
|
||||
# Runs the full pre-registered contender matrix sequentially (one process per
|
||||
# contender — clean CPU accounting, no runtime co-tenancy), then the live-window
|
||||
# latency runs on the realistic live rungs.
|
||||
$ErrorActionPreference = "Continue"
|
||||
$repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
|
||||
Set-Location $repo
|
||||
$exe = "C:\wt\release\examples\asr_bench.exe"
|
||||
$live = "bench-corpus\test_wavs\live-concat.wav"
|
||||
|
||||
$contenders = @(
|
||||
"w1-cpu-base", "w2-vulkan-base", "w3-vulkan-medium",
|
||||
"w4-npu-onnx", "p1-parakeet-t8", "p1-parakeet-t4"
|
||||
)
|
||||
foreach ($c in $contenders) {
|
||||
Write-Output "===== corpus: $c ====="
|
||||
& $exe $c --corpus bench-corpus --out bench-results\raw.csv 2>&1
|
||||
}
|
||||
foreach ($c in @("w2-vulkan-base", "w4-npu-onnx", "p1-parakeet-t8")) {
|
||||
Write-Output "===== live: $c ====="
|
||||
& $exe $c --live $live 2>&1
|
||||
}
|
||||
Write-Output "===== matrix done ====="
|
||||
@@ -0,0 +1,32 @@
|
||||
// i18n key-parity check: every locale file must have exactly en.json's keys,
|
||||
// and every translation must use the same {placeholders} as its English
|
||||
// source. Runs as part of `npm run lint` — English fallback otherwise hides
|
||||
// missing translations silently (they shipped untranslated for months once).
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
|
||||
const DIR = new URL("../src/lib/i18n/", import.meta.url);
|
||||
const en = JSON.parse(readFileSync(new URL("en.json", DIR), "utf8"));
|
||||
const placeholders = (s) =>
|
||||
[...s.matchAll(/\{(\w+)\}/g)]
|
||||
.map((m) => m[1])
|
||||
.sort()
|
||||
.join(",");
|
||||
|
||||
let failed = false;
|
||||
for (const file of readdirSync(DIR).filter((f) => f.endsWith(".json") && f !== "en.json")) {
|
||||
const dict = JSON.parse(readFileSync(new URL(file, DIR), "utf8"));
|
||||
const missing = Object.keys(en).filter((k) => !(k in dict));
|
||||
const extra = Object.keys(dict).filter((k) => !(k in en));
|
||||
const badVars = Object.keys(dict).filter(
|
||||
(k) => k in en && placeholders(dict[k]) !== placeholders(en[k]),
|
||||
);
|
||||
for (const k of missing) console.error(`${file}: missing "${k}"`);
|
||||
for (const k of extra) console.error(`${file}: extra "${k}" (not in en.json)`);
|
||||
for (const k of badVars) console.error(`${file}: "${k}" placeholders differ from en.json`);
|
||||
if (missing.length || extra.length || badVars.length) failed = true;
|
||||
}
|
||||
if (failed) {
|
||||
console.error("\ni18n parity check FAILED — keep every locale in lockstep with en.json.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("i18n parity OK");
|
||||
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.5.2"
|
||||
version = "0.7.5"
|
||||
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.5.2"
|
||||
version = "0.7.5"
|
||||
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.
|
||||
@@ -125,6 +126,10 @@ diarization = ["dep:sherpa-rs"] # sherpa-onnx
|
||||
pst = [] # shells out to readpst (libpst) — no crate dep, see ADR-0008 update
|
||||
# Phase 9
|
||||
sync = ["dep:keyring"] # remote upload (WebDAV + OAuth providers)
|
||||
# Bench-only (test_parakeet branch): DirectML-enabled sherpa-onnx binaries so
|
||||
# asr_bench's P2 contender can try Parakeet on the iGPU. NEVER a default
|
||||
# feature — it swaps the sherpa runtime the whole binary links against.
|
||||
bench-directml = ["sherpa-rs?/directml"]
|
||||
# Phase 10
|
||||
mcp = [
|
||||
"dep:rmcp", "dep:keyring", "dep:hyper", "dep:hyper-util",
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
//! ASR bench harness — the Parakeet-vs-Whisper bake-off (test_parakeet branch).
|
||||
//!
|
||||
//! Drives the *production* transcribers (`WhisperTranscriber`, `OnnxTranscriber`)
|
||||
//! plus a sherpa-onnx `TransducerRecognizer` (Parakeet TDT) over a shared corpus,
|
||||
//! and reports WER / RTF / process-CPU-seconds per contender. One contender per
|
||||
//! process invocation, deliberately: (1) process CPU time then measures exactly
|
||||
//! one engine, and (2) the ort crate (OpenVINO runtime) and sherpa's bundled
|
||||
//! onnxruntime never coexist in one address space.
|
||||
//!
|
||||
//! Usage:
|
||||
//! asr_bench <contender> [--corpus DIR] [--out CSV]
|
||||
//! asr_bench <contender> --live WAV # sequential 10 s window latency
|
||||
//! asr_bench --self-test # WER scorer assertions
|
||||
//!
|
||||
//! Contenders: w1-cpu-base | w2-vulkan-base | w3-vulkan-medium | w4-npu-onnx |
|
||||
//! p1-parakeet-t<N> (N = sherpa num_threads)
|
||||
//!
|
||||
//! Corpus layout: {name}.wav (16 kHz mono) + {name}.txt reference per utterance
|
||||
//! (staged by scripts/bench/fetch-corpus.ps1).
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use whispassist_lib::audio::read_wav_mono_16k;
|
||||
use whispassist_lib::models::BackendId;
|
||||
use whispassist_lib::paths;
|
||||
use whispassist_lib::transcription::{
|
||||
onnx_models, AudioWindow, OnnxTranscriber, Transcriber, WhisperTranscriber,
|
||||
};
|
||||
|
||||
const WINDOW_SECS: usize = 10;
|
||||
const SAMPLE_RATE: usize = 16_000;
|
||||
|
||||
// ---- process CPU time (kernel + user) via GetProcessTimes — no crate
|
||||
// features needed, kernel32 is always linked on Windows. ----
|
||||
#[cfg(windows)]
|
||||
mod cputime {
|
||||
#[repr(C)]
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct Filetime {
|
||||
lo: u32,
|
||||
hi: u32,
|
||||
}
|
||||
extern "system" {
|
||||
fn GetCurrentProcess() -> isize;
|
||||
fn GetProcessTimes(
|
||||
h: isize,
|
||||
creation: *mut Filetime,
|
||||
exit: *mut Filetime,
|
||||
kernel: *mut Filetime,
|
||||
user: *mut Filetime,
|
||||
) -> i32;
|
||||
}
|
||||
/// Cumulative process CPU time in milliseconds.
|
||||
pub fn process_cpu_ms() -> u64 {
|
||||
let (mut c, mut e, mut k, mut u) = (
|
||||
Filetime::default(),
|
||||
Filetime::default(),
|
||||
Filetime::default(),
|
||||
Filetime::default(),
|
||||
);
|
||||
// SAFETY: pseudo-handle + four valid out-pointers, per the API contract.
|
||||
let ok = unsafe { GetProcessTimes(GetCurrentProcess(), &mut c, &mut e, &mut k, &mut u) };
|
||||
if ok == 0 {
|
||||
return 0;
|
||||
}
|
||||
let ms = |f: Filetime| (((f.hi as u64) << 32) | f.lo as u64) / 10_000; // 100 ns units
|
||||
ms(k) + ms(u)
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
mod cputime {
|
||||
pub fn process_cpu_ms() -> u64 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
// ---- WER ----
|
||||
|
||||
/// Lowercase, strip everything but alphanumerics/apostrophes, split to words —
|
||||
/// LibriSpeech references are uppercase without punctuation; hypotheses carry
|
||||
/// casing + punctuation, so both sides normalize through here.
|
||||
fn normalize(s: &str) -> Vec<String> {
|
||||
s.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '\'' {
|
||||
c
|
||||
} else {
|
||||
' '
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split_whitespace()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Word-level Levenshtein distance (two-row DP).
|
||||
fn edit_distance(a: &[String], b: &[String]) -> usize {
|
||||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||
let mut cur = vec![0usize; b.len() + 1];
|
||||
for (i, wa) in a.iter().enumerate() {
|
||||
cur[0] = i + 1;
|
||||
for (j, wb) in b.iter().enumerate() {
|
||||
let sub = prev[j] + usize::from(wa != wb);
|
||||
cur[j + 1] = sub.min(prev[j + 1] + 1).min(cur[j] + 1);
|
||||
}
|
||||
std::mem::swap(&mut prev, &mut cur);
|
||||
}
|
||||
prev[b.len()]
|
||||
}
|
||||
|
||||
fn self_test() {
|
||||
assert_eq!(edit_distance(&normalize("a b c"), &normalize("a b c")), 0);
|
||||
assert_eq!(edit_distance(&normalize("a b c"), &normalize("a x c")), 1); // sub
|
||||
assert_eq!(edit_distance(&normalize("a b c"), &normalize("a c")), 1); // del
|
||||
assert_eq!(edit_distance(&normalize("a c"), &normalize("a b c")), 1); // ins
|
||||
assert_eq!(
|
||||
normalize("The QUICK, brown-fox's."),
|
||||
vec!["the", "quick", "brown", "fox's"]
|
||||
);
|
||||
assert!(edit_distance(&normalize("hello world"), &normalize("")) == 2);
|
||||
println!("self-test OK");
|
||||
}
|
||||
|
||||
// ---- engines ----
|
||||
|
||||
// ponytail: one Engine per process for its whole lifetime — variant size skew is irrelevant.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum Engine {
|
||||
Whisper(WhisperTranscriber),
|
||||
Onnx(OnnxTranscriber),
|
||||
Parakeet(std::sync::Mutex<sherpa_rs::transducer::TransducerRecognizer>),
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
fn transcribe_wav(&self, wav: &Path) -> Result<String, String> {
|
||||
match self {
|
||||
Engine::Whisper(t) => Ok(join_segments(
|
||||
t.transcribe_file(wav).map_err(|e| e.to_string())?,
|
||||
)),
|
||||
Engine::Onnx(t) => Ok(join_segments(
|
||||
t.transcribe_file(wav).map_err(|e| e.to_string())?,
|
||||
)),
|
||||
Engine::Parakeet(r) => {
|
||||
let samples = read_wav_mono_16k(wav).map_err(|e| e.to_string())?;
|
||||
Ok(r.lock()
|
||||
.map_err(|_| "poisoned".to_string())?
|
||||
.transcribe(SAMPLE_RATE as u32, &samples))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One live window; returns the wall time only (text is discarded — this
|
||||
/// mode measures latency, corpus mode measures accuracy).
|
||||
fn time_window(&self, samples: Vec<f32>, offset_ms: u64) -> Result<u128, String> {
|
||||
let t0 = Instant::now();
|
||||
match self {
|
||||
Engine::Whisper(t) => {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
t.transcribe_stream(AudioWindow { samples, offset_ms }, tx)
|
||||
.map_err(|e| e.to_string())?;
|
||||
drop(rx);
|
||||
}
|
||||
Engine::Onnx(t) => {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
t.transcribe_stream(AudioWindow { samples, offset_ms }, tx)
|
||||
.map_err(|e| e.to_string())?;
|
||||
drop(rx);
|
||||
}
|
||||
Engine::Parakeet(r) => {
|
||||
let _ = r
|
||||
.lock()
|
||||
.map_err(|_| "poisoned".to_string())?
|
||||
.transcribe(SAMPLE_RATE as u32, &samples);
|
||||
}
|
||||
}
|
||||
Ok(t0.elapsed().as_millis())
|
||||
}
|
||||
}
|
||||
|
||||
fn join_segments(segs: Vec<whispassist_lib::models::TranscriptSegment>) -> String {
|
||||
segs.iter()
|
||||
.map(|s| s.text.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// First file in `dir` whose name starts with `prefix` and ends with `.onnx`.
|
||||
fn find_onnx(dir: &Path, prefix: &str) -> Result<PathBuf, String> {
|
||||
std::fs::read_dir(dir)
|
||||
.map_err(|e| format!("{}: {e}", dir.display()))?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.find(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with(prefix) && n.ends_with(".onnx"))
|
||||
})
|
||||
.ok_or_else(|| format!("no {prefix}*.onnx under {}", dir.display()))
|
||||
}
|
||||
|
||||
fn load_engine(contender: &str) -> Result<Engine, String> {
|
||||
let whisper = |model: &str, backend: BackendId| -> Result<Engine, String> {
|
||||
let path = paths::whisper_model_file(model);
|
||||
WhisperTranscriber::load(&path, backend, Some("en"))
|
||||
.map(Engine::Whisper)
|
||||
.map_err(|e| e.to_string())
|
||||
};
|
||||
match contender {
|
||||
"w1-cpu-base" => whisper("base.en-q5_1", BackendId::Cpu),
|
||||
"w2-vulkan-base" => whisper("base.en-q5_1", BackendId::Intel),
|
||||
"w3-vulkan-medium" => whisper("medium.en-q5_0", BackendId::Intel),
|
||||
"w4-npu-onnx" => {
|
||||
let dir = onnx_models::model_dir(onnx_models::DEFAULT_ONNX_MODEL);
|
||||
OnnxTranscriber::load(&dir, BackendId::Npu, None)
|
||||
.map(Engine::Onnx)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
p if p.starts_with("p1-parakeet-t") || p.starts_with("p2-parakeet-dml-t") => {
|
||||
let dml = p.starts_with("p2-");
|
||||
let threads: i32 = p
|
||||
.rsplit_once('t')
|
||||
.and_then(|(_, n)| n.parse().ok())
|
||||
.ok_or_else(|| format!("bad thread count in '{p}'"))?;
|
||||
let dir = paths::models_dir().join("bench-parakeet");
|
||||
let config = sherpa_rs::transducer::TransducerConfig {
|
||||
encoder: find_onnx(&dir, "encoder")?.to_string_lossy().into_owned(),
|
||||
decoder: find_onnx(&dir, "decoder")?.to_string_lossy().into_owned(),
|
||||
joiner: find_onnx(&dir, "joiner")?.to_string_lossy().into_owned(),
|
||||
tokens: dir.join("tokens.txt").to_string_lossy().into_owned(),
|
||||
model_type: "nemo_transducer".to_string(),
|
||||
decoding_method: "greedy_search".to_string(),
|
||||
sample_rate: SAMPLE_RATE as i32,
|
||||
feature_dim: 80,
|
||||
num_threads: threads,
|
||||
// "directml" needs the bench-directml cargo feature (DirectML
|
||||
// sherpa-onnx binaries); with plain binaries sherpa falls back
|
||||
// noisily and the run is invalid — P2 rows only count from a
|
||||
// bench-directml build.
|
||||
provider: Some(if dml { "directml" } else { "cpu" }.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
sherpa_rs::transducer::TransducerRecognizer::new(config)
|
||||
.map(|r| Engine::Parakeet(std::sync::Mutex::new(r)))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
other => Err(format!(
|
||||
"unknown contender '{other}' (w1-cpu-base | w2-vulkan-base | w3-vulkan-medium | w4-npu-onnx | p1-parakeet-t<N> | p2-parakeet-dml-t<N>)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- modes ----
|
||||
|
||||
fn csv_escape(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
fn run_corpus(contender: &str, engine: &Engine, load_ms: u128, corpus: &Path, out: &Path) {
|
||||
let mut wavs: Vec<PathBuf> = std::fs::read_dir(corpus)
|
||||
.unwrap_or_else(|e| panic!("corpus dir {}: {e}", corpus.display()))
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "wav"))
|
||||
.collect();
|
||||
wavs.sort();
|
||||
assert!(!wavs.is_empty(), "no wavs in {}", corpus.display());
|
||||
|
||||
if let Some(parent) = out.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create out dir");
|
||||
}
|
||||
let new_file = !out.exists();
|
||||
let mut csv = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(out)
|
||||
.expect("open csv");
|
||||
if new_file {
|
||||
writeln!(
|
||||
csv,
|
||||
"contender,file,audio_secs,load_ms,wall_ms,cpu_ms,wer_errors,ref_words,hypothesis"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let (mut errors, mut words, mut wall_total, mut cpu_total, mut audio_total) =
|
||||
(0usize, 0usize, 0u128, 0u64, 0f64);
|
||||
for wav in &wavs {
|
||||
let stem = wav.file_stem().unwrap().to_string_lossy().into_owned();
|
||||
let reference = std::fs::read_to_string(wav.with_extension("txt")).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"missing reference {}: {e}",
|
||||
wav.with_extension("txt").display()
|
||||
)
|
||||
});
|
||||
let audio_secs = read_wav_mono_16k(wav)
|
||||
.map(|s| s.len() as f64 / SAMPLE_RATE as f64)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let cpu0 = cputime::process_cpu_ms();
|
||||
let t0 = Instant::now();
|
||||
let hypothesis = match engine.transcribe_wav(wav) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("FAILED {stem}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let wall = t0.elapsed().as_millis();
|
||||
let cpu = cputime::process_cpu_ms() - cpu0;
|
||||
|
||||
let r = normalize(&reference);
|
||||
let h = normalize(&hypothesis);
|
||||
let err = edit_distance(&r, &h);
|
||||
errors += err;
|
||||
words += r.len();
|
||||
wall_total += wall;
|
||||
cpu_total += cpu;
|
||||
audio_total += audio_secs;
|
||||
|
||||
writeln!(
|
||||
csv,
|
||||
"{contender},{stem},{audio_secs:.2},{load_ms},{wall},{cpu},{err},{},{}",
|
||||
r.len(),
|
||||
csv_escape(&hypothesis)
|
||||
)
|
||||
.unwrap();
|
||||
println!(
|
||||
"{stem}: {audio_secs:.1}s wall={wall}ms cpu={cpu}ms wer={err}/{}",
|
||||
r.len()
|
||||
);
|
||||
}
|
||||
|
||||
println!("---- {contender} summary ----");
|
||||
println!(
|
||||
"files: {} audio: {audio_total:.1}s load: {load_ms}ms",
|
||||
wavs.len()
|
||||
);
|
||||
println!(
|
||||
"WER: {:.2}% ({errors}/{words})",
|
||||
100.0 * errors as f64 / words.max(1) as f64
|
||||
);
|
||||
println!(
|
||||
"RTF (wall): {:.3}",
|
||||
wall_total as f64 / 1000.0 / audio_total.max(0.001)
|
||||
);
|
||||
println!(
|
||||
"CPU-sec per audio-sec: {:.3}",
|
||||
cpu_total as f64 / 1000.0 / audio_total.max(0.001)
|
||||
);
|
||||
}
|
||||
|
||||
fn run_live(contender: &str, engine: &Engine, wav: &Path) {
|
||||
let samples = read_wav_mono_16k(wav).expect("read live wav");
|
||||
let chunk = WINDOW_SECS * SAMPLE_RATE;
|
||||
let mut latencies: Vec<u128> = Vec::new();
|
||||
for (i, part) in samples.chunks(chunk).enumerate() {
|
||||
if part.len() < SAMPLE_RATE {
|
||||
continue; // sub-second tail: skip, same spirit as MIN_DIARIZE_SAMPLES
|
||||
}
|
||||
let ms = engine
|
||||
.time_window(part.to_vec(), (i * WINDOW_SECS * 1000) as u64)
|
||||
.expect("window decode");
|
||||
println!("window {i}: {ms}ms");
|
||||
latencies.push(ms);
|
||||
}
|
||||
latencies.sort_unstable();
|
||||
let pct = |p: f64| latencies[(((latencies.len() - 1) as f64) * p) as usize];
|
||||
println!(
|
||||
"---- {contender} live ({WINDOW_SECS}s windows, n={}) ----",
|
||||
latencies.len()
|
||||
);
|
||||
println!(
|
||||
"p50={}ms p95={}ms max={}ms (budget: {}ms)",
|
||||
pct(0.50),
|
||||
pct(0.95),
|
||||
latencies.last().unwrap(),
|
||||
WINDOW_SECS * 1000
|
||||
);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.iter().any(|a| a == "--self-test") {
|
||||
self_test();
|
||||
return;
|
||||
}
|
||||
let contender = args
|
||||
.first()
|
||||
.expect("usage: asr_bench <contender> [--corpus DIR|--live WAV]");
|
||||
let flag = |name: &str| {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(PathBuf::from)
|
||||
};
|
||||
let corpus = flag("--corpus").unwrap_or_else(|| PathBuf::from("bench-corpus"));
|
||||
let out = flag("--out").unwrap_or_else(|| PathBuf::from("bench-results/raw.csv"));
|
||||
|
||||
let t0 = Instant::now();
|
||||
let engine = match load_engine(contender) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("load failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let load_ms = t0.elapsed().as_millis();
|
||||
println!("{contender}: loaded in {load_ms}ms");
|
||||
|
||||
match flag("--live") {
|
||||
Some(wav) => run_live(contender, &engine, &wav),
|
||||
None => run_corpus(contender, &engine, load_ms, &corpus, &out),
|
||||
}
|
||||
}
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- WhispAssist dual-channel capture (FR-SPK/FR-CAP). Forward-only migration.
|
||||
-- Mirrors docs/03-data-model.md.
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- How audio.wav stores its channels:
|
||||
-- NULL / 'summed' — mic summed into every loopback channel (all pre-2026-07-14
|
||||
-- recordings and every import): one mixed mono-equivalent.
|
||||
-- 'split' — stereo, left = microphone ("You"), right = system/loopback
|
||||
-- ("Speaker"), kept separate so diarization runs on the far
|
||||
-- side alone. New mic-enabled recordings.
|
||||
-- Diarization, playback (downmix), and bundle export branch on this.
|
||||
ALTER TABLE meetings ADD COLUMN audio_layout TEXT;
|
||||
+354
-25
@@ -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>>;
|
||||
@@ -201,16 +218,37 @@ impl MicBridge {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loopback idle path: take everything buffered at once, device-paced. The
|
||||
/// idle writer must NOT meter the mic by wall-clock — that lets the buffer
|
||||
/// back up to `cap` and drop the oldest samples, which sounds like the audio
|
||||
/// skipping/fast-forwarding late in a long silent stretch. Draining fully
|
||||
/// keeps the mic flowing at its own clock.
|
||||
fn drain(&self) -> Vec<f32> {
|
||||
self.buf
|
||||
.lock()
|
||||
.map(|mut buf| buf.drain(..).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Below this per-chunk RMS the mic is treated as silence/room noise and not
|
||||
/// collected into a voiceprint. Without it the voiceprint was just the first
|
||||
/// few seconds of the recording regardless of whether the user had spoken —
|
||||
/// so a user silent for the first 30s got a noise voiceprint that spuriously
|
||||
/// matched a cluster and mislabeled everyone "You". ponytail: fixed floor for
|
||||
/// normalized [-1,1] speech; promote to a calibration knob if real mics vary.
|
||||
const VOICE_ENERGY_FLOOR: f32 = 0.01;
|
||||
|
||||
/// A one-shot, bounded capture of raw mic-only audio (16kHz mono, same format
|
||||
/// the transcriber and diarizer both use) taken early in a recording — enough
|
||||
/// to compute a voiceprint that identifies which diarized speaker cluster is
|
||||
/// the mic (so it can be labeled "You" instead of a clustered "S1"/"S2"; see
|
||||
/// `diarization::voiceprint`). Unlike `MicBridge`, this is filled once and
|
||||
/// never drained: the first `cap` samples are kept and everything after is
|
||||
/// dropped, since a voiceprint only needs a few seconds of real speech, not
|
||||
/// the whole meeting.
|
||||
/// the transcriber and diarizer both use) taken from the user's early *speech*
|
||||
/// — enough to compute a voiceprint that identifies which diarized speaker
|
||||
/// cluster is the mic (so it can be labeled "You" instead of a clustered
|
||||
/// "S1"/"S2"; see `diarization::voiceprint`). Unlike `MicBridge`, this is filled
|
||||
/// once and never drained: the first `cap` samples of speech-level audio are
|
||||
/// kept and everything after is dropped, since a voiceprint only needs a few
|
||||
/// seconds of real speech, not the whole meeting. Silence is skipped so the
|
||||
/// window tracks when the user actually talks, not the wall clock.
|
||||
pub struct VoiceSample {
|
||||
cap: usize,
|
||||
buf: Mutex<Vec<f32>>,
|
||||
@@ -225,6 +263,18 @@ impl VoiceSample {
|
||||
}
|
||||
|
||||
fn push(&self, samples: &[f32]) {
|
||||
// Skip silence/room noise so the voiceprint is the user's voice, not
|
||||
// whatever the mic picked up before they first spoke (see
|
||||
// VOICE_ENERGY_FLOOR). RMS inline so VoiceSample stays independent of
|
||||
// the `audio`-gated level helper.
|
||||
let rms = if samples.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
(samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt()
|
||||
};
|
||||
if rms < VOICE_ENERGY_FLOOR {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut buf) = self.buf.lock() {
|
||||
if buf.len() < self.cap {
|
||||
buf.extend_from_slice(samples);
|
||||
@@ -240,6 +290,12 @@ impl VoiceSample {
|
||||
}
|
||||
}
|
||||
|
||||
// MicActivity (the live mic-speech timeline) was retired 2026-07-14 with the
|
||||
// masked-mono diarization it fed — the split (mic-left/loopback-right) layout
|
||||
// makes the mic/far-side separation intrinsic to audio.wav, so "You" is
|
||||
// recomputed from the left channel via `vad_spans`. See the retirement note in
|
||||
// commands.rs (attribute_split).
|
||||
|
||||
/// Number of audio frames in a raw WASAPI byte buffer of the given format.
|
||||
#[cfg(feature = "audio")]
|
||||
fn frame_count(bytes: &[u8], format: &WaveFormat) -> usize {
|
||||
@@ -273,12 +329,15 @@ impl WasapiCapture {
|
||||
event_sink: EventSink,
|
||||
bridge: Option<Arc<MicBridge>>,
|
||||
voice_sample: Option<Arc<VoiceSample>>,
|
||||
split: bool,
|
||||
emit_level: bool,
|
||||
) -> 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);
|
||||
|
||||
@@ -293,8 +352,10 @@ impl WasapiCapture {
|
||||
&event_sink,
|
||||
&running_th,
|
||||
&paused_th,
|
||||
&muted_th,
|
||||
bridge.as_ref(),
|
||||
voice_sample.as_ref(),
|
||||
split,
|
||||
emit_level,
|
||||
)
|
||||
})
|
||||
@@ -303,6 +364,7 @@ impl WasapiCapture {
|
||||
Ok(CaptureHandle {
|
||||
running,
|
||||
paused,
|
||||
muted,
|
||||
thread,
|
||||
})
|
||||
}
|
||||
@@ -327,7 +389,8 @@ impl WasapiCapture {
|
||||
event_sink,
|
||||
Some(bridge),
|
||||
None,
|
||||
true,
|
||||
true, // split: mic-enabled loopback records mic-left / loopback-right
|
||||
true, // emit_level
|
||||
)
|
||||
}
|
||||
|
||||
@@ -352,7 +415,8 @@ impl WasapiCapture {
|
||||
event_sink,
|
||||
Some(bridge),
|
||||
voice_sample,
|
||||
true,
|
||||
false, // split: mic stream has no writer
|
||||
true, // emit_level
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -375,7 +439,8 @@ impl AudioCapture for WasapiCapture {
|
||||
event_sink,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
false, // split: loopback-only / mic stream — no split writer
|
||||
true, // emit_level
|
||||
)
|
||||
}
|
||||
|
||||
@@ -394,7 +459,8 @@ impl AudioCapture for WasapiCapture {
|
||||
event_sink,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
false, // split: loopback-only / mic stream — no split writer
|
||||
true, // emit_level
|
||||
)
|
||||
}
|
||||
|
||||
@@ -540,6 +606,18 @@ fn audio_level(mono: &[f32], mic: bool) -> AudioLevel {
|
||||
/// without flooding the frontend with an event per ~10ms WASAPI callback.
|
||||
const LEVEL_EMIT_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
/// When the loopback (render) endpoint is idle, WASAPI delivers no packets, so
|
||||
/// the recording would stall and the bridged mic — the user talking over system
|
||||
/// silence — would be lost as the MicBridge overflows. The loopback loop then
|
||||
/// synthesizes silence for the elapsed gap and folds the mic in, keeping
|
||||
/// `audio.wav` continuous and complete (FR-CAP-7). MIN gates on a *sustained*
|
||||
/// gap so brief inter-packet stalls during active playback don't punch silence
|
||||
/// into the loopback (audible clicks); it stays well under the MicBridge's
|
||||
/// ~0.5s cap so the mic can't back up and drop samples between drains. MAX caps
|
||||
/// the silence padding written in one chunk.
|
||||
const SILENCE_TICK_MIN: Duration = Duration::from_millis(150);
|
||||
const SILENCE_TICK_MAX: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Runs on a dedicated OS thread for the lifetime of a `CaptureHandle`. Owns the
|
||||
/// WASAPI client and the WAV writer; exits (and finalizes the WAV) once `running`
|
||||
/// is cleared.
|
||||
@@ -553,8 +631,15 @@ 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
|
||||
// is a separate track, not summed in) so diarization can run on the far side
|
||||
// alone. Only meaningful for the loopback writer.
|
||||
split: bool,
|
||||
emit_level: bool,
|
||||
) -> Result<CaptureSummary, AudioError> {
|
||||
wasapi::initialize_mta()
|
||||
@@ -566,14 +651,18 @@ fn capture_loop(
|
||||
// UI can overlay them — but only the loopback stream writes a WAV.
|
||||
let mut session = open_capture_session(&direction, device_id)?;
|
||||
// Loopback publishes its rate so the mic knows what to resample to before
|
||||
// pushing into the shared bridge (mic-into-recording, FR-CAP-7).
|
||||
// pushing into the shared bridge (mic-into-recording, FR-CAP-7), and so the
|
||||
// Phase 3 mic-activity timeline maps frame positions to windows.
|
||||
if is_loopback {
|
||||
if let Some(b) = bridge {
|
||||
b.rate
|
||||
.store(session.format.get_samplespersec(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
let spec = wav_spec_for(&session.format)?;
|
||||
let mut spec = wav_spec_for(&session.format)?;
|
||||
if split && is_loopback {
|
||||
spec.channels = 2; // L = mic, R = loopback (downmixed)
|
||||
}
|
||||
let mut writer = match wav_path {
|
||||
Some(path) => Some(WavWriter::create(path, spec).map_err(|e| {
|
||||
AudioError::Capture(format!("could not create {}: {e}", path.display()))
|
||||
@@ -593,6 +682,9 @@ fn capture_loop(
|
||||
let mut queue: std::collections::VecDeque<u8> = std::collections::VecDeque::new();
|
||||
let mut frames_written: u64 = 0;
|
||||
let mut last_level_emit = Instant::now() - LEVEL_EMIT_INTERVAL;
|
||||
// Wall-clock of the last WAV write (real or synthesized), so the loopback
|
||||
// loop can fill idle gaps with silence+mic instead of stalling.
|
||||
let mut last_write = Instant::now();
|
||||
|
||||
while running.load(Ordering::Relaxed) {
|
||||
// Short timeout so we periodically re-check `running` even with no data.
|
||||
@@ -636,8 +728,42 @@ fn capture_loop(
|
||||
continue;
|
||||
}
|
||||
if queue.is_empty() {
|
||||
// Loopback idle (nothing playing): keep audio.wav advancing so the
|
||||
// bridged mic (user talking over system silence) is preserved
|
||||
// instead of lost to MicBridge overflow. Only after a *sustained*
|
||||
// gap, so brief inter-packet stalls during active playback don't
|
||||
// punch silence into the loopback (clicks). Loopback + mic-enabled
|
||||
// only; the mic capture stream has no writer.
|
||||
if is_loopback && !paused.load(Ordering::Relaxed) {
|
||||
if let (Some(w), Some(b)) = (writer.as_mut(), bridge) {
|
||||
let elapsed = last_write.elapsed();
|
||||
if elapsed >= SILENCE_TICK_MIN {
|
||||
// Drain the whole mic buffer (device-paced). Metering the
|
||||
// pull by wall-clock let the buffer back up to its cap and
|
||||
// drop the oldest samples — the audio skipped/fast-
|
||||
// forwarded late in a long gap. Draining fully avoids that.
|
||||
let mic = b.drain();
|
||||
let rate = session.format.get_samplespersec() as usize;
|
||||
// Advance the timeline by the wall-clock gap (capped), but
|
||||
// never fewer frames than the mic we must write.
|
||||
let gap_frames =
|
||||
(elapsed.min(SILENCE_TICK_MAX).as_micros() as usize * rate / 1_000_000)
|
||||
.max(mic.len());
|
||||
let bpf = session.format.get_nchannels() as usize
|
||||
* (session.format.get_bitspersample() as usize / 8);
|
||||
let silence = vec![0u8; gap_frames * bpf];
|
||||
frames_written += if split {
|
||||
write_wav_split(w, &silence, &session.format, &mic)?
|
||||
} else {
|
||||
write_wav_bytes(w, &silence, &session.format, &mic)?
|
||||
};
|
||||
last_write = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
last_write = Instant::now();
|
||||
let bytes: Vec<u8> = queue.drain(..).collect();
|
||||
|
||||
// Must keep pulling WASAPI buffers even while paused (required to avoid
|
||||
@@ -653,9 +779,20 @@ fn capture_loop(
|
||||
Some(b) if is_loopback => b.pull(frame_count(&bytes, &session.format)),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
frames_written += write_wav_bytes(w, &bytes, &session.format, &mic)?;
|
||||
frames_written += if split {
|
||||
write_wav_split(w, &bytes, &session.format, &mic)?
|
||||
} else {
|
||||
write_wav_bytes(w, &bytes, &session.format, &mic)?
|
||||
};
|
||||
}
|
||||
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);
|
||||
}
|
||||
let mono = decode_mono_f32(&bytes, &session.format)?;
|
||||
|
||||
// Mic: feed the shared bridge (resampled to the loopback's rate) so the
|
||||
// loopback thread can fold it into the recording.
|
||||
@@ -783,6 +920,57 @@ fn f32_to_i16(v: f32) -> i16 {
|
||||
(v.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
|
||||
}
|
||||
|
||||
/// Split layout (FR-SPK): write frames as **stereo, L = mic, R = loopback**
|
||||
/// (downmixed to mono), so the two sources stay separate in `audio.wav` and
|
||||
/// diarization can run on the far side alone. `loopback_bytes` is the raw WASAPI
|
||||
/// mix buffer (all-zero for a synthesized-silence chunk); `mic` may be shorter
|
||||
/// than the frame count (rest silent) or empty (mic underran). Returns frames.
|
||||
fn write_wav_split(
|
||||
writer: &mut WavWriter<BufWriter<File>>,
|
||||
loopback_bytes: &[u8],
|
||||
format: &WaveFormat,
|
||||
mic: &[f32],
|
||||
) -> Result<u64, AudioError> {
|
||||
let sample_type = format
|
||||
.get_subformat()
|
||||
.map_err(|e| AudioError::Device(format!("unrecognized mix format: {e}")))?;
|
||||
let channels = format.get_nchannels() as usize;
|
||||
if channels == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let (bytes_per_sample, decode): (usize, fn(&[u8]) -> f32) =
|
||||
match (sample_type, format.get_bitspersample()) {
|
||||
(SampleType::Float, 32) => (4, |c| f32::from_le_bytes(c.try_into().unwrap())),
|
||||
(SampleType::Int, 16) => (2, |c| {
|
||||
i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32
|
||||
}),
|
||||
(st, bits) => {
|
||||
return Err(AudioError::Device(format!(
|
||||
"unsupported capture format: {st} {bits}-bit"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let mut frames = 0u64;
|
||||
for frame in loopback_bytes.chunks_exact(bytes_per_sample * channels) {
|
||||
// R = loopback downmixed to mono.
|
||||
let loop_mono = frame
|
||||
.chunks_exact(bytes_per_sample)
|
||||
.map(decode)
|
||||
.sum::<f32>()
|
||||
/ channels as f32;
|
||||
// L = mic for this frame (silent if the mic underran).
|
||||
let mic_s = mic.get(frames as usize).copied().unwrap_or(0.0);
|
||||
writer
|
||||
.write_sample(f32_to_i16(mic_s))
|
||||
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
|
||||
writer
|
||||
.write_sample(f32_to_i16(loop_mono))
|
||||
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
|
||||
frames += 1;
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
/// Downmix raw WASAPI capture bytes to mono `f32` in `[-1.0, 1.0]`, at the
|
||||
/// device's native sample rate (resampling to 16kHz happens separately).
|
||||
fn decode_mono_f32(bytes: &[u8], format: &WaveFormat) -> Result<Vec<f32>, AudioError> {
|
||||
@@ -897,6 +1085,22 @@ impl Resampler {
|
||||
/// through hound's size-aware iterator; hound is only used to parse the
|
||||
/// format/`fmt ` chunk.
|
||||
pub fn read_wav_mono_16k(path: &Path) -> Result<Vec<f32>, AudioError> {
|
||||
read_wav_16k(path, None)
|
||||
}
|
||||
|
||||
/// Like [`read_wav_mono_16k`] but extracts a single channel instead of
|
||||
/// downmixing — used by the Phase 3.5 split path to diarize the right
|
||||
/// (loopback) channel and VAD the left (mic) channel (FR-SPK). `channel` is
|
||||
/// clamped to the file's last channel, so it's safe on a mono/summed file.
|
||||
pub fn read_wav_channel_16k(path: &Path, channel: usize) -> Result<Vec<f32>, AudioError> {
|
||||
read_wav_16k(path, Some(channel))
|
||||
}
|
||||
|
||||
/// Shared reader: decrypt (T8.8), reduce each frame to one f32 — averaging all
|
||||
/// channels (`channel = None`) or picking one (`channel = Some`) — resample to
|
||||
/// 16kHz. Reads the `data` payload as raw bytes (an unfinalized WAV's size
|
||||
/// fields read back as 0), so it works on a mid-write / crash-left file (FR-REL-1).
|
||||
fn read_wav_16k(path: &Path, channel: Option<usize>) -> Result<Vec<f32>, AudioError> {
|
||||
let raw = std::fs::read(path).map_err(|e| AudioError::Capture(format!("read wav: {e}")))?;
|
||||
// Decrypt if the recording was sealed at rest (T8.8); plaintext passes through.
|
||||
let file =
|
||||
@@ -906,6 +1110,8 @@ pub fn read_wav_mono_16k(path: &Path) -> Result<Vec<f32>, AudioError> {
|
||||
.map_err(|e| AudioError::Capture(format!("open wav: {e}")))?
|
||||
.spec();
|
||||
let channels = spec.channels.max(1) as usize;
|
||||
// Which channel index to pick (clamped), or None to downmix.
|
||||
let pick = channel.map(|c| c.min(channels - 1));
|
||||
|
||||
let data_marker = file
|
||||
.windows(4)
|
||||
@@ -918,21 +1124,17 @@ pub fn read_wav_mono_16k(path: &Path) -> Result<Vec<f32>, AudioError> {
|
||||
(SampleFormat::Float, 32) => bytes
|
||||
.chunks_exact(4 * channels)
|
||||
.map(|frame| {
|
||||
frame
|
||||
.chunks_exact(4)
|
||||
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
|
||||
.sum::<f32>()
|
||||
/ channels as f32
|
||||
reduce_frame(frame, 4, pick, channels, |c| {
|
||||
f32::from_le_bytes(c.try_into().unwrap())
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
(SampleFormat::Int, 16) => bytes
|
||||
.chunks_exact(2 * channels)
|
||||
.map(|frame| {
|
||||
frame
|
||||
.chunks_exact(2)
|
||||
.map(|c| i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32)
|
||||
.sum::<f32>()
|
||||
/ channels as f32
|
||||
reduce_frame(frame, 2, pick, channels, |c| {
|
||||
i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
(fmt, bits) => {
|
||||
@@ -946,6 +1148,78 @@ pub fn read_wav_mono_16k(path: &Path) -> Result<Vec<f32>, AudioError> {
|
||||
Ok(resampler.process(&mono))
|
||||
}
|
||||
|
||||
/// Voice-activity "You" spans (FR-SPK): 100ms windows of 16kHz mono audio above
|
||||
/// [`VOICE_ENERGY_FLOOR`], merged into `(start_ms, end_ms)` ranges. Run on the
|
||||
/// left (mic) channel of a split recording to attribute segments to "You" —
|
||||
/// replaces the old live `MicActivity` timeline (recomputed from the file, so
|
||||
/// stop and reprocess agree).
|
||||
pub fn vad_spans(samples: &[f32]) -> Vec<(u64, u64)> {
|
||||
const WINDOW_MS: u64 = 100;
|
||||
let win = (TARGET_SAMPLE_RATE as u64 * WINDOW_MS / 1000) as usize; // 1600 @16kHz
|
||||
if win == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut spans = Vec::new();
|
||||
let mut start: Option<u64> = None;
|
||||
let mut w = 0u64;
|
||||
let mut i = 0usize;
|
||||
while i < samples.len() {
|
||||
let chunk = &samples[i..(i + win).min(samples.len())];
|
||||
let rms = (chunk.iter().map(|s| s * s).sum::<f32>() / chunk.len().max(1) as f32).sqrt();
|
||||
match (rms >= VOICE_ENERGY_FLOOR, start) {
|
||||
(true, None) => start = Some(w * WINDOW_MS),
|
||||
(false, Some(s)) => {
|
||||
spans.push((s, w * WINDOW_MS));
|
||||
start = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += win;
|
||||
w += 1;
|
||||
}
|
||||
if let Some(s) = start {
|
||||
spans.push((s, w * WINDOW_MS));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
/// Reduce one interleaved frame to a single f32: pick channel `pick` or average
|
||||
/// all `channels`. `width` is bytes-per-sample; `decode` turns those bytes → f32.
|
||||
fn reduce_frame(
|
||||
frame: &[u8],
|
||||
width: usize,
|
||||
pick: Option<usize>,
|
||||
channels: usize,
|
||||
decode: impl Fn(&[u8]) -> f32,
|
||||
) -> f32 {
|
||||
match pick {
|
||||
Some(c) => decode(&frame[c * width..c * width + width]),
|
||||
None => frame.chunks_exact(width).map(decode).sum::<f32>() / channels as f32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write mono 16kHz `f32` samples as a Float32 WAV — the format
|
||||
/// [`read_wav_mono_16k`] reads back. Used to persist the mic voiceprint sample
|
||||
/// next to a retained recording so it can be re-matched on reprocess (FR-SPK).
|
||||
pub fn write_wav_mono_16k(path: &Path, samples: &[f32]) -> Result<(), AudioError> {
|
||||
let spec = WavSpec {
|
||||
channels: 1,
|
||||
sample_rate: 16_000,
|
||||
bits_per_sample: 32,
|
||||
sample_format: SampleFormat::Float,
|
||||
};
|
||||
let mut writer = WavWriter::create(path, spec)
|
||||
.map_err(|e| AudioError::Capture(format!("write wav: {e}")))?;
|
||||
for &s in samples {
|
||||
writer
|
||||
.write_sample(s)
|
||||
.map_err(|e| AudioError::Capture(format!("write wav: {e}")))?;
|
||||
}
|
||||
writer
|
||||
.finalize()
|
||||
.map_err(|e| AudioError::Capture(format!("write wav: {e}")))
|
||||
}
|
||||
|
||||
/// Ceiling on how far the still-flowing stream may run ahead of a stalled
|
||||
/// partner before the mixer forwards it alone — so a muted/dead microphone (or a
|
||||
/// silent system output) can't hold up the live transcript. 0.5s @ 16kHz.
|
||||
@@ -1051,6 +1325,34 @@ fn mixer_loop(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vad_spans_ignores_silence() {
|
||||
// 200ms of 16kHz silence → no "You" spans.
|
||||
assert!(vad_spans(&[0.0; 3_200]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vad_spans_collapses_speech_into_spans_with_gaps() {
|
||||
// 16kHz: 1600 samples per 100ms window. Speech in windows 0,1 (0-200ms)
|
||||
// and 4,5 (400-600ms), silence between.
|
||||
let mut samples = vec![0.0f32; 9_600]; // 600ms
|
||||
samples[0..3_200].fill(0.2); // windows 0,1
|
||||
samples[6_400..9_600].fill(0.2); // windows 4,5
|
||||
assert_eq!(vad_spans(&samples), vec![(0, 200), (400, 600)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_sample_skips_silence_and_keeps_speech() {
|
||||
let vs = VoiceSample::new(16_000);
|
||||
vs.push(&[0.0; 4_000]); // silence: below the energy floor
|
||||
assert!(
|
||||
vs.samples().is_empty(),
|
||||
"silence must not fill the voiceprint"
|
||||
);
|
||||
vs.push(&[0.2; 4_000]); // speech-level audio
|
||||
assert_eq!(vs.samples().len(), 4_000, "speech-level audio is collected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixer_sums_aligned_streams_and_keeps_the_unmatched_tail() {
|
||||
let mut m = Mixer::new();
|
||||
@@ -1127,6 +1429,33 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_wav_split_puts_mic_left_and_loopback_right() {
|
||||
let dir = std::env::temp_dir().join(format!("wa-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("audio.wav");
|
||||
// 2ch float loopback input; split output is always 2ch 16-bit.
|
||||
let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
||||
let spec = WavSpec {
|
||||
channels: 2,
|
||||
sample_rate: 48_000,
|
||||
bits_per_sample: 16,
|
||||
sample_format: SampleFormat::Int,
|
||||
};
|
||||
let mut writer = WavWriter::create(&path, spec).unwrap();
|
||||
// One 2ch loopback frame [0.4, 0.6] → R = downmix 0.5; mic 0.2 → L.
|
||||
let loop_bytes: Vec<u8> = [0.4f32, 0.6].iter().flat_map(|s| s.to_le_bytes()).collect();
|
||||
let n = write_wav_split(&mut writer, &loop_bytes, &format, &[0.2]).unwrap();
|
||||
writer.finalize().unwrap();
|
||||
assert_eq!(n, 1);
|
||||
|
||||
let mut reader = hound::WavReader::open(&path).unwrap();
|
||||
let s: Vec<i16> = reader.samples::<i16>().map(|x| x.unwrap()).collect();
|
||||
assert!((s[0] as f32 / i16::MAX as f32 - 0.2).abs() < 0.001); // L = mic
|
||||
assert!((s[1] as f32 / i16::MAX as f32 - 0.5).abs() < 0.001); // R = loopback downmix
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f32_to_i16_clamps_and_scales() {
|
||||
assert_eq!(f32_to_i16(0.0), 0);
|
||||
|
||||
+1230
-122
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ pub enum DiarError {
|
||||
pub trait Diarizer: Send + Sync {
|
||||
/// Partition audio into speaker spans.
|
||||
fn diarize(&self, wav: &Path) -> Result<Vec<SpeakerSpan>, DiarError>;
|
||||
/// Partition already-loaded 16kHz mono samples into speaker spans. Phase 3
|
||||
/// (FR-SPK) diarizes a *masked* far-side signal (the mic's "You" ranges
|
||||
/// zeroed), so the caller loads and masks the samples itself rather than
|
||||
/// pointing at the raw WAV.
|
||||
fn diarize_samples(&self, samples: Vec<f32>) -> Result<Vec<SpeakerSpan>, DiarError>;
|
||||
/// Assign speaker labels to transcript segments by overlap with spans.
|
||||
fn assign(&self, segments: &mut [TranscriptSegment], spans: &[SpeakerSpan]);
|
||||
}
|
||||
@@ -53,6 +58,60 @@ 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
|
||||
@@ -70,7 +129,13 @@ impl SherpaDiarizer {
|
||||
// sherpa-onnx to pick the cluster count itself from `threshold`
|
||||
// instead of forcing a fixed number of speakers.
|
||||
num_clusters: Some(-1),
|
||||
threshold: Some(0.5),
|
||||
// Tuning evidence: 0.5 over the summed mic+loopback mono WAV
|
||||
// exploded a 2-speaker conversation into 83 clusters (overlapped
|
||||
// speech + short pyannote chunks yield unstable embeddings). sherpa
|
||||
// fast-clustering: larger threshold → fewer clusters. 0.7 lands a
|
||||
// 2-person meeting near 2–4. ponytail: code constant, no settings
|
||||
// knob until 0.7 proves insufficient in the field.
|
||||
threshold: Some(0.7),
|
||||
..Default::default()
|
||||
};
|
||||
let engine = sherpa_rs::diarize::Diarize::new(segmentation_model, embedding_model, config)
|
||||
@@ -86,11 +151,28 @@ impl Diarizer for SherpaDiarizer {
|
||||
fn diarize(&self, wav: &Path) -> Result<Vec<SpeakerSpan>, DiarError> {
|
||||
let samples =
|
||||
crate::audio::read_wav_mono_16k(wav).map_err(|e| DiarError::Run(e.to_string()))?;
|
||||
self.diarize_samples(samples)
|
||||
}
|
||||
|
||||
fn diarize_samples(&self, samples: Vec<f32>) -> Result<Vec<SpeakerSpan>, DiarError> {
|
||||
// Guard the native FFI against degenerate input (a fully-masked far side
|
||||
// is all zeros; a tiny buffer has no usable window). sherpa can crash
|
||||
// rather than error on these, so skip cleanly instead.
|
||||
if samples.len() < MIN_DIARIZE_SAMPLES {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut engine = self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let segments = engine
|
||||
.compute(samples, None)
|
||||
.map_err(|e| DiarError::Run(e.to_string()))?;
|
||||
Ok(segments.into_iter().map(segment_to_span).collect())
|
||||
// Drop sub-700ms spans: their embeddings are unstable and only cause
|
||||
// label churn in assign_by_overlap (which keeps the prior label when no
|
||||
// span overlaps). See MIN_SPAN_MS.
|
||||
Ok(segments
|
||||
.into_iter()
|
||||
.map(segment_to_span)
|
||||
.filter(span_long_enough)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn assign(&self, segments: &mut [TranscriptSegment], spans: &[SpeakerSpan]) {
|
||||
@@ -98,6 +180,23 @@ impl Diarizer for SherpaDiarizer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum span duration kept from a diarization pass. Sub-second pyannote
|
||||
/// chunks carry unstable ERes2Net embeddings and only churn labels.
|
||||
#[cfg(feature = "diarization")]
|
||||
const MIN_SPAN_MS: u64 = 700;
|
||||
|
||||
/// Below this many 16kHz samples (~1s) the diarizer skips the sherpa FFI: a
|
||||
/// fully-masked far side is all zeros and a tiny buffer has no usable
|
||||
/// segmentation window, either of which can crash the native code.
|
||||
#[cfg(feature = "diarization")]
|
||||
const MIN_DIARIZE_SAMPLES: usize = 16_000;
|
||||
|
||||
/// A span is kept only if it lasts at least [`MIN_SPAN_MS`].
|
||||
#[cfg(feature = "diarization")]
|
||||
fn span_long_enough(span: &SpeakerSpan) -> bool {
|
||||
span.end_ms.saturating_sub(span.start_ms) >= MIN_SPAN_MS
|
||||
}
|
||||
|
||||
/// sherpa-onnx speaker indices are 0-based; WA's internal labels are 1-based ("S1"…).
|
||||
#[cfg(feature = "diarization")]
|
||||
fn segment_to_span(seg: sherpa_rs::diarize::Segment) -> SpeakerSpan {
|
||||
@@ -165,6 +264,63 @@ 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"))]
|
||||
@@ -184,6 +340,22 @@ mod tests {
|
||||
assert_eq!(span.speaker, "S1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn span_long_enough_filters_sub_minimum_spans() {
|
||||
let short = SpeakerSpan {
|
||||
start_ms: 0,
|
||||
end_ms: MIN_SPAN_MS - 1,
|
||||
speaker: "S1".to_string(),
|
||||
};
|
||||
let exact = SpeakerSpan {
|
||||
start_ms: 0,
|
||||
end_ms: MIN_SPAN_MS,
|
||||
speaker: "S1".to_string(),
|
||||
};
|
||||
assert!(!span_long_enough(&short));
|
||||
assert!(span_long_enough(&exact));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_surfaces_a_load_error_for_missing_models_instead_of_panicking() {
|
||||
let result =
|
||||
|
||||
@@ -110,8 +110,9 @@ pub fn match_mic_speaker(
|
||||
}
|
||||
|
||||
/// Distinct speaker labels in first-appearance order — spans come back from
|
||||
/// the diarizer already sorted by start time.
|
||||
fn first_appearance_order(spans: &[SpeakerSpan]) -> Vec<String> {
|
||||
/// the diarizer already sorted by start time. `pub(crate)` so the Phase 3
|
||||
/// per-stream path can reuse it to name its merged You + far-side spans.
|
||||
pub(crate) fn first_appearance_order(spans: &[SpeakerSpan]) -> Vec<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
spans
|
||||
.iter()
|
||||
@@ -152,8 +153,12 @@ fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
|
||||
/// `mic_label` -> "You"; every other label, in first-appearance order ->
|
||||
/// "Speaker 2", "Speaker 3", … (numbering starts at 2 — "You" stands in for
|
||||
/// "Speaker 1" without ever being called that).
|
||||
fn build_name_map(labels_in_order: &[String], mic_label: &str) -> HashMap<String, String> {
|
||||
/// "Speaker 1" without ever being called that). `pub(crate)` so Phase 3 reuses
|
||||
/// it for its merged You + far-side spans, keeping naming uniform app-wide.
|
||||
pub(crate) fn build_name_map(
|
||||
labels_in_order: &[String],
|
||||
mic_label: &str,
|
||||
) -> HashMap<String, String> {
|
||||
let mut names = HashMap::new();
|
||||
let mut next_speaker_number = 2;
|
||||
for label in labels_in_order {
|
||||
|
||||
+124
-3
@@ -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;
|
||||
|
||||
@@ -85,6 +87,10 @@ pub struct RecordingSession {
|
||||
/// auto-labeled "You" instead of a clustered "S1"/"S2". `None` when the
|
||||
/// mic is disabled (same conditions as `mic_capture`).
|
||||
pub mic_voice_sample: Option<Arc<audio::VoiceSample>>,
|
||||
/// `audio.wav` channel layout for this recording (FR-SPK): `"split"` (mic
|
||||
/// enabled → stereo mic-left/loopback-right) or `"summed"` (mic off). Passed
|
||||
/// to `finalize_meeting` so playback/diarization/export can branch on it.
|
||||
pub audio_layout: &'static str,
|
||||
/// Live notes redesign: raw user-authored notes accumulated *during* the
|
||||
/// recording (freeform text + per-moment annotations) — see
|
||||
/// `models::ManualNotes`. Mutated by `update_live_notes`/`set_segment_note`
|
||||
@@ -111,7 +117,11 @@ fn fatal_startup_error(body: &str) -> ! {
|
||||
MessageBoxW, MB_ICONERROR, MB_OK, MB_SYSTEMMODAL,
|
||||
};
|
||||
// Null-terminated UTF-16 for the Win32 wide API.
|
||||
let to_wide = |s: &str| s.encode_utf16().chain(std::iter::once(0)).collect::<Vec<u16>>();
|
||||
let to_wide = |s: &str| {
|
||||
s.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<u16>>()
|
||||
};
|
||||
let text = to_wide(body);
|
||||
let caption = to_wide("WhispAssist can't start");
|
||||
// SAFETY: both buffers are null-terminated and outlive the call.
|
||||
@@ -151,6 +161,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| {
|
||||
@@ -161,13 +179,88 @@ 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.
|
||||
@@ -261,6 +354,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,
|
||||
@@ -268,6 +372,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,
|
||||
@@ -279,6 +384,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,
|
||||
@@ -307,6 +415,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,
|
||||
@@ -346,11 +455,23 @@ pub fn run() {
|
||||
commands::unlock_vault,
|
||||
commands::lock_vault,
|
||||
commands::change_vault_password,
|
||||
commands::list_unencrypted_meetings,
|
||||
commands::encrypt_meeting,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.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>() {
|
||||
|
||||
+7
-11
@@ -21,10 +21,7 @@ pub enum MediaError {
|
||||
#[error("{0} isn't installed or on PATH — install it and try again")]
|
||||
ToolMissing(&'static str),
|
||||
#[error("{tool} failed: {message}")]
|
||||
Failed {
|
||||
tool: &'static str,
|
||||
message: String,
|
||||
},
|
||||
Failed { tool: &'static str, message: String },
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
@@ -60,7 +57,11 @@ fn run(program: &'static str, cmd: &mut Command) -> Result<(), MediaError> {
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let text = if stderr.trim().is_empty() { stdout } else { stderr };
|
||||
let text = if stderr.trim().is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
let tail: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
let start = tail.len().saturating_sub(6);
|
||||
return Err(MediaError::Failed {
|
||||
@@ -145,12 +146,7 @@ mod tests {
|
||||
fn import_reports_a_missing_local_file_without_touching_a_tool() {
|
||||
let dir = std::env::temp_dir().join(format!("wa-media-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let err = import_to_wav(
|
||||
"no-such-file.mp4",
|
||||
&dir.join("out.wav"),
|
||||
&dir,
|
||||
)
|
||||
.unwrap_err();
|
||||
let err = import_to_wav("no-such-file.mp4", &dir.join("out.wav"), &dir).unwrap_err();
|
||||
assert!(matches!(err, MediaError::Failed { tool: "import", .. }));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -214,6 +214,11 @@ pub struct MeetingBundle {
|
||||
pub tags: Vec<String>,
|
||||
pub speakers: Vec<SpeakerInfo>,
|
||||
pub action_items: Vec<ActionItem>,
|
||||
/// `audio.wav` channel layout (FR-SPK/FR-CAP): `"split"` or `"summed"`.
|
||||
/// `default` so bundles exported before this field deserialize as `None`
|
||||
/// (treated as `"summed"`).
|
||||
#[serde(default)]
|
||||
pub audio_layout: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -349,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 {
|
||||
|
||||
@@ -74,6 +74,10 @@ pub struct FinalizeMeeting {
|
||||
pub language: Option<String>,
|
||||
pub backend_used: Option<String>,
|
||||
pub model_used: Option<String>,
|
||||
/// How `audio.wav`'s channels are laid out (FR-SPK/FR-CAP): `"split"` =
|
||||
/// stereo mic-left/loopback-right, `"summed"`/`None` = mic mixed into every
|
||||
/// channel. `None` leaves the stored value unchanged (e.g. on reprocess).
|
||||
pub audio_layout: Option<String>,
|
||||
}
|
||||
|
||||
/// Full meeting detail: DB row + transcript + speakers + notes (`get_meeting`'s
|
||||
@@ -91,6 +95,10 @@ pub struct Meeting {
|
||||
pub language: Option<String>,
|
||||
pub backend_used: Option<String>,
|
||||
pub model_used: Option<String>,
|
||||
/// `audio.wav` channel layout (FR-SPK/FR-CAP): `"split"` (mic-left /
|
||||
/// loopback-right) or `"summed"`/`None` (mic mixed into every channel).
|
||||
/// Diarization, playback downmix, and bundle export branch on it.
|
||||
pub audio_layout: Option<String>,
|
||||
pub segments: Vec<TranscriptSegment>,
|
||||
pub speakers: Vec<SpeakerInfo>,
|
||||
pub notes_markdown: String,
|
||||
@@ -268,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
|
||||
@@ -284,6 +296,14 @@ pub trait Store: Send + Sync {
|
||||
label: &str,
|
||||
name: &str,
|
||||
) -> Result<(), StoreError>;
|
||||
/// Delete every `speakers` row for a meeting (labels, names, participant
|
||||
/// links, merges). Used before a full re-diarization rebuild
|
||||
/// (`reprocess_transcript`, FR-SPK): the old labels key to the previous
|
||||
/// clustering and are meaningless once the audio is re-clustered, so the
|
||||
/// caller re-inserts the fresh set via `finalize_meeting`. Without this,
|
||||
/// stale labels from an over-split run linger in the DB (and the
|
||||
/// Participants pane) even though the transcript no longer references them.
|
||||
async fn clear_speakers(&self, id: &MeetingId) -> Result<(), StoreError>;
|
||||
/// Fold over-split speaker labels into one canonical label (T4.5,
|
||||
/// FR-SPK-3). Segment speaker IDs in storage are never rewritten
|
||||
/// (FR-SPK-5) — `get_meeting` resolves `from` labels to `into` when it
|
||||
@@ -734,7 +754,8 @@ impl Store for SqliteStore {
|
||||
let now = now_unix();
|
||||
sqlx::query(
|
||||
"UPDATE meetings SET status = 'ready', ended_at = ?, duration_secs = ?, recorded = ?,
|
||||
language = ?, backend_used = ?, model_used = ?, updated_at = ? WHERE id = ?",
|
||||
language = ?, backend_used = ?, model_used = ?,
|
||||
audio_layout = COALESCE(?, audio_layout), updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(s.duration_secs)
|
||||
@@ -742,6 +763,7 @@ impl Store for SqliteStore {
|
||||
.bind(&s.language)
|
||||
.bind(&s.backend_used)
|
||||
.bind(&s.model_used)
|
||||
.bind(&s.audio_layout)
|
||||
.bind(now)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
@@ -857,7 +879,7 @@ impl Store for SqliteStore {
|
||||
|
||||
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, title, started_at, ended_at, duration_secs, status, recorded, language, backend_used, model_used, calendar_event_id, template_id
|
||||
"SELECT id, title, started_at, ended_at, duration_secs, status, recorded, language, backend_used, model_used, calendar_event_id, template_id, audio_layout
|
||||
FROM meetings WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
@@ -928,6 +950,7 @@ impl Store for SqliteStore {
|
||||
language: row.get("language"),
|
||||
backend_used: row.get("backend_used"),
|
||||
model_used: row.get("model_used"),
|
||||
audio_layout: row.get("audio_layout"),
|
||||
segments,
|
||||
speakers,
|
||||
notes_markdown,
|
||||
@@ -957,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"),
|
||||
@@ -1227,13 +1260,15 @@ impl Store for SqliteStore {
|
||||
started_at: i64,
|
||||
ended_at: Option<i64>,
|
||||
) -> Result<(), StoreError> {
|
||||
sqlx::query("UPDATE meetings SET started_at = ?, ended_at = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(started_at)
|
||||
.bind(ended_at)
|
||||
.bind(now_unix())
|
||||
.bind(meeting_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE meetings SET started_at = ?, ended_at = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(started_at)
|
||||
.bind(ended_at)
|
||||
.bind(now_unix())
|
||||
.bind(meeting_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1252,6 +1287,14 @@ impl Store for SqliteStore {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn clear_speakers(&self, id: &MeetingId) -> Result<(), StoreError> {
|
||||
sqlx::query("DELETE FROM speakers WHERE meeting_id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn merge_speakers(
|
||||
&self,
|
||||
id: &MeetingId,
|
||||
@@ -2015,6 +2058,7 @@ mod tests {
|
||||
language: Some("fr".to_string()), // what auto-detect resolved to
|
||||
backend_used: Some("cpu".to_string()),
|
||||
model_used: Some("small-q5_1".to_string()),
|
||||
audio_layout: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -2058,6 +2102,7 @@ mod tests {
|
||||
language: Some("en".to_string()),
|
||||
backend_used: Some("cpu".to_string()),
|
||||
model_used: Some("small-q5_1".to_string()),
|
||||
audio_layout: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -287,8 +287,22 @@ impl Transcriber for WhisperTranscriber {
|
||||
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
fn available_threads() -> std::ffi::c_int {
|
||||
// WA_WHISPER_THREADS: support/bench override — see cap rationale below.
|
||||
if let Some(n) = std::env::var("WA_WHISPER_THREADS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
{
|
||||
return n;
|
||||
}
|
||||
// Capped at 4 (whisper.cpp's own CLI default): ggml workers spin-wait at
|
||||
// a per-graph-node barrier, so one worker scheduled on a slow core stalls
|
||||
// every node while the rest burn CPU. With all 14 logical CPUs of a
|
||||
// hybrid Meteor Lake (incl. 2 LP E-cores) this measured RTF ~22 — ~80x
|
||||
// slower than the same build at 4 threads (RTF 0.28), regardless of
|
||||
// compiler flags. 4 threads is also the CPU-frugal choice (NFR-RES-1):
|
||||
// 1.07 vs 2.16 cpu-sec/audio-sec at 12 threads for near-equal wall time.
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get() as std::ffi::c_int)
|
||||
.map(|n| (n.get() as std::ffi::c_int).min(4))
|
||||
.unwrap_or(4)
|
||||
}
|
||||
|
||||
@@ -588,7 +602,10 @@ mod tests {
|
||||
feed_into(&mut s, &step, &d2, &mut out);
|
||||
assert!(out[3].interim && out[3].text == "next sentence");
|
||||
assert_ne!(out[3].id, out[2].id);
|
||||
assert!(out[3].start_ms >= 3000, "starts after the 3 committed steps");
|
||||
assert!(
|
||||
out[3].start_ms >= 3000,
|
||||
"starts after the 3 committed steps"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -612,7 +629,10 @@ mod tests {
|
||||
assert!(out.last().unwrap().interim, "still growing before flush");
|
||||
flush_into(&mut s, &decode, &mut out);
|
||||
let last = out.last().unwrap();
|
||||
assert!(!last.interim && last.text == "partial", "flush finalizes it");
|
||||
assert!(
|
||||
!last.interim && last.text == "partial",
|
||||
"flush finalizes it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,12 @@ struct Catalog {
|
||||
multilingual: bool,
|
||||
}
|
||||
|
||||
// Each size ships two quantizations: a q5 variant (recommended — smaller,
|
||||
// near-identical quality) and q8_0 (less quantized, larger). The UI groups
|
||||
// the pair into one row with a quantization dropdown, relying on the q5
|
||||
// variant being listed first within its family. Labels are intentionally
|
||||
// identical across a family's variants so the row label doesn't change when
|
||||
// the dropdown flips; the id/size carry the quantization difference.
|
||||
const CATALOG: &[Catalog] = &[
|
||||
Catalog {
|
||||
id: "tiny.en-q5_1",
|
||||
@@ -27,51 +33,112 @@ const CATALOG: &[Catalog] = &[
|
||||
size_mb: 32,
|
||||
multilingual: false,
|
||||
},
|
||||
Catalog {
|
||||
id: "tiny.en-q8_0",
|
||||
label: "Tiny (English, quantized) — fastest, least accurate",
|
||||
size_mb: 42,
|
||||
multilingual: false,
|
||||
},
|
||||
Catalog {
|
||||
id: "base.en-q5_1",
|
||||
label: "Base (English, quantized) — balanced default",
|
||||
size_mb: 60,
|
||||
multilingual: false,
|
||||
},
|
||||
Catalog {
|
||||
id: "base.en-q8_0",
|
||||
label: "Base (English, quantized) — balanced default",
|
||||
size_mb: 78,
|
||||
multilingual: false,
|
||||
},
|
||||
Catalog {
|
||||
id: "small.en-q5_1",
|
||||
label: "Small (English, quantized) — more accurate, slower",
|
||||
size_mb: 190,
|
||||
multilingual: false,
|
||||
},
|
||||
Catalog {
|
||||
id: "small.en-q8_0",
|
||||
label: "Small (English, quantized) — more accurate, slower",
|
||||
size_mb: 252,
|
||||
multilingual: false,
|
||||
},
|
||||
Catalog {
|
||||
// ggerganov/whisper.cpp only ships a q5_0 quantization for medium
|
||||
// (q5_1 doesn't exist upstream for this size) — q5_1 here 404s.
|
||||
id: "medium.en-q5_0",
|
||||
label: "Medium (English, quantized) — best accuracy, slowest",
|
||||
label: "Medium (English, quantized) — high accuracy, slowest",
|
||||
size_mb: 540,
|
||||
multilingual: false,
|
||||
},
|
||||
Catalog {
|
||||
id: "medium.en-q8_0",
|
||||
label: "Medium (English, quantized) — high accuracy, slowest",
|
||||
size_mb: 785,
|
||||
multilingual: false,
|
||||
},
|
||||
Catalog {
|
||||
id: "tiny-q5_1",
|
||||
label: "Tiny (multilingual, quantized) — fastest, least accurate",
|
||||
size_mb: 32,
|
||||
multilingual: true,
|
||||
},
|
||||
Catalog {
|
||||
id: "tiny-q8_0",
|
||||
label: "Tiny (multilingual, quantized) — fastest, least accurate",
|
||||
size_mb: 42,
|
||||
multilingual: true,
|
||||
},
|
||||
Catalog {
|
||||
id: "base-q5_1",
|
||||
label: "Base (multilingual, quantized) — balanced default",
|
||||
size_mb: 60,
|
||||
multilingual: true,
|
||||
},
|
||||
Catalog {
|
||||
id: "base-q8_0",
|
||||
label: "Base (multilingual, quantized) — balanced default",
|
||||
size_mb: 78,
|
||||
multilingual: true,
|
||||
},
|
||||
Catalog {
|
||||
id: "small-q5_1",
|
||||
label: "Small (multilingual, quantized) — more accurate, slower",
|
||||
size_mb: 190,
|
||||
multilingual: true,
|
||||
},
|
||||
Catalog {
|
||||
id: "small-q8_0",
|
||||
label: "Small (multilingual, quantized) — more accurate, slower",
|
||||
size_mb: 252,
|
||||
multilingual: true,
|
||||
},
|
||||
Catalog {
|
||||
// Same upstream-availability caveat as medium.en above.
|
||||
id: "medium-q5_0",
|
||||
label: "Medium (multilingual, quantized) — best accuracy, slowest",
|
||||
label: "Medium (multilingual, quantized) — high accuracy, slowest",
|
||||
size_mb: 540,
|
||||
multilingual: true,
|
||||
},
|
||||
Catalog {
|
||||
id: "medium-q8_0",
|
||||
label: "Medium (multilingual, quantized) — high accuracy, slowest",
|
||||
size_mb: 785,
|
||||
multilingual: true,
|
||||
},
|
||||
// large-v3-turbo has no `.en` variant upstream — multilingual only.
|
||||
Catalog {
|
||||
id: "large-v3-turbo-q5_0",
|
||||
label: "Large v3 Turbo (multilingual, quantized) — most accurate",
|
||||
size_mb: 547,
|
||||
multilingual: true,
|
||||
},
|
||||
Catalog {
|
||||
id: "large-v3-turbo-q8_0",
|
||||
label: "Large v3 Turbo (multilingual, quantized) — most accurate",
|
||||
size_mb: 833,
|
||||
multilingual: true,
|
||||
},
|
||||
];
|
||||
|
||||
fn model_url(id: &str) -> String {
|
||||
@@ -199,18 +266,47 @@ mod tests {
|
||||
// T8.7/M4.2: every `.en` model must have a same-size multilingual
|
||||
// sibling so the Settings picker always has a language-capable
|
||||
// option at whatever accuracy/speed tier the user already chose.
|
||||
let en_only: Vec<_> = CATALOG.iter().filter(|m| !m.multilingual).collect();
|
||||
let multilingual: Vec<_> = CATALOG.iter().filter(|m| m.multilingual).collect();
|
||||
assert_eq!(en_only.len(), multilingual.len());
|
||||
for en in &en_only {
|
||||
// (The reverse doesn't hold: large-v3-turbo is multilingual-only.)
|
||||
for en in CATALOG.iter().filter(|m| !m.multilingual) {
|
||||
assert!(
|
||||
multilingual.iter().any(|m| m.size_mb == en.size_mb),
|
||||
CATALOG
|
||||
.iter()
|
||||
.any(|m| m.multilingual && m.size_mb == en.size_mb),
|
||||
"no multilingual sibling for {}",
|
||||
en.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_family_lists_q5_then_q8() {
|
||||
// The Settings UI groups the catalog into one row per family
|
||||
// (id minus the `-q*` suffix) with a quantization dropdown, and
|
||||
// treats the family's FIRST variant as the recommended default —
|
||||
// so each family must be exactly [q5, q8_0], in that order.
|
||||
let mut families: Vec<(&str, Vec<&str>)> = Vec::new();
|
||||
for m in CATALOG {
|
||||
let (family, _) = m.id.rsplit_once("-q").unwrap();
|
||||
match families.iter_mut().find(|(f, _)| *f == family) {
|
||||
Some((_, ids)) => ids.push(m.id),
|
||||
None => families.push((family, vec![m.id])),
|
||||
}
|
||||
}
|
||||
for (family, ids) in &families {
|
||||
assert_eq!(ids.len(), 2, "family {family} must have exactly 2 variants");
|
||||
assert!(
|
||||
ids[0].contains("-q5_"),
|
||||
"family {family} must list its q5 variant first, got {}",
|
||||
ids[0]
|
||||
);
|
||||
assert!(
|
||||
ids[1].ends_with("-q8_0"),
|
||||
"family {family} must list q8_0 second, got {}",
|
||||
ids[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_multilingual_matches_the_catalog_flag() {
|
||||
assert!(!is_multilingual("base.en-q5_1"));
|
||||
|
||||
@@ -7,21 +7,24 @@
|
||||
//!
|
||||
//! Split of work, validated by the T3.4 spike (encoder ~3.6× faster on NPU):
|
||||
//! - **encoder** (fixed `[1,80,3000]` shape) runs on the **accelerator** (NPU or
|
||||
//! GPU) — the expensive graph, and the fixed shape accelerators want;
|
||||
//! - **decoder** (dynamic, autoregressive) runs greedy on the **CPU** EP — a
|
||||
//! dynamic KV loop is a poor accelerator fit and the cheap half anyway.
|
||||
//!
|
||||
//! ponytail: greedy, no-KV-cache decode (re-feeds the full token prefix each
|
||||
//! step). Correct and simple; windows are short so the token count is small.
|
||||
//! Add `decoder_with_past` only if the decoder shows up in a profile.
|
||||
//! DirectML GPU) — the expensive graph, and the fixed shape accelerators want;
|
||||
//! - **decoder** (dynamic, autoregressive) decodes greedily **with a KV cache**
|
||||
//! (Optimum's merged export: `use_cache_branch` + `past_key_values`), so each
|
||||
//! step feeds one token instead of re-running the whole prefix. On the NPU
|
||||
//! path the decoder session itself goes to the **OpenVINO GPU EP** (Intel
|
||||
//! iGPU, same runtime bundle) so sustained CPU stays near-idle; it falls back
|
||||
//! to the CPU EP when no iGPU is usable, and `WA_ONNX_DECODER_DEVICE=cpu|gpu`
|
||||
//! overrides the choice. DirectML backends keep the CPU decoder — the DML EP
|
||||
//! handles per-step-growing KV shapes poorly.
|
||||
|
||||
use super::{AudioWindow, SegmentSink, Transcriber, TrxError};
|
||||
use crate::models::{BackendId, TranscriptSegment};
|
||||
use ort::execution_providers::{
|
||||
DirectMLExecutionProvider, ExecutionProviderDispatch, OpenVINOExecutionProvider,
|
||||
};
|
||||
use ort::session::Session;
|
||||
use ort::value::Tensor;
|
||||
use ort::session::{Session, SessionInputValue};
|
||||
use ort::value::{Tensor, ValueType};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -30,6 +33,36 @@ use std::sync::Mutex;
|
||||
/// Safety cap on generated tokens per 30 s window (Whisper's own max is 448).
|
||||
const MAX_NEW_TOKENS: usize = 224;
|
||||
|
||||
/// One KV-cache tensor pair of the merged decoder, discovered from the graph
|
||||
/// at load: the `past_key_values.*` input, its matching `present.*` output,
|
||||
/// whether it's decoder self-attention (refreshed every step) or encoder
|
||||
/// cross-attention (computed once on the first step, then passed through
|
||||
/// untouched by the cache branch), and the dummy shape to feed before any
|
||||
/// past exists (see [`dummy_past_dims`]).
|
||||
struct KvSlot {
|
||||
past: String,
|
||||
present: String,
|
||||
self_attention: bool,
|
||||
dummy_dims: Vec<usize>,
|
||||
}
|
||||
|
||||
/// `"past_key_values.3.decoder.key"` → `"present.3.decoder.key"` — Optimum's
|
||||
/// fixed naming convention for the merged decoder's cache I/O.
|
||||
fn present_name_for(past: &str) -> String {
|
||||
format!("present.{}", past.trim_start_matches("past_key_values."))
|
||||
}
|
||||
|
||||
/// The "no past yet" shape for a KV input: batch → 1, any other dynamic (−1)
|
||||
/// dim → **1** (a dummy length-1 past — ORT's raw-data tensor creation rejects
|
||||
/// zero-length dims, and the no-cache branch never reads the values anyway;
|
||||
/// same dummy Optimum's own runner feeds), static dims kept.
|
||||
fn dummy_past_dims(dims: &[i64]) -> Vec<usize> {
|
||||
dims.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &d)| if i == 0 || d < 0 { 1 } else { d as usize })
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub struct OnnxTranscriber {
|
||||
encoder: Mutex<Session>,
|
||||
decoder: Mutex<Session>,
|
||||
@@ -38,6 +71,13 @@ pub struct OnnxTranscriber {
|
||||
dec_ids: String,
|
||||
dec_hidden: String,
|
||||
dec_logits: String,
|
||||
/// The merged decoder's KV-cache plumbing (see [`KvSlot`]) plus its
|
||||
/// branch selector input.
|
||||
kv_slots: Vec<KvSlot>,
|
||||
dec_use_cache: String,
|
||||
/// Which EP the decoder session actually landed on ("OpenVINO/GPU" or
|
||||
/// "CPU") — surfaced so `load_transcriber`'s engine log tells the truth.
|
||||
decoder_ep: &'static str,
|
||||
detok: Detok,
|
||||
/// `[decoder_start_token_id, ...forced_decoder_ids]` — the fixed prompt
|
||||
/// prefix before generation begins.
|
||||
@@ -120,29 +160,76 @@ impl OnnxTranscriber {
|
||||
let hidden = Tensor::from_array((hidden_shape, hidden_data))
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
|
||||
// Greedy decode on the CPU.
|
||||
// Greedy KV-cache decode. Step 1 runs the merged graph's no-cache
|
||||
// branch over the full prompt and yields every present KV; later steps
|
||||
// feed a single token plus the cache, refreshing only the decoder
|
||||
// self-attention slots — the cache branch passes encoder
|
||||
// cross-attention KVs through untouched, so the step-1 tensors stay
|
||||
// authoritative (same contract as Optimum's own runner).
|
||||
// ponytail: KVs round-trip host memory each step; wire ort IoBinding
|
||||
// to pin them on-device if GPU-decoder profiling shows the copies matter.
|
||||
let mut kv_cache: Vec<Tensor<f32>> = self
|
||||
.kv_slots
|
||||
.iter()
|
||||
.map(|slot| {
|
||||
let len = slot.dummy_dims.iter().product::<usize>();
|
||||
Tensor::from_array((slot.dummy_dims.clone(), vec![0.0f32; len]))
|
||||
})
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
|
||||
let mut tokens = self.initial_tokens.clone();
|
||||
let mut cached = false; // flips true once step 1 has filled kv_cache
|
||||
for _ in 0..MAX_NEW_TOKENS {
|
||||
let ids: Vec<i64> = tokens.clone();
|
||||
let ids: Vec<i64> = if cached {
|
||||
vec![*tokens.last().unwrap_or(&self.eot)]
|
||||
} else {
|
||||
tokens.clone()
|
||||
};
|
||||
let ids_tensor = Tensor::from_array(([1usize, ids.len()], ids))
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
let branch = Tensor::from_array(([1usize], vec![cached]))
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
|
||||
let next = {
|
||||
// Presents are extracted (copied) inside the outputs' borrow scope,
|
||||
// then written back into kv_cache after it ends.
|
||||
let (next, fresh_kvs) = {
|
||||
let mut dec = self
|
||||
.decoder
|
||||
.lock()
|
||||
.map_err(|_| TrxError::Inference("decoder mutex poisoned".into()))?;
|
||||
let mut inputs: Vec<(Cow<'_, str>, SessionInputValue<'_>)> = vec![
|
||||
(Cow::from(self.dec_ids.as_str()), ids_tensor.view().into()),
|
||||
(Cow::from(self.dec_hidden.as_str()), hidden.view().into()),
|
||||
(Cow::from(self.dec_use_cache.as_str()), branch.view().into()),
|
||||
];
|
||||
for (slot, kv) in self.kv_slots.iter().zip(kv_cache.iter()) {
|
||||
inputs.push((Cow::from(slot.past.as_str()), kv.view().into()));
|
||||
}
|
||||
let outputs = dec
|
||||
.run(ort::inputs![
|
||||
self.dec_ids.as_str() => ids_tensor.view(),
|
||||
self.dec_hidden.as_str() => hidden.view(),
|
||||
])
|
||||
.run(inputs)
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
let (shape, data) = outputs[self.dec_logits.as_str()]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
self.argmax_last(shape, data)
|
||||
let next = self.argmax_last(shape, data);
|
||||
|
||||
let mut fresh: Vec<(usize, Vec<usize>, Vec<f32>)> = Vec::new();
|
||||
for (i, slot) in self.kv_slots.iter().enumerate() {
|
||||
if slot.self_attention || !cached {
|
||||
let (s, d) = outputs[slot.present.as_str()]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
fresh.push((i, s.iter().map(|d| *d as usize).collect(), d.to_vec()));
|
||||
}
|
||||
}
|
||||
(next, fresh)
|
||||
};
|
||||
for (i, dims, data) in fresh_kvs {
|
||||
kv_cache[i] = Tensor::from_array((dims, data))
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
}
|
||||
cached = true;
|
||||
|
||||
if next == self.eot {
|
||||
break;
|
||||
@@ -180,6 +267,11 @@ impl OnnxTranscriber {
|
||||
best
|
||||
}
|
||||
|
||||
/// Which EP the decoder session landed on — "OpenVINO/GPU" or "CPU".
|
||||
pub fn decoder_ep(&self) -> &'static str {
|
||||
self.decoder_ep
|
||||
}
|
||||
|
||||
fn segment(&self, text: String, start_ms: u64, end_ms: u64) -> Option<TranscriptSegment> {
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
@@ -252,7 +344,7 @@ impl Transcriber for OnnxTranscriber {
|
||||
Self::ensure_runtime_env(runtime_dll);
|
||||
|
||||
let encoder_path = model.join("encoder_model.onnx");
|
||||
let decoder_path = model.join("decoder_model.onnx");
|
||||
let decoder_path = model.join("decoder_model_merged.onnx");
|
||||
let tokenizer_path = model.join("tokenizer.json");
|
||||
let gen_config_path = model.join("generation_config.json");
|
||||
|
||||
@@ -263,11 +355,46 @@ impl Transcriber for OnnxTranscriber {
|
||||
.commit_from_file(&encoder_path)
|
||||
.map_err(|e| TrxError::Load(e.to_string()))?;
|
||||
|
||||
// Decoder on the default CPU EP (dynamic shapes; poor NPU fit).
|
||||
let decoder = Session::builder()
|
||||
.map_err(|e| TrxError::Load(e.to_string()))?
|
||||
.commit_from_file(&decoder_path)
|
||||
.map_err(|e| TrxError::Load(e.to_string()))?;
|
||||
// Decoder session. The KV-cache loop is dynamic-shaped and
|
||||
// autoregressive — a poor NPU fit — but it needn't burn CPU either: on
|
||||
// the NPU path, try the same OpenVINO runtime's GPU plugin (Intel
|
||||
// iGPU) first and fall back to the default CPU EP if no GPU is usable.
|
||||
// `WA_ONNX_DECODER_DEVICE=cpu|gpu` overrides (debug escape hatch, like
|
||||
// WA_NPU_RUNTIME_URL). DirectML backends keep the CPU decoder — the
|
||||
// DML EP handles per-step-growing KV shapes poorly.
|
||||
let want_gpu = match std::env::var("WA_ONNX_DECODER_DEVICE").as_deref() {
|
||||
Ok("cpu") => false,
|
||||
Ok("gpu") => true,
|
||||
_ => matches!(backend, BackendId::Npu),
|
||||
};
|
||||
let mut decoder_ep = "CPU";
|
||||
let mut decoder = None;
|
||||
if want_gpu {
|
||||
let gpu = Session::builder()
|
||||
.and_then(|b| {
|
||||
b.with_execution_providers([OpenVINOExecutionProvider::default()
|
||||
.with_device_type("GPU")
|
||||
.build()
|
||||
.error_on_failure()])
|
||||
})
|
||||
.and_then(|b| b.commit_from_file(&decoder_path));
|
||||
match gpu {
|
||||
Ok(s) => {
|
||||
decoder_ep = "OpenVINO/GPU";
|
||||
decoder = Some(s);
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
"OpenVINO GPU EP unavailable for the decoder ({e}); using the CPU EP"
|
||||
),
|
||||
}
|
||||
}
|
||||
let decoder = match decoder {
|
||||
Some(s) => s,
|
||||
None => Session::builder()
|
||||
.map_err(|e| TrxError::Load(e.to_string()))?
|
||||
.commit_from_file(&decoder_path)
|
||||
.map_err(|e| TrxError::Load(e.to_string()))?,
|
||||
};
|
||||
|
||||
let enc_input = Self::find_input(&encoder, "input_features")?;
|
||||
let enc_output = Self::find_output(&encoder, "last_hidden_state")
|
||||
@@ -275,6 +402,53 @@ impl Transcriber for OnnxTranscriber {
|
||||
let dec_ids = Self::find_input(&decoder, "input_ids")?;
|
||||
let dec_hidden = Self::find_input(&decoder, "encoder_hidden")?;
|
||||
let dec_logits = Self::find_output(&decoder, "logits")?;
|
||||
let dec_use_cache = Self::find_input(&decoder, "use_cache_branch")?;
|
||||
|
||||
// KV-cache plumbing, discovered rather than hardcoded so the layer
|
||||
// count comes from the graph (whisper-base: 6 layers → 24 slots).
|
||||
let mut kv_slots: Vec<KvSlot> = Vec::new();
|
||||
for input in &decoder.inputs {
|
||||
if !input.name.starts_with("past_key_values.") {
|
||||
continue;
|
||||
}
|
||||
let dims: Vec<i64> = match &input.input_type {
|
||||
ValueType::Tensor { shape, .. } => shape.to_vec(),
|
||||
other => {
|
||||
return Err(TrxError::Load(format!(
|
||||
"KV input '{}' has non-tensor type {other:?}",
|
||||
input.name
|
||||
)))
|
||||
}
|
||||
};
|
||||
let present = present_name_for(&input.name);
|
||||
if !decoder.outputs.iter().any(|o| o.name == present) {
|
||||
return Err(TrxError::Load(format!(
|
||||
"decoder missing present output '{present}'"
|
||||
)));
|
||||
}
|
||||
kv_slots.push(KvSlot {
|
||||
self_attention: input.name.contains(".decoder."),
|
||||
dummy_dims: dummy_past_dims(&dims),
|
||||
past: input.name.clone(),
|
||||
present,
|
||||
});
|
||||
}
|
||||
if kv_slots.is_empty() {
|
||||
return Err(TrxError::Load(
|
||||
"decoder has no past_key_values inputs — expected the merged (KV-cache) export"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
// A pre-KV-cache install leaves the old plain decoder behind — 208 MB
|
||||
// of dead weight once the merged graph loads. Best-effort removal.
|
||||
let legacy = model.join("decoder_model.onnx");
|
||||
if legacy.exists() {
|
||||
match std::fs::remove_file(&legacy) {
|
||||
Ok(()) => tracing::info!("removed superseded decoder_model.onnx"),
|
||||
Err(e) => tracing::warn!("couldn't remove superseded decoder_model.onnx: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
let detok = Detok::from_tokenizer_json(&tokenizer_path)?;
|
||||
let gen = GenConfig::from_file(&gen_config_path)?;
|
||||
@@ -290,6 +464,9 @@ impl Transcriber for OnnxTranscriber {
|
||||
dec_ids,
|
||||
dec_hidden,
|
||||
dec_logits,
|
||||
kv_slots,
|
||||
dec_use_cache,
|
||||
decoder_ep,
|
||||
detok,
|
||||
initial_tokens,
|
||||
special_floor: gen.decoder_start,
|
||||
@@ -457,6 +634,28 @@ mod tests {
|
||||
assert_eq!(d.get(&'A'), Some(&b'A'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_name_follows_optimum_convention() {
|
||||
assert_eq!(
|
||||
present_name_for("past_key_values.3.decoder.key"),
|
||||
"present.3.decoder.key"
|
||||
);
|
||||
assert_eq!(
|
||||
present_name_for("past_key_values.0.encoder.value"),
|
||||
"present.0.encoder.value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dummy_past_dims_pins_dynamic_axes_to_one_and_keeps_static_ones() {
|
||||
// whisper-base decoder self-attention past: [batch, 8, past_seq, 64]
|
||||
// with batch/past_seq dynamic — both pin to 1 (ORT rejects 0-length
|
||||
// dims for raw-data tensors; the no-cache branch ignores the values).
|
||||
assert_eq!(dummy_past_dims(&[-1, 8, -1, 64]), vec![1, 8, 1, 64]);
|
||||
// A static trailing shape is preserved (batch still pins to 1).
|
||||
assert_eq!(dummy_past_dims(&[-1, 8, 1500, 64]), vec![1, 8, 1500, 64]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_decoder_covers_all_256_bytes() {
|
||||
let d = byte_decoder();
|
||||
@@ -481,10 +680,14 @@ mod tests {
|
||||
return;
|
||||
};
|
||||
let wav = std::env::var("WA_NPU_TEST_WAV").expect("set WA_NPU_TEST_WAV");
|
||||
// Surface the engine's own EP-selection logs (GPU-decoder fallback
|
||||
// warnings etc.) — the test harness has no subscriber otherwise.
|
||||
let _ = tracing_subscriber::fmt().with_env_filter("info").try_init();
|
||||
let t0 = std::time::Instant::now();
|
||||
let t = OnnxTranscriber::load(Path::new(&model_dir), BackendId::Npu, None)
|
||||
.expect("load NPU transcriber");
|
||||
let load_ms = t0.elapsed().as_millis();
|
||||
eprintln!("[spike] decoder EP: {}", t.decoder_ep());
|
||||
let t1 = std::time::Instant::now();
|
||||
let segs = t.transcribe_file(Path::new(&wav)).expect("transcribe");
|
||||
let infer_ms = t1.elapsed().as_millis();
|
||||
|
||||
@@ -15,9 +15,19 @@ const REPO: &str = "onnx-community/whisper-base.en";
|
||||
pub const DEFAULT_ONNX_MODEL: &str = "base.en";
|
||||
|
||||
/// The four artifacts the `OnnxTranscriber` needs, relative to the repo root.
|
||||
/// The decoder is Optimum's *merged* export — both the no-past and with-past
|
||||
/// branches in one graph, selected by a `use_cache_branch` input — which is
|
||||
/// what enables the KV-cache decode loop (the plain `decoder_model.onnx`
|
||||
/// re-ran the whole prefix every step). Existing installs migrate for free:
|
||||
/// swapping this entry flips `is_installed` to false, the startup background
|
||||
/// fetch re-runs `download`, and `download` skips the three files already on
|
||||
/// disk, fetching only the merged decoder.
|
||||
const FILES: &[(&str, &str)] = &[
|
||||
("encoder_model.onnx", "onnx/encoder_model.onnx"),
|
||||
("decoder_model.onnx", "onnx/decoder_model.onnx"),
|
||||
(
|
||||
"decoder_model_merged.onnx",
|
||||
"onnx/decoder_model_merged.onnx",
|
||||
),
|
||||
("tokenizer.json", "tokenizer.json"),
|
||||
("generation_config.json", "generation_config.json"),
|
||||
];
|
||||
@@ -45,7 +55,7 @@ pub fn model_files(id: &str) -> OnnxModelFiles {
|
||||
let dir = onnx_dir(id);
|
||||
OnnxModelFiles {
|
||||
encoder: dir.join("encoder_model.onnx"),
|
||||
decoder: dir.join("decoder_model.onnx"),
|
||||
decoder: dir.join("decoder_model_merged.onnx"),
|
||||
tokenizer: dir.join("tokenizer.json"),
|
||||
generation_config: dir.join("generation_config.json"),
|
||||
}
|
||||
@@ -125,6 +135,6 @@ mod tests {
|
||||
fn catalog_lists_the_four_required_artifacts() {
|
||||
assert_eq!(FILES.len(), 4);
|
||||
assert!(FILES.iter().any(|(n, _)| *n == "encoder_model.onnx"));
|
||||
assert!(FILES.iter().any(|(n, _)| *n == "decoder_model.onnx"));
|
||||
assert!(FILES.iter().any(|(n, _)| *n == "decoder_model_merged.onnx"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "WhispAssist",
|
||||
"version": "0.5.2",
|
||||
"version": "0.7.5",
|
||||
"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;
|
||||
|
||||
+75
-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: (
|
||||
@@ -574,6 +621,11 @@ export const api = {
|
||||
lockVault: () => invoke<void>("lock_vault"),
|
||||
changeVaultPassword: (oldPassword: string, newPassword: string) =>
|
||||
invoke<void>("change_vault_password", { args: { oldPassword, newPassword } }),
|
||||
/** Meetings with at least one plaintext artifact on disk (empty when the
|
||||
* vault isn't set up) — drives the "unencrypted" padlock in the list. */
|
||||
listUnencryptedMeetings: () => invoke<MeetingId[]>("list_unencrypted_meetings"),
|
||||
/** Seal a meeting's artifacts in place; requires the vault to be unlocked. */
|
||||
encryptMeeting: (meetingId: MeetingId) => invoke<void>("encrypt_meeting", { meetingId }),
|
||||
};
|
||||
|
||||
// ---- Events (Rust → UI) ----
|
||||
@@ -588,12 +640,23 @@ 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: (
|
||||
cb: (p: { meetingId: string; speakers: SpeakerInfo[] }) => void,
|
||||
): Promise<UnlistenFn> => listen("diarization://updated", (e) => cb(e.payload as never)),
|
||||
onLlmToken: (cb: (p: { meetingId: string; text: string }) => void): Promise<UnlistenFn> =>
|
||||
listen("llm://token", (e) => cb(e.payload as never)),
|
||||
onLlmDone: (cb: (p: { meetingId: string; summary: SummaryFile }) => void): Promise<UnlistenFn> =>
|
||||
@@ -604,6 +667,14 @@ 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">
|
||||
{t("import.title_label")} <em>({t("import.optional")})</em>
|
||||
<input bind:value={title} placeholder={t("import.title_placeholder")} disabled={busy} />
|
||||
</label>
|
||||
<div class="grid">
|
||||
<label>
|
||||
{t("import.model_label")}
|
||||
<select bind:value={model} disabled={busy || models.length === 0}>
|
||||
{#each models as m (m.id)}
|
||||
<option value={m.id}>{m.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="hint">{t("import.model_hint")}</span>
|
||||
</label>
|
||||
|
||||
<p class="muted small">
|
||||
<LinkIcon size={12} aria-hidden="true" />
|
||||
{t("import.requires_1")} <code>ffmpeg</code>
|
||||
{t("import.requires_2")} <code>yt-dlp</code>
|
||||
{t("import.requires_3")}
|
||||
</p>
|
||||
<label>
|
||||
{t("import.title_label")} <em>({t("import.optional")})</em>
|
||||
<input bind:value={title} placeholder={t("import.title_placeholder")} disabled={busy} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="tools">
|
||||
<span class="muted small">{t("import.requires")}</span>
|
||||
<button class="tool" type="button" onclick={() => api.openUrl(FFMPEG_URL)}>
|
||||
<Download size={12} aria-hidden="true" /> ffmpeg
|
||||
</button>
|
||||
<button class="tool" type="button" onclick={() => api.openUrl(YTDLP_URL)}>
|
||||
<Download size={12} aria-hidden="true" /> yt-dlp
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
@@ -121,6 +157,7 @@
|
||||
<button class="primary" onclick={doImport} disabled={!source.trim() || busy}>
|
||||
{busy ? t("import.importing") : t("import.import")}
|
||||
</button>
|
||||
<span class="muted small note">{t("import.background_note")}</span>
|
||||
<button class="link" onclick={onClose} disabled={busy}>{t("import.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +180,7 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
width: min(520px, 100%);
|
||||
width: min(540px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||
@@ -179,7 +216,8 @@
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
input {
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
@@ -189,10 +227,18 @@
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
input:focus-visible {
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
@@ -213,18 +259,49 @@
|
||||
padding: 0.4rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Model + title side by side on wide panels, stacked when cramped. */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 0.75rem;
|
||||
}
|
||||
@media (max-width: 460px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
.tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: var(--bg-hover, transparent);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.small {
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger, #d33);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
@@ -232,6 +309,10 @@
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.actions .note {
|
||||
flex: 1;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.actions .primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg, #fff);
|
||||
|
||||
@@ -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>
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "بدّل إلى نموذج متعدد اللغات في الأعلى لاختيار لغة.",
|
||||
|
||||
"settings.hardware.title": "العتاد",
|
||||
"settings.hardware.refresh": "تحديث",
|
||||
"settings.hardware.test_output": "اختبار",
|
||||
"settings.hardware.test_mic": "اختبار",
|
||||
"settings.hardware.testing": "يستمع…",
|
||||
"settings.hardware.play_tone": "تشغيل نغمة",
|
||||
"settings.hardware.stress_title": "اختبار إجهاد سريع",
|
||||
"settings.hardware.stress_hint": "يقيس أداء النماذج المثبّتة على كل خلفية متاحة ويوصي بالأدق منها الذي يواكب الكلام المباشر. يستغرق لحظات.",
|
||||
"settings.hardware.stress_run": "تشغيل اختبار الإجهاد",
|
||||
"settings.hardware.stress_running": "قيد التشغيل…",
|
||||
"settings.hardware.stress_progress": "جارٍ قياس {pair}…",
|
||||
"settings.hardware.stress_recommend": "الموصى به: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "تطبيق",
|
||||
"settings.hardware.stress_none": "لا يوجد نموذج مثبّت يواكب الكلام المباشر على هذا الجهاز — جرّب نموذجاً أصغر.",
|
||||
"settings.hardware.stress_backend": "الخلفية",
|
||||
"settings.hardware.stress_model": "النموذج",
|
||||
"settings.hardware.stress_rtf": "السرعة (×الزمن الفعلي)",
|
||||
"settings.hardware.stress_realtime": "مباشر؟",
|
||||
"settings.hardware.stress_yes": "نعم",
|
||||
"settings.hardware.stress_no": "لا",
|
||||
"settings.hardware.active_backend": "الخلفية النشطة",
|
||||
"settings.hardware.model_meta": "· نموذج {size}",
|
||||
"settings.hardware.preferred_backend": "الخلفية المفضلة",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "جارٍ تنزيل {name}",
|
||||
"settings.hardware.active_badge": "نشط",
|
||||
"settings.hardware.installed": "مثبّت",
|
||||
"settings.hardware.quantization_for": "التكميم لـ {name}",
|
||||
"settings.hardware.quant_recommended": "{quant} · موصى به",
|
||||
"settings.hardware.quant_higher": "{quant} · جودة أعلى، أكبر حجماً",
|
||||
"settings.hardware.use": "استخدام",
|
||||
"settings.hardware.remove": "إزالة",
|
||||
"settings.hardware.cant_remove_active": "لا يمكن إزالة النموذج النشط",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "التقويم وOutlook .pst",
|
||||
"settings.calendar.intro_1": "استورد الأحداث والحضور من نسخة Outlook الاحتياطية المحلية",
|
||||
"settings.calendar.intro_2": "— للقراءة فقط، لا يُكتب أي شيء مرة أخرى في الملف. لا يغادر أي شيء هذا الجهاز.",
|
||||
"settings.calendar.requires": "يتطلب وجود هذا في PATH لديك (غير مضمّن):",
|
||||
"settings.calendar.pst_file": "ملف .pst",
|
||||
"settings.calendar.no_file": "لم يتم اختيار ملف",
|
||||
"settings.calendar.browse": "استعراض…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "مقفلة",
|
||||
"settings.privacy.vault_locked_2": ". افتح القفل لقراءة الاجتماعات المشفّرة.",
|
||||
"settings.privacy.password": "كلمة المرور",
|
||||
"settings.privacy.show_password": "إظهار كلمة المرور",
|
||||
"settings.privacy.unlock": "فتح القفل",
|
||||
"settings.privacy.vault_unlocked_1": "الخزنة ",
|
||||
"settings.privacy.unlocked_word": "مفتوحة",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "اختياري",
|
||||
"import.title_placeholder": "افتراضيًا اسم الملف",
|
||||
"import.filter_av": "صوت / فيديو",
|
||||
"import.requires_1": "يتطلب",
|
||||
"import.requires_2": "مثبّتًا وفي PATH لديك (بالإضافة إلى",
|
||||
"import.requires_3": "للعناوين URL). لا يرفق WhispAssist بهما.",
|
||||
"import.model_label": "نموذج التفريغ",
|
||||
"import.model_hint": "يُسجَّل مع الاجتماع لتعرف النموذج الذي استُخدم في التفريغ.",
|
||||
"import.requires": "يتطلب وجود هذه في PATH لديك (غير مضمّنة):",
|
||||
"import.background_note": "يعمل في الخلفية — تابع تقدمه في القائمة.",
|
||||
"import.importing": "جارٍ الاستيراد… قد يستغرق هذا بعض الوقت",
|
||||
"import.import": "استيراد",
|
||||
"import.cancel": "إلغاء",
|
||||
"import.tracker.label": "تقدم الاستيراد",
|
||||
"import.tracker.running": "جارٍ الاستيراد…",
|
||||
"import.tracker.done": "اكتمل الاستيراد",
|
||||
"import.tracker.failed": "فشل الاستيراد",
|
||||
"import.tracker.working": "جارٍ العمل…",
|
||||
"import.tracker.stopped": "متوقف",
|
||||
"import.phase.prepare": "تحويل الترميز",
|
||||
"import.phase.transcribe": "التفريغ",
|
||||
"import.phase.diarize": "تمييز المتحدثين",
|
||||
"import.phase.finalize": "الإنهاء",
|
||||
|
||||
"tagchip.filter": "تصفية الاجتماعات الموسومة بـ «{tag}»",
|
||||
"tagchip.remove": "إزالة الوسم {tag}",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "إلغاء",
|
||||
"app.cancel_title": "تجاهل هذا التسجيل وحذفه",
|
||||
"app.recording": "جارٍ التسجيل…",
|
||||
"app.mute": "كتم الميكروفون",
|
||||
"app.unmute": "إلغاء كتم الميكروفون",
|
||||
"app.mute_title": "كتم الميكروفون (M)",
|
||||
"app.unmute_title": "إلغاء كتم الميكروفون (M)",
|
||||
"app.backend_title": "خلفية النسخ النشطة",
|
||||
"app.retention_title": "حفظ الصوت كـ .wav لهذا الاجتماع",
|
||||
"app.saving": "جارٍ الحفظ",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "خطأ",
|
||||
"meetings.resume": "استئناف النسخ",
|
||||
"meetings.delete_aria": "حذف الاجتماع",
|
||||
"meetings.not_encrypted": "غير مشفّر",
|
||||
"meetings.encrypt": "تشفير",
|
||||
"meetings.encrypting": "جارٍ التشفير…",
|
||||
"meetings.encrypt_title": "تشفير ملفات هذا الاجتماع بالخزنة",
|
||||
"meetings.delete_confirm": "حذف هذا الاجتماع؟ يؤدي ذلك إلى إزالة تسجيله ونسخه وملاحظاته.",
|
||||
|
||||
"transcript.heading": "النسخ",
|
||||
"transcript.title_aria": "عنوان الاجتماع",
|
||||
"transcript.transcribed_with": "فُرّغ باستخدام",
|
||||
"transcript.lang_title": "لغة النسخ",
|
||||
"transcript.lang_auto": "جارٍ الكشف…",
|
||||
"transcript.show": "إظهار النسخ",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "مائل",
|
||||
"notes.h1": "عنوان 1",
|
||||
"notes.h2": "عنوان 2",
|
||||
"notes.h3": "عنوان 3",
|
||||
"notes.bullet": "قائمة نقطية",
|
||||
"notes.numbered": "قائمة مرقّمة",
|
||||
"notes.quote": "اقتباس",
|
||||
"notes.divider": "فاصل",
|
||||
"notes.checkbox_title": "مربع اختيار",
|
||||
"notes.checkbox_aria": "عنصر قائمة بمربع اختيار",
|
||||
"notes.enhance": "تحسين",
|
||||
"notes.enhancing": "جارٍ التحسين…",
|
||||
"notes.enhance_title": "توسيع هذه الملاحظات إلى Markdown منظّم بالاستناد إلى التفريغ (ذكاء اصطناعي)",
|
||||
"notes.enhanced_note": "حُسِّنت الملاحظات استناداً إلى التفريغ.",
|
||||
"notes.undo_enhance": "تراجع",
|
||||
"notes.edit_raw": "تحرير markdown الخام",
|
||||
"notes.render": "عرض markdown",
|
||||
"notes.editor": "المحرر",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "مُقرّة",
|
||||
"settings.recording.consent_not": "لم تُقرّ بعد",
|
||||
"settings.recording.auto_label": "بدء التسجيل تلقائيًا عند بدء حدث تقويم",
|
||||
"settings.recording.auto_hint": "فقط أثناء فتح WhispAssist. عند حلول وقت بدء حدث تقويم مستورد، يبدأ التسجيل تلقائيًا (باستخدام إعداد الاحتفاظ الافتراضي أعلاه). لا شيء يعمل في الخلفية — يُسلَّح المؤقّت فقط أثناء تشغيل التطبيق. استورد الأحداث من الإعدادات → التقويم."
|
||||
"settings.recording.auto_hint": "فقط أثناء فتح WhispAssist. عند حلول وقت بدء حدث تقويم مستورد، يبدأ التسجيل تلقائيًا (باستخدام إعداد الاحتفاظ الافتراضي أعلاه). لا شيء يعمل في الخلفية — يُسلَّح المؤقّت فقط أثناء تشغيل التطبيق. استورد الأحداث من الإعدادات → التقويم.",
|
||||
"settings.recording.autostart_label": "تشغيل WhispAssist عند تسجيل الدخول",
|
||||
"settings.recording.autostart_hint": "يبدأ WhispAssist تلقائياً عند تسجيل دخولك إلى Windows. معطّل افتراضياً؛ يثبّت إدخال بدء تشغيل خاصاً بالمستخدم (بلا صلاحيات مسؤول) ولا يبدأ التسجيل من تلقاء نفسه.",
|
||||
"settings.recording.close_tray_label": "الإغلاق إلى علبة النظام",
|
||||
"settings.recording.close_tray_hint": "إغلاق النافذة يُبقي WhispAssist يعمل في الخلفية بدلاً من الخروج. أعد فتحه من أيقونة علبة النظام؛ ولإنهائه تماماً استخدم «خروج» من قائمة العلبة. مفعّل افتراضياً."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "একটি ভাষা বেছে নিতে উপরে একটি বহুভাষিক মডেলে স্যুইচ করুন।",
|
||||
|
||||
"settings.hardware.title": "হার্ডওয়্যার",
|
||||
"settings.hardware.refresh": "রিফ্রেশ",
|
||||
"settings.hardware.test_output": "পরীক্ষা",
|
||||
"settings.hardware.test_mic": "পরীক্ষা",
|
||||
"settings.hardware.testing": "শুনছে…",
|
||||
"settings.hardware.play_tone": "টোন বাজান",
|
||||
"settings.hardware.stress_title": "দ্রুত স্ট্রেস টেস্ট",
|
||||
"settings.hardware.stress_hint": "ইনস্টল করা মডেলগুলোকে প্রতিটি উপলব্ধ ব্যাকএন্ডে বেঞ্চমার্ক করে এবং লাইভ কথার গতি ধরে রাখতে পারে এমন সবচেয়ে নির্ভুল মডেলটি সুপারিশ করে। কিছুটা সময় লাগে।",
|
||||
"settings.hardware.stress_run": "স্ট্রেস টেস্ট চালান",
|
||||
"settings.hardware.stress_running": "চলছে…",
|
||||
"settings.hardware.stress_progress": "{pair} বেঞ্চমার্ক হচ্ছে…",
|
||||
"settings.hardware.stress_recommend": "সুপারিশ: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "প্রয়োগ করুন",
|
||||
"settings.hardware.stress_none": "এই হার্ডওয়্যারে ইনস্টল করা কোনো মডেলই লাইভ কথার গতি ধরে রাখতে পারছে না — ছোট কোনো মডেল চেষ্টা করুন।",
|
||||
"settings.hardware.stress_backend": "ব্যাকএন্ড",
|
||||
"settings.hardware.stress_model": "মডেল",
|
||||
"settings.hardware.stress_rtf": "গতি (×রিয়েল-টাইম)",
|
||||
"settings.hardware.stress_realtime": "লাইভ?",
|
||||
"settings.hardware.stress_yes": "হ্যাঁ",
|
||||
"settings.hardware.stress_no": "না",
|
||||
"settings.hardware.active_backend": "সক্রিয় ব্যাকএন্ড",
|
||||
"settings.hardware.model_meta": "· মডেল {size}",
|
||||
"settings.hardware.preferred_backend": "পছন্দসই ব্যাকএন্ড",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "{name} ডাউনলোড হচ্ছে",
|
||||
"settings.hardware.active_badge": "সক্রিয়",
|
||||
"settings.hardware.installed": "ইনস্টল করা হয়েছে",
|
||||
"settings.hardware.quantization_for": "{name}-এর কোয়ান্টাইজেশন",
|
||||
"settings.hardware.quant_recommended": "{quant} · প্রস্তাবিত",
|
||||
"settings.hardware.quant_higher": "{quant} · উন্নত মান, বড়",
|
||||
"settings.hardware.use": "ব্যবহার করুন",
|
||||
"settings.hardware.remove": "সরান",
|
||||
"settings.hardware.cant_remove_active": "সক্রিয় মডেল সরানো যায় না",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "ক্যালেন্ডার ও Outlook .pst",
|
||||
"settings.calendar.intro_1": "একটি স্থানীয় Outlook ব্যাকআপ থেকে ইভেন্ট ও অংশগ্রহণকারীদের আমদানি করুন",
|
||||
"settings.calendar.intro_2": "— শুধু পড়া, ফাইলে কিছু ফিরে লেখা হয় না। এই ডিভাইস থেকে কিছুই বেরোয় না।",
|
||||
"settings.calendar.requires": "আপনার PATH-এ এটি থাকা দরকার (সাথে দেওয়া নেই):",
|
||||
"settings.calendar.pst_file": ".pst ফাইল",
|
||||
"settings.calendar.no_file": "কোনো ফাইল নির্বাচিত নেই",
|
||||
"settings.calendar.browse": "ব্রাউজ করুন…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "লক করা",
|
||||
"settings.privacy.vault_locked_2": "। এনক্রিপ্ট করা মিটিং পড়তে আনলক করুন।",
|
||||
"settings.privacy.password": "পাসওয়ার্ড",
|
||||
"settings.privacy.show_password": "পাসওয়ার্ড দেখান",
|
||||
"settings.privacy.unlock": "আনলক করুন",
|
||||
"settings.privacy.vault_unlocked_1": "ভল্ট ",
|
||||
"settings.privacy.unlocked_word": "আনলক করা",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "ঐচ্ছিক",
|
||||
"import.title_placeholder": "ডিফল্টভাবে ফাইলের নাম",
|
||||
"import.filter_av": "অডিও / ভিডিও",
|
||||
"import.requires_1": "প্রয়োজন",
|
||||
"import.requires_2": "ইনস্টল করা এবং আপনার PATH-এ (সাথে",
|
||||
"import.requires_3": "URL-এর জন্য)। WhispAssist এগুলি বান্ডল করে না।",
|
||||
"import.model_label": "ট্রান্সক্রিপশন মডেল",
|
||||
"import.model_hint": "মিটিংয়ের সাথে সংরক্ষিত হয়, যাতে দেখা যায় কীসে ট্রান্সক্রাইব হয়েছে।",
|
||||
"import.requires": "আপনার PATH-এ এগুলো থাকা দরকার (সাথে দেওয়া নেই):",
|
||||
"import.background_note": "পটভূমিতে চলে — তালিকায় অগ্রগতি দেখুন।",
|
||||
"import.importing": "আমদানি হচ্ছে… এতে কিছু সময় লাগতে পারে",
|
||||
"import.import": "আমদানি করুন",
|
||||
"import.cancel": "বাতিল করুন",
|
||||
"import.tracker.label": "আমদানির অগ্রগতি",
|
||||
"import.tracker.running": "আমদানি হচ্ছে…",
|
||||
"import.tracker.done": "আমদানি সম্পূর্ণ",
|
||||
"import.tracker.failed": "আমদানি ব্যর্থ",
|
||||
"import.tracker.working": "কাজ চলছে…",
|
||||
"import.tracker.stopped": "থামানো হয়েছে",
|
||||
"import.phase.prepare": "ট্রান্সকোড",
|
||||
"import.phase.transcribe": "ট্রান্সক্রাইব",
|
||||
"import.phase.diarize": "বক্তা শনাক্তকরণ",
|
||||
"import.phase.finalize": "চূড়ান্ত করা",
|
||||
|
||||
"tagchip.filter": "“{tag}” ট্যাগযুক্ত মিটিং ফিল্টার করুন",
|
||||
"tagchip.remove": "ট্যাগ {tag} সরান",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "বাতিল করুন",
|
||||
"app.cancel_title": "এই রেকর্ডিং বাতিল করে মুছুন",
|
||||
"app.recording": "রেকর্ড হচ্ছে…",
|
||||
"app.mute": "মাইক্রোফোন নিঃশব্দ করুন",
|
||||
"app.unmute": "মাইক্রোফোন সশব্দ করুন",
|
||||
"app.mute_title": "মাইক্রোফোন নিঃশব্দ করুন (M)",
|
||||
"app.unmute_title": "মাইক্রোফোন সশব্দ করুন (M)",
|
||||
"app.backend_title": "সক্রিয় ট্রান্সক্রিপশন ব্যাকএন্ড",
|
||||
"app.retention_title": "এই মিটিংয়ের জন্য অডিও .wav হিসেবে সংরক্ষণ করুন",
|
||||
"app.saving": "সংরক্ষণ হচ্ছে",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "ত্রুটি",
|
||||
"meetings.resume": "ট্রান্সক্রিপশন পুনরায় শুরু করুন",
|
||||
"meetings.delete_aria": "মিটিং মুছুন",
|
||||
"meetings.not_encrypted": "এনক্রিপ্ট করা নয়",
|
||||
"meetings.encrypt": "এনক্রিপ্ট করুন",
|
||||
"meetings.encrypting": "এনক্রিপ্ট হচ্ছে…",
|
||||
"meetings.encrypt_title": "ভল্ট দিয়ে এই মিটিংয়ের ফাইল এনক্রিপ্ট করুন",
|
||||
"meetings.delete_confirm": "এই মিটিং মুছবেন? এটি এর রেকর্ডিং, ট্রান্সক্রিপ্ট এবং নোট সরিয়ে ফেলে।",
|
||||
|
||||
"transcript.heading": "ট্রান্সক্রিপ্ট",
|
||||
"transcript.title_aria": "মিটিং শিরোনাম",
|
||||
"transcript.transcribed_with": "যা দিয়ে ট্রান্সক্রাইব হয়েছে:",
|
||||
"transcript.lang_title": "ট্রান্সক্রিপশন ভাষা",
|
||||
"transcript.lang_auto": "শনাক্ত হচ্ছে…",
|
||||
"transcript.show": "ট্রান্সক্রিপ্ট দেখান",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "তির্যক",
|
||||
"notes.h1": "শিরোনাম ১",
|
||||
"notes.h2": "শিরোনাম ২",
|
||||
"notes.h3": "শিরোনাম ৩",
|
||||
"notes.bullet": "বুলেট তালিকা",
|
||||
"notes.numbered": "সংখ্যাযুক্ত তালিকা",
|
||||
"notes.quote": "উদ্ধৃতি",
|
||||
"notes.divider": "বিভাজক",
|
||||
"notes.checkbox_title": "চেকবক্স",
|
||||
"notes.checkbox_aria": "চেকবক্স তালিকা আইটেম",
|
||||
"notes.enhance": "উন্নত করুন",
|
||||
"notes.enhancing": "উন্নত করা হচ্ছে…",
|
||||
"notes.enhance_title": "ট্রান্সক্রিপ্ট ব্যবহার করে এই নোটগুলোকে কাঠামোবদ্ধ Markdown-এ সম্প্রসারিত করুন (AI)",
|
||||
"notes.enhanced_note": "ট্রান্সক্রিপ্ট থেকে নোট উন্নত করা হয়েছে।",
|
||||
"notes.undo_enhance": "পূর্বাবস্থা",
|
||||
"notes.edit_raw": "কাঁচা markdown সম্পাদনা করুন",
|
||||
"notes.render": "markdown রেন্ডার করুন",
|
||||
"notes.editor": "সম্পাদক",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "স্বীকৃত",
|
||||
"settings.recording.consent_not": "এখনও স্বীকৃত নয়",
|
||||
"settings.recording.auto_label": "কোনো ক্যালেন্ডার ইভেন্ট শুরু হলে স্বয়ংক্রিয়ভাবে রেকর্ডিং শুরু করুন",
|
||||
"settings.recording.auto_hint": "শুধু WhispAssist খোলা থাকাকালীন। কোনো আমদানি করা ক্যালেন্ডার ইভেন্টের শুরুর সময় এলে, একটি রেকর্ডিং স্বয়ংক্রিয়ভাবে শুরু হয় (উপরের আপনার ডিফল্ট ধারণ সেটিং ব্যবহার করে)। পটভূমিতে কিছুই চলে না — টাইমার শুধু অ্যাপ চলাকালীন সক্রিয় থাকে। সেটিংস → ক্যালেন্ডার-এর অধীনে ইভেন্ট আমদানি করুন।"
|
||||
"settings.recording.auto_hint": "শুধু WhispAssist খোলা থাকাকালীন। কোনো আমদানি করা ক্যালেন্ডার ইভেন্টের শুরুর সময় এলে, একটি রেকর্ডিং স্বয়ংক্রিয়ভাবে শুরু হয় (উপরের আপনার ডিফল্ট ধারণ সেটিং ব্যবহার করে)। পটভূমিতে কিছুই চলে না — টাইমার শুধু অ্যাপ চলাকালীন সক্রিয় থাকে। সেটিংস → ক্যালেন্ডার-এর অধীনে ইভেন্ট আমদানি করুন।",
|
||||
"settings.recording.autostart_label": "লগইনে WhispAssist চালু করুন",
|
||||
"settings.recording.autostart_hint": "Windows-এ সাইন ইন করলে WhispAssist স্বয়ংক্রিয়ভাবে চালু হয়। ডিফল্টে বন্ধ; ব্যবহারকারী-প্রতি স্টার্টআপ এন্ট্রি যোগ করে (অ্যাডমিন লাগে না) এবং নিজে থেকে রেকর্ডিং শুরু করে না।",
|
||||
"settings.recording.close_tray_label": "সিস্টেম ট্রেতে বন্ধ করুন",
|
||||
"settings.recording.close_tray_hint": "উইন্ডো বন্ধ করলে WhispAssist বন্ধ না হয়ে পটভূমিতে চলতে থাকে। ট্রে আইকন থেকে আবার খুলুন; পুরোপুরি বন্ধ করতে ট্রের 'প্রস্থান' ব্যবহার করুন। ডিফল্টে চালু।"
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "Wechseln Sie oben zu einem mehrsprachigen Modell, um eine Sprache zu wählen.",
|
||||
|
||||
"settings.hardware.title": "Hardware",
|
||||
"settings.hardware.refresh": "Aktualisieren",
|
||||
"settings.hardware.test_output": "Testen",
|
||||
"settings.hardware.test_mic": "Testen",
|
||||
"settings.hardware.testing": "Höre zu…",
|
||||
"settings.hardware.play_tone": "Ton abspielen",
|
||||
"settings.hardware.stress_title": "Schneller Belastungstest",
|
||||
"settings.hardware.stress_hint": "Misst die installierten Modelle auf jedem verfügbaren Backend und empfiehlt das genaueste, das mit Live-Sprache Schritt hält. Dauert einen Moment.",
|
||||
"settings.hardware.stress_run": "Belastungstest starten",
|
||||
"settings.hardware.stress_running": "Läuft…",
|
||||
"settings.hardware.stress_progress": "Benchmark für {pair}…",
|
||||
"settings.hardware.stress_recommend": "Empfohlen: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "Übernehmen",
|
||||
"settings.hardware.stress_none": "Kein installiertes Modell hält auf dieser Hardware mit Live-Sprache Schritt — versuchen Sie ein kleineres Modell.",
|
||||
"settings.hardware.stress_backend": "Backend",
|
||||
"settings.hardware.stress_model": "Modell",
|
||||
"settings.hardware.stress_rtf": "Geschwindigkeit (×Echtzeit)",
|
||||
"settings.hardware.stress_realtime": "Live?",
|
||||
"settings.hardware.stress_yes": "Ja",
|
||||
"settings.hardware.stress_no": "Nein",
|
||||
"settings.hardware.active_backend": "Aktives Backend",
|
||||
"settings.hardware.model_meta": "· Modell {size}",
|
||||
"settings.hardware.preferred_backend": "Bevorzugtes Backend",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "{name} wird heruntergeladen",
|
||||
"settings.hardware.active_badge": "Aktiv",
|
||||
"settings.hardware.installed": "Installiert",
|
||||
"settings.hardware.quantization_for": "Quantisierung für {name}",
|
||||
"settings.hardware.quant_recommended": "{quant} · empfohlen",
|
||||
"settings.hardware.quant_higher": "{quant} · höhere Qualität, größer",
|
||||
"settings.hardware.use": "Verwenden",
|
||||
"settings.hardware.remove": "Entfernen",
|
||||
"settings.hardware.cant_remove_active": "Das aktive Modell kann nicht entfernt werden",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "Kalender & Outlook .pst",
|
||||
"settings.calendar.intro_1": "Importieren Sie Termine und Teilnehmer aus einem lokalen Outlook-",
|
||||
"settings.calendar.intro_2": "Backup — schreibgeschützt, es wird nichts in die Datei zurückgeschrieben. Nichts verlässt dieses Gerät.",
|
||||
"settings.calendar.requires": "Benötigt dies in Ihrem PATH (nicht mitgeliefert):",
|
||||
"settings.calendar.pst_file": ".pst-Datei",
|
||||
"settings.calendar.no_file": "Keine Datei ausgewählt",
|
||||
"settings.calendar.browse": "Durchsuchen…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "gesperrt",
|
||||
"settings.privacy.vault_locked_2": ". Entsperren Sie, um verschlüsselte Meetings zu lesen.",
|
||||
"settings.privacy.password": "Passwort",
|
||||
"settings.privacy.show_password": "Passwort anzeigen",
|
||||
"settings.privacy.unlock": "Entsperren",
|
||||
"settings.privacy.vault_unlocked_1": "Der Tresor ist ",
|
||||
"settings.privacy.unlocked_word": "entsperrt",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "optional",
|
||||
"import.title_placeholder": "Standardmäßig der Dateiname",
|
||||
"import.filter_av": "Audio / Video",
|
||||
"import.requires_1": "Erfordert",
|
||||
"import.requires_2": "installiert und in Ihrem PATH (plus",
|
||||
"import.requires_3": "für URLs). WhispAssist liefert sie nicht mit.",
|
||||
"import.model_label": "Transkriptionsmodell",
|
||||
"import.model_hint": "Wird mit dem Meeting gespeichert, damit Sie sehen, womit es transkribiert wurde.",
|
||||
"import.requires": "Benötigt diese in Ihrem PATH (nicht mitgeliefert):",
|
||||
"import.background_note": "Läuft im Hintergrund — verfolgen Sie den Fortschritt in der Liste.",
|
||||
"import.importing": "Wird importiert… das kann eine Weile dauern",
|
||||
"import.import": "Importieren",
|
||||
"import.cancel": "Abbrechen",
|
||||
"import.tracker.label": "Importfortschritt",
|
||||
"import.tracker.running": "Importiere…",
|
||||
"import.tracker.done": "Import abgeschlossen",
|
||||
"import.tracker.failed": "Import fehlgeschlagen",
|
||||
"import.tracker.working": "arbeitet…",
|
||||
"import.tracker.stopped": "gestoppt",
|
||||
"import.phase.prepare": "Transkodieren",
|
||||
"import.phase.transcribe": "Transkribieren",
|
||||
"import.phase.diarize": "Sprecher erkennen",
|
||||
"import.phase.finalize": "Abschließen",
|
||||
|
||||
"tagchip.filter": "Meetings mit dem Tag „{tag}“ filtern",
|
||||
"tagchip.remove": "Tag {tag} entfernen",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "Abbrechen",
|
||||
"app.cancel_title": "Diese Aufnahme verwerfen und löschen",
|
||||
"app.recording": "Aufnahme läuft…",
|
||||
"app.mute": "Mikrofon stummschalten",
|
||||
"app.unmute": "Stummschaltung aufheben",
|
||||
"app.mute_title": "Mikrofon stummschalten (M)",
|
||||
"app.unmute_title": "Stummschaltung aufheben (M)",
|
||||
"app.backend_title": "Aktives Transkriptions-Backend",
|
||||
"app.retention_title": "Audio als .wav für dieses Meeting speichern",
|
||||
"app.saving": "wird gespeichert",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "Fehler",
|
||||
"meetings.resume": "Transkription fortsetzen",
|
||||
"meetings.delete_aria": "Meeting löschen",
|
||||
"meetings.not_encrypted": "Nicht verschlüsselt",
|
||||
"meetings.encrypt": "Verschlüsseln",
|
||||
"meetings.encrypting": "Wird verschlüsselt…",
|
||||
"meetings.encrypt_title": "Die Dateien dieses Meetings mit dem Tresor verschlüsseln",
|
||||
"meetings.delete_confirm": "Dieses Meeting löschen? Dadurch werden seine Aufnahme, sein Transkript und seine Notizen entfernt.",
|
||||
|
||||
"transcript.heading": "Transkript",
|
||||
"transcript.title_aria": "Meetingtitel",
|
||||
"transcript.transcribed_with": "Transkribiert mit",
|
||||
"transcript.lang_title": "Transkriptionssprache",
|
||||
"transcript.lang_auto": "wird erkannt…",
|
||||
"transcript.show": "Transkript anzeigen",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "Kursiv",
|
||||
"notes.h1": "Überschrift 1",
|
||||
"notes.h2": "Überschrift 2",
|
||||
"notes.h3": "Überschrift 3",
|
||||
"notes.bullet": "Aufzählungsliste",
|
||||
"notes.numbered": "Nummerierte Liste",
|
||||
"notes.quote": "Zitat",
|
||||
"notes.divider": "Trennlinie",
|
||||
"notes.checkbox_title": "Kontrollkästchen",
|
||||
"notes.checkbox_aria": "Kontrollkästchen-Listenelement",
|
||||
"notes.enhance": "Aufbereiten",
|
||||
"notes.enhancing": "Wird aufbereitet…",
|
||||
"notes.enhance_title": "Diese Notizen mithilfe des Transkripts zu strukturiertem Markdown erweitern (KI)",
|
||||
"notes.enhanced_note": "Notizen aus dem Transkript aufbereitet.",
|
||||
"notes.undo_enhance": "Rückgängig",
|
||||
"notes.edit_raw": "Rohes Markdown bearbeiten",
|
||||
"notes.render": "Markdown rendern",
|
||||
"notes.editor": "Editor",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "bestätigt",
|
||||
"settings.recording.consent_not": "noch nicht bestätigt",
|
||||
"settings.recording.auto_label": "Aufnahme automatisch starten, wenn ein Kalendertermin beginnt",
|
||||
"settings.recording.auto_hint": "Nur während WhispAssist geöffnet ist. Wenn die Startzeit eines importierten Kalendertermins erreicht ist, beginnt automatisch eine Aufnahme (mit Ihrer obigen Standard-Aufbewahrungseinstellung). Nichts läuft im Hintergrund — der Timer ist nur aktiv, während die App läuft. Importieren Sie Termine unter Einstellungen → Kalender."
|
||||
"settings.recording.auto_hint": "Nur während WhispAssist geöffnet ist. Wenn die Startzeit eines importierten Kalendertermins erreicht ist, beginnt automatisch eine Aufnahme (mit Ihrer obigen Standard-Aufbewahrungseinstellung). Nichts läuft im Hintergrund — der Timer ist nur aktiv, während die App läuft. Importieren Sie Termine unter Einstellungen → Kalender.",
|
||||
"settings.recording.autostart_label": "WhispAssist bei der Anmeldung starten",
|
||||
"settings.recording.autostart_hint": "Startet WhispAssist automatisch, wenn Sie sich bei Windows anmelden. Standardmäßig aus; legt einen Autostart-Eintrag pro Benutzer an (keine Adminrechte nötig) und beginnt nicht von selbst mit der Aufnahme.",
|
||||
"settings.recording.close_tray_label": "In den Infobereich schließen",
|
||||
"settings.recording.close_tray_hint": "Beim Schließen des Fensters läuft WhispAssist im Hintergrund weiter, statt zu beenden. Über das Tray-Symbol wieder öffnen; zum vollständigen Beenden dort „Beenden“ wählen. Standardmäßig an."
|
||||
}
|
||||
|
||||
+62
-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",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "Downloading {name}",
|
||||
"settings.hardware.active_badge": "Active",
|
||||
"settings.hardware.installed": "Installed",
|
||||
"settings.hardware.quantization_for": "Quantization for {name}",
|
||||
"settings.hardware.quant_recommended": "{quant} · recommended",
|
||||
"settings.hardware.quant_higher": "{quant} · higher quality, larger",
|
||||
"settings.hardware.use": "Use",
|
||||
"settings.hardware.remove": "Remove",
|
||||
"settings.hardware.cant_remove_active": "Can't remove the active model",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "Calendar & Outlook .pst",
|
||||
"settings.calendar.intro_1": "Import events and attendees from a local Outlook",
|
||||
"settings.calendar.intro_2": "backup — read-only, nothing is written back to the file. Nothing leaves this device.",
|
||||
"settings.calendar.requires": "Needs this on your PATH (not bundled):",
|
||||
"settings.calendar.pst_file": ".pst file",
|
||||
"settings.calendar.no_file": "No file selected",
|
||||
"settings.calendar.browse": "Browse…",
|
||||
@@ -278,6 +301,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 +344,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 +380,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",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "error",
|
||||
"meetings.resume": "Resume transcription",
|
||||
"meetings.delete_aria": "Delete meeting",
|
||||
"meetings.not_encrypted": "Not encrypted",
|
||||
"meetings.encrypt": "Encrypt",
|
||||
"meetings.encrypting": "Encrypting…",
|
||||
"meetings.encrypt_title": "Encrypt this meeting's files with the vault",
|
||||
"meetings.delete_confirm": "Delete this meeting? This removes its recording, transcript, and notes.",
|
||||
|
||||
"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 +472,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 +580,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."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "Cambia a un modelo multilingüe arriba para elegir un idioma.",
|
||||
|
||||
"settings.hardware.title": "Hardware",
|
||||
"settings.hardware.refresh": "Actualizar",
|
||||
"settings.hardware.test_output": "Probar",
|
||||
"settings.hardware.test_mic": "Probar",
|
||||
"settings.hardware.testing": "Escuchando…",
|
||||
"settings.hardware.play_tone": "Reproducir tono",
|
||||
"settings.hardware.stress_title": "Prueba rápida de rendimiento",
|
||||
"settings.hardware.stress_hint": "Evalúa los modelos instalados en cada backend disponible y recomienda el más preciso que siga el ritmo del habla en directo. Tarda un momento.",
|
||||
"settings.hardware.stress_run": "Ejecutar prueba",
|
||||
"settings.hardware.stress_running": "Ejecutando…",
|
||||
"settings.hardware.stress_progress": "Evaluando {pair}…",
|
||||
"settings.hardware.stress_recommend": "Recomendado: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "Aplicar",
|
||||
"settings.hardware.stress_none": "Ningún modelo instalado sigue el ritmo del habla en directo en este hardware — prueba con un modelo más pequeño.",
|
||||
"settings.hardware.stress_backend": "Backend",
|
||||
"settings.hardware.stress_model": "Modelo",
|
||||
"settings.hardware.stress_rtf": "Velocidad (×tiempo real)",
|
||||
"settings.hardware.stress_realtime": "¿En directo?",
|
||||
"settings.hardware.stress_yes": "Sí",
|
||||
"settings.hardware.stress_no": "No",
|
||||
"settings.hardware.active_backend": "Backend activo",
|
||||
"settings.hardware.model_meta": "· modelo {size}",
|
||||
"settings.hardware.preferred_backend": "Backend preferido",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "Descargando {name}",
|
||||
"settings.hardware.active_badge": "Activo",
|
||||
"settings.hardware.installed": "Instalado",
|
||||
"settings.hardware.quantization_for": "Cuantización de {name}",
|
||||
"settings.hardware.quant_recommended": "{quant} · recomendado",
|
||||
"settings.hardware.quant_higher": "{quant} · mayor calidad, más grande",
|
||||
"settings.hardware.use": "Usar",
|
||||
"settings.hardware.remove": "Eliminar",
|
||||
"settings.hardware.cant_remove_active": "No se puede eliminar el modelo activo",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "Calendario y Outlook .pst",
|
||||
"settings.calendar.intro_1": "Importa eventos y asistentes desde una copia de seguridad local de Outlook",
|
||||
"settings.calendar.intro_2": "— solo lectura, no se escribe nada de vuelta en el archivo. Nada sale de este dispositivo.",
|
||||
"settings.calendar.requires": "Necesita esto en tu PATH (no incluido):",
|
||||
"settings.calendar.pst_file": "Archivo .pst",
|
||||
"settings.calendar.no_file": "Ningún archivo seleccionado",
|
||||
"settings.calendar.browse": "Examinar…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "bloqueada",
|
||||
"settings.privacy.vault_locked_2": ". Desbloquéala para leer las reuniones cifradas.",
|
||||
"settings.privacy.password": "Contraseña",
|
||||
"settings.privacy.show_password": "Mostrar contraseña",
|
||||
"settings.privacy.unlock": "Desbloquear",
|
||||
"settings.privacy.vault_unlocked_1": "La bóveda está ",
|
||||
"settings.privacy.unlocked_word": "desbloqueada",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "opcional",
|
||||
"import.title_placeholder": "Por defecto, el nombre del archivo",
|
||||
"import.filter_av": "Audio / vídeo",
|
||||
"import.requires_1": "Requiere",
|
||||
"import.requires_2": "instalado y en tu PATH (además de",
|
||||
"import.requires_3": "para las URL). WhispAssist no los incluye.",
|
||||
"import.model_label": "Modelo de transcripción",
|
||||
"import.model_hint": "Se guarda con la reunión para que veas cómo se transcribió.",
|
||||
"import.requires": "Necesita estos en tu PATH (no incluidos):",
|
||||
"import.background_note": "Se ejecuta en segundo plano — síguelo en la lista.",
|
||||
"import.importing": "Importando… esto puede tardar un rato",
|
||||
"import.import": "Importar",
|
||||
"import.cancel": "Cancelar",
|
||||
"import.tracker.label": "Progreso de la importación",
|
||||
"import.tracker.running": "Importando…",
|
||||
"import.tracker.done": "Importación completada",
|
||||
"import.tracker.failed": "Importación fallida",
|
||||
"import.tracker.working": "trabajando…",
|
||||
"import.tracker.stopped": "detenido",
|
||||
"import.phase.prepare": "Transcodificar",
|
||||
"import.phase.transcribe": "Transcribir",
|
||||
"import.phase.diarize": "Identificar hablantes",
|
||||
"import.phase.finalize": "Finalizar",
|
||||
|
||||
"tagchip.filter": "Filtrar reuniones etiquetadas con «{tag}»",
|
||||
"tagchip.remove": "Eliminar etiqueta {tag}",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "Cancelar",
|
||||
"app.cancel_title": "Descartar esta grabación y eliminarla",
|
||||
"app.recording": "Grabando…",
|
||||
"app.mute": "Silenciar micrófono",
|
||||
"app.unmute": "Activar micrófono",
|
||||
"app.mute_title": "Silenciar micrófono (M)",
|
||||
"app.unmute_title": "Activar micrófono (M)",
|
||||
"app.backend_title": "Backend de transcripción activo",
|
||||
"app.retention_title": "Guardar el audio como .wav para esta reunión",
|
||||
"app.saving": "guardando",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "error",
|
||||
"meetings.resume": "Reanudar transcripción",
|
||||
"meetings.delete_aria": "Eliminar reunión",
|
||||
"meetings.not_encrypted": "Sin cifrar",
|
||||
"meetings.encrypt": "Cifrar",
|
||||
"meetings.encrypting": "Cifrando…",
|
||||
"meetings.encrypt_title": "Cifrar los archivos de esta reunión con la caja fuerte",
|
||||
"meetings.delete_confirm": "¿Eliminar esta reunión? Se eliminarán su grabación, transcripción y notas.",
|
||||
|
||||
"transcript.heading": "Transcripción",
|
||||
"transcript.title_aria": "Título de la reunión",
|
||||
"transcript.transcribed_with": "Transcrito con",
|
||||
"transcript.lang_title": "Idioma de transcripción",
|
||||
"transcript.lang_auto": "detectando…",
|
||||
"transcript.show": "Mostrar transcripción",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "Cursiva",
|
||||
"notes.h1": "Encabezado 1",
|
||||
"notes.h2": "Encabezado 2",
|
||||
"notes.h3": "Encabezado 3",
|
||||
"notes.bullet": "Lista con viñetas",
|
||||
"notes.numbered": "Lista numerada",
|
||||
"notes.quote": "Cita",
|
||||
"notes.divider": "Separador",
|
||||
"notes.checkbox_title": "Casilla de verificación",
|
||||
"notes.checkbox_aria": "Elemento de lista con casilla",
|
||||
"notes.enhance": "Mejorar",
|
||||
"notes.enhancing": "Mejorando…",
|
||||
"notes.enhance_title": "Ampliar estas notas a Markdown estructurado usando la transcripción (IA)",
|
||||
"notes.enhanced_note": "Notas mejoradas a partir de la transcripción.",
|
||||
"notes.undo_enhance": "Deshacer",
|
||||
"notes.edit_raw": "Editar el markdown en bruto",
|
||||
"notes.render": "Renderizar el markdown",
|
||||
"notes.editor": "Editor",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "reconocido",
|
||||
"settings.recording.consent_not": "aún no reconocido",
|
||||
"settings.recording.auto_label": "Iniciar la grabación automáticamente cuando comience un evento del calendario",
|
||||
"settings.recording.auto_hint": "Solo mientras WhispAssist esté abierto. Cuando llega la hora de inicio de un evento del calendario importado, comienza una grabación automáticamente (usando tu ajuste de retención predeterminado de arriba). Nada se ejecuta en segundo plano — el temporizador solo se arma mientras la aplicación está en ejecución. Importa eventos en Ajustes → Calendario."
|
||||
"settings.recording.auto_hint": "Solo mientras WhispAssist esté abierto. Cuando llega la hora de inicio de un evento del calendario importado, comienza una grabación automáticamente (usando tu ajuste de retención predeterminado de arriba). Nada se ejecuta en segundo plano — el temporizador solo se arma mientras la aplicación está en ejecución. Importa eventos en Ajustes → Calendario.",
|
||||
"settings.recording.autostart_label": "Iniciar WhispAssist al iniciar sesión",
|
||||
"settings.recording.autostart_hint": "Inicia WhispAssist automáticamente al iniciar sesión en Windows. Desactivado por defecto; instala una entrada de inicio por usuario (sin permisos de administrador) y no empieza a grabar por sí solo.",
|
||||
"settings.recording.close_tray_label": "Cerrar a la bandeja del sistema",
|
||||
"settings.recording.close_tray_hint": "Al cerrar la ventana, WhispAssist sigue ejecutándose en segundo plano en lugar de salir. Vuelve a abrirlo desde el icono de la bandeja; usa Salir en la bandeja para cerrarlo del todo. Activado por defecto."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "Cambia a un modelo multilingüe arriba para elegir un idioma.",
|
||||
|
||||
"settings.hardware.title": "Hardware",
|
||||
"settings.hardware.refresh": "Actualizar",
|
||||
"settings.hardware.test_output": "Probar",
|
||||
"settings.hardware.test_mic": "Probar",
|
||||
"settings.hardware.testing": "Escuchando…",
|
||||
"settings.hardware.play_tone": "Reproducir tono",
|
||||
"settings.hardware.stress_title": "Prueba rápida de rendimiento",
|
||||
"settings.hardware.stress_hint": "Evalúa los modelos instalados en cada backend disponible y recomienda el más preciso que siga el ritmo del habla en vivo. Tarda un momento.",
|
||||
"settings.hardware.stress_run": "Ejecutar prueba",
|
||||
"settings.hardware.stress_running": "Ejecutando…",
|
||||
"settings.hardware.stress_progress": "Evaluando {pair}…",
|
||||
"settings.hardware.stress_recommend": "Recomendado: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "Aplicar",
|
||||
"settings.hardware.stress_none": "Ningún modelo instalado sigue el ritmo del habla en vivo en este hardware — prueba con un modelo más pequeño.",
|
||||
"settings.hardware.stress_backend": "Backend",
|
||||
"settings.hardware.stress_model": "Modelo",
|
||||
"settings.hardware.stress_rtf": "Velocidad (×tiempo real)",
|
||||
"settings.hardware.stress_realtime": "¿En vivo?",
|
||||
"settings.hardware.stress_yes": "Sí",
|
||||
"settings.hardware.stress_no": "No",
|
||||
"settings.hardware.active_backend": "Backend activo",
|
||||
"settings.hardware.model_meta": "· modelo {size}",
|
||||
"settings.hardware.preferred_backend": "Backend preferido",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "Descargando {name}",
|
||||
"settings.hardware.active_badge": "Activo",
|
||||
"settings.hardware.installed": "Instalado",
|
||||
"settings.hardware.quantization_for": "Cuantización de {name}",
|
||||
"settings.hardware.quant_recommended": "{quant} · recomendado",
|
||||
"settings.hardware.quant_higher": "{quant} · mayor calidad, más grande",
|
||||
"settings.hardware.use": "Usar",
|
||||
"settings.hardware.remove": "Quitar",
|
||||
"settings.hardware.cant_remove_active": "No se puede quitar el modelo activo",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "Calendario y Outlook .pst",
|
||||
"settings.calendar.intro_1": "Importa eventos y asistentes desde un respaldo local de Outlook",
|
||||
"settings.calendar.intro_2": "— solo lectura, no se escribe nada de vuelta en el archivo. Nada sale de este dispositivo.",
|
||||
"settings.calendar.requires": "Necesita esto en tu PATH (no incluido):",
|
||||
"settings.calendar.pst_file": "Archivo .pst",
|
||||
"settings.calendar.no_file": "Ningún archivo seleccionado",
|
||||
"settings.calendar.browse": "Examinar…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "bloqueada",
|
||||
"settings.privacy.vault_locked_2": ". Desbloquéala para leer las reuniones cifradas.",
|
||||
"settings.privacy.password": "Contraseña",
|
||||
"settings.privacy.show_password": "Mostrar contraseña",
|
||||
"settings.privacy.unlock": "Desbloquear",
|
||||
"settings.privacy.vault_unlocked_1": "La bóveda está ",
|
||||
"settings.privacy.unlocked_word": "desbloqueada",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "opcional",
|
||||
"import.title_placeholder": "Por defecto, el nombre del archivo",
|
||||
"import.filter_av": "Audio / video",
|
||||
"import.requires_1": "Requiere",
|
||||
"import.requires_2": "instalado y en tu PATH (además de",
|
||||
"import.requires_3": "para las URL). WhispAssist no los incluye.",
|
||||
"import.model_label": "Modelo de transcripción",
|
||||
"import.model_hint": "Se guarda con la reunión para que veas cómo se transcribió.",
|
||||
"import.requires": "Necesita estos en tu PATH (no incluidos):",
|
||||
"import.background_note": "Se ejecuta en segundo plano — síguelo en la lista.",
|
||||
"import.importing": "Importando… esto puede tardar un rato",
|
||||
"import.import": "Importar",
|
||||
"import.cancel": "Cancelar",
|
||||
"import.tracker.label": "Progreso de la importación",
|
||||
"import.tracker.running": "Importando…",
|
||||
"import.tracker.done": "Importación completada",
|
||||
"import.tracker.failed": "Importación fallida",
|
||||
"import.tracker.working": "trabajando…",
|
||||
"import.tracker.stopped": "detenido",
|
||||
"import.phase.prepare": "Transcodificar",
|
||||
"import.phase.transcribe": "Transcribir",
|
||||
"import.phase.diarize": "Identificar hablantes",
|
||||
"import.phase.finalize": "Finalizar",
|
||||
|
||||
"tagchip.filter": "Filtrar reuniones etiquetadas con «{tag}»",
|
||||
"tagchip.remove": "Quitar etiqueta {tag}",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "Cancelar",
|
||||
"app.cancel_title": "Descartar esta grabación y eliminarla",
|
||||
"app.recording": "Grabando…",
|
||||
"app.mute": "Silenciar micrófono",
|
||||
"app.unmute": "Activar micrófono",
|
||||
"app.mute_title": "Silenciar micrófono (M)",
|
||||
"app.unmute_title": "Activar micrófono (M)",
|
||||
"app.backend_title": "Backend de transcripción activo",
|
||||
"app.retention_title": "Guardar el audio como .wav para esta reunión",
|
||||
"app.saving": "guardando",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "error",
|
||||
"meetings.resume": "Reanudar transcripción",
|
||||
"meetings.delete_aria": "Eliminar reunión",
|
||||
"meetings.not_encrypted": "Sin cifrar",
|
||||
"meetings.encrypt": "Cifrar",
|
||||
"meetings.encrypting": "Cifrando…",
|
||||
"meetings.encrypt_title": "Cifrar los archivos de esta reunión con la bóveda",
|
||||
"meetings.delete_confirm": "¿Eliminar esta reunión? Se eliminarán su grabación, transcripción y notas.",
|
||||
|
||||
"transcript.heading": "Transcripción",
|
||||
"transcript.title_aria": "Título de la reunión",
|
||||
"transcript.transcribed_with": "Transcrito con",
|
||||
"transcript.lang_title": "Idioma de transcripción",
|
||||
"transcript.lang_auto": "detectando…",
|
||||
"transcript.show": "Mostrar transcripción",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "Cursiva",
|
||||
"notes.h1": "Encabezado 1",
|
||||
"notes.h2": "Encabezado 2",
|
||||
"notes.h3": "Encabezado 3",
|
||||
"notes.bullet": "Lista con viñetas",
|
||||
"notes.numbered": "Lista numerada",
|
||||
"notes.quote": "Cita",
|
||||
"notes.divider": "Separador",
|
||||
"notes.checkbox_title": "Casilla de verificación",
|
||||
"notes.checkbox_aria": "Elemento de lista con casilla",
|
||||
"notes.enhance": "Mejorar",
|
||||
"notes.enhancing": "Mejorando…",
|
||||
"notes.enhance_title": "Ampliar estas notas a Markdown estructurado usando la transcripción (IA)",
|
||||
"notes.enhanced_note": "Notas mejoradas a partir de la transcripción.",
|
||||
"notes.undo_enhance": "Deshacer",
|
||||
"notes.edit_raw": "Editar el markdown en bruto",
|
||||
"notes.render": "Renderizar el markdown",
|
||||
"notes.editor": "Editor",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "reconocido",
|
||||
"settings.recording.consent_not": "aún no reconocido",
|
||||
"settings.recording.auto_label": "Iniciar la grabación automáticamente cuando comience un evento del calendario",
|
||||
"settings.recording.auto_hint": "Solo mientras WhispAssist esté abierto. Cuando llega la hora de inicio de un evento del calendario importado, comienza una grabación automáticamente (usando tu configuración de retención predeterminada de arriba). Nada se ejecuta en segundo plano — el temporizador solo se arma mientras la aplicación está en ejecución. Importa eventos en Configuración → Calendario."
|
||||
"settings.recording.auto_hint": "Solo mientras WhispAssist esté abierto. Cuando llega la hora de inicio de un evento del calendario importado, comienza una grabación automáticamente (usando tu configuración de retención predeterminada de arriba). Nada se ejecuta en segundo plano — el temporizador solo se arma mientras la aplicación está en ejecución. Importa eventos en Configuración → Calendario.",
|
||||
"settings.recording.autostart_label": "Iniciar WhispAssist al iniciar sesión",
|
||||
"settings.recording.autostart_hint": "Inicia WhispAssist automáticamente al iniciar sesión en Windows. Desactivado por defecto; instala una entrada de inicio por usuario (sin permisos de administrador) y no empieza a grabar por sí solo.",
|
||||
"settings.recording.close_tray_label": "Cerrar a la bandeja del sistema",
|
||||
"settings.recording.close_tray_hint": "Al cerrar la ventana, WhispAssist sigue ejecutándose en segundo plano en lugar de salir. Vuelve a abrirlo desde el ícono de la bandeja; usa Salir en la bandeja para cerrarlo por completo. Activado por defecto."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "Vaihda yllä monikieliseen malliin valitaksesi kielen.",
|
||||
|
||||
"settings.hardware.title": "Laitteisto",
|
||||
"settings.hardware.refresh": "Päivitä",
|
||||
"settings.hardware.test_output": "Testaa",
|
||||
"settings.hardware.test_mic": "Testaa",
|
||||
"settings.hardware.testing": "Kuunnellaan…",
|
||||
"settings.hardware.play_tone": "Toista ääni",
|
||||
"settings.hardware.stress_title": "Pikainen rasitustesti",
|
||||
"settings.hardware.stress_hint": "Mittaa asennetut mallit jokaisella käytettävissä olevalla taustajärjestelmällä ja suosittelee tarkinta, joka pysyy puheen tahdissa. Kestää hetken.",
|
||||
"settings.hardware.stress_run": "Suorita rasitustesti",
|
||||
"settings.hardware.stress_running": "Suoritetaan…",
|
||||
"settings.hardware.stress_progress": "Mitataan {pair}…",
|
||||
"settings.hardware.stress_recommend": "Suositus: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "Käytä",
|
||||
"settings.hardware.stress_none": "Mikään asennettu malli ei pysy puheen tahdissa tällä laitteistolla — kokeile pienempää mallia.",
|
||||
"settings.hardware.stress_backend": "Taustajärjestelmä",
|
||||
"settings.hardware.stress_model": "Malli",
|
||||
"settings.hardware.stress_rtf": "Nopeus (×reaaliaika)",
|
||||
"settings.hardware.stress_realtime": "Reaaliaikainen?",
|
||||
"settings.hardware.stress_yes": "Kyllä",
|
||||
"settings.hardware.stress_no": "Ei",
|
||||
"settings.hardware.active_backend": "Aktiivinen taustajärjestelmä",
|
||||
"settings.hardware.model_meta": "· malli {size}",
|
||||
"settings.hardware.preferred_backend": "Ensisijainen taustajärjestelmä",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "Ladataan {name}",
|
||||
"settings.hardware.active_badge": "Aktiivinen",
|
||||
"settings.hardware.installed": "Asennettu",
|
||||
"settings.hardware.quantization_for": "Kvantisointi: {name}",
|
||||
"settings.hardware.quant_recommended": "{quant} · suositeltu",
|
||||
"settings.hardware.quant_higher": "{quant} · parempi laatu, suurempi",
|
||||
"settings.hardware.use": "Käytä",
|
||||
"settings.hardware.remove": "Poista",
|
||||
"settings.hardware.cant_remove_active": "Aktiivista mallia ei voi poistaa",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "Kalenteri ja Outlook .pst",
|
||||
"settings.calendar.intro_1": "Tuo tapahtumia ja osallistujia paikallisesta Outlook-",
|
||||
"settings.calendar.intro_2": "varmuuskopiosta — vain luku, mitään ei kirjoiteta takaisin tiedostoon. Mikään ei poistu tältä laitteelta.",
|
||||
"settings.calendar.requires": "Vaatii tämän PATH-polusta (ei sisälly):",
|
||||
"settings.calendar.pst_file": ".pst-tiedosto",
|
||||
"settings.calendar.no_file": "Ei valittua tiedostoa",
|
||||
"settings.calendar.browse": "Selaa…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "lukittu",
|
||||
"settings.privacy.vault_locked_2": ". Avaa lukitus lukeaksesi salatut kokoukset.",
|
||||
"settings.privacy.password": "Salasana",
|
||||
"settings.privacy.show_password": "Näytä salasana",
|
||||
"settings.privacy.unlock": "Avaa lukitus",
|
||||
"settings.privacy.vault_unlocked_1": "Holvi on ",
|
||||
"settings.privacy.unlocked_word": "avattu",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "valinnainen",
|
||||
"import.title_placeholder": "Oletuksena tiedoston nimi",
|
||||
"import.filter_av": "Ääni / video",
|
||||
"import.requires_1": "Vaatii",
|
||||
"import.requires_2": "asennettuna ja PATH:issasi (sekä",
|
||||
"import.requires_3": "URL-osoitteille). WhispAssist ei sisällä niitä.",
|
||||
"import.model_label": "Transkriptiomalli",
|
||||
"import.model_hint": "Tallennetaan kokouksen mukana, jotta näet millä se litteroitiin.",
|
||||
"import.requires": "Vaatii nämä PATH-polusta (eivät sisälly):",
|
||||
"import.background_note": "Suoritetaan taustalla — seuraa etenemistä listasta.",
|
||||
"import.importing": "Tuodaan… tämä voi kestää hetken",
|
||||
"import.import": "Tuo",
|
||||
"import.cancel": "Peruuta",
|
||||
"import.tracker.label": "Tuonnin eteneminen",
|
||||
"import.tracker.running": "Tuodaan…",
|
||||
"import.tracker.done": "Tuonti valmis",
|
||||
"import.tracker.failed": "Tuonti epäonnistui",
|
||||
"import.tracker.working": "työskentelee…",
|
||||
"import.tracker.stopped": "pysäytetty",
|
||||
"import.phase.prepare": "Transkoodaus",
|
||||
"import.phase.transcribe": "Litterointi",
|
||||
"import.phase.diarize": "Puhujien tunnistus",
|
||||
"import.phase.finalize": "Viimeistely",
|
||||
|
||||
"tagchip.filter": "Suodata kokoukset, joilla on tunniste ”{tag}”",
|
||||
"tagchip.remove": "Poista tunniste {tag}",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "Peruuta",
|
||||
"app.cancel_title": "Hylkää tämä tallennus ja poista se",
|
||||
"app.recording": "Tallennetaan…",
|
||||
"app.mute": "Mykistä mikrofoni",
|
||||
"app.unmute": "Poista mykistys",
|
||||
"app.mute_title": "Mykistä mikrofoni (M)",
|
||||
"app.unmute_title": "Poista mykistys (M)",
|
||||
"app.backend_title": "Aktiivinen litteroinnin taustajärjestelmä",
|
||||
"app.retention_title": "Tallenna ääni .wav-muodossa tälle kokoukselle",
|
||||
"app.saving": "tallennetaan",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "virhe",
|
||||
"meetings.resume": "Jatka litterointia",
|
||||
"meetings.delete_aria": "Poista kokous",
|
||||
"meetings.not_encrypted": "Salaamaton",
|
||||
"meetings.encrypt": "Salaa",
|
||||
"meetings.encrypting": "Salataan…",
|
||||
"meetings.encrypt_title": "Salaa tämän kokouksen tiedostot holvilla",
|
||||
"meetings.delete_confirm": "Poistetaanko tämä kokous? Tämä poistaa sen tallennuksen, litteroinnin ja muistiinpanot.",
|
||||
|
||||
"transcript.heading": "Litterointi",
|
||||
"transcript.title_aria": "Kokouksen otsikko",
|
||||
"transcript.transcribed_with": "Litteroitu mallilla",
|
||||
"transcript.lang_title": "Litterointikieli",
|
||||
"transcript.lang_auto": "tunnistetaan…",
|
||||
"transcript.show": "Näytä litterointi",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "Kursivointi",
|
||||
"notes.h1": "Otsikko 1",
|
||||
"notes.h2": "Otsikko 2",
|
||||
"notes.h3": "Otsikko 3",
|
||||
"notes.bullet": "Luettelomerkitty lista",
|
||||
"notes.numbered": "Numeroitu luettelo",
|
||||
"notes.quote": "Lainaus",
|
||||
"notes.divider": "Erotin",
|
||||
"notes.checkbox_title": "Valintaruutu",
|
||||
"notes.checkbox_aria": "Valintaruutulistan kohde",
|
||||
"notes.enhance": "Paranna",
|
||||
"notes.enhancing": "Parannetaan…",
|
||||
"notes.enhance_title": "Laajenna muistiinpanot jäsennellyksi Markdowniksi transkription avulla (tekoäly)",
|
||||
"notes.enhanced_note": "Muistiinpanot paranneltu transkription pohjalta.",
|
||||
"notes.undo_enhance": "Kumoa",
|
||||
"notes.edit_raw": "Muokkaa raakaa markdownia",
|
||||
"notes.render": "Renderöi markdown",
|
||||
"notes.editor": "Editori",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "vahvistettu",
|
||||
"settings.recording.consent_not": "ei vielä vahvistettu",
|
||||
"settings.recording.auto_label": "Aloita tallennus automaattisesti, kun kalenteritapahtuma alkaa",
|
||||
"settings.recording.auto_hint": "Vain kun WhispAssist on auki. Kun tuodun kalenteritapahtuman alkamisaika koittaa, tallennus alkaa automaattisesti (käyttäen yllä olevaa oletussäilytysasetustasi). Mikään ei suoritu taustalla — ajastin on viritetty vain sovelluksen ollessa käynnissä. Tuo tapahtumia kohdasta Asetukset → Kalenteri."
|
||||
"settings.recording.auto_hint": "Vain kun WhispAssist on auki. Kun tuodun kalenteritapahtuman alkamisaika koittaa, tallennus alkaa automaattisesti (käyttäen yllä olevaa oletussäilytysasetustasi). Mikään ei suoritu taustalla — ajastin on viritetty vain sovelluksen ollessa käynnissä. Tuo tapahtumia kohdasta Asetukset → Kalenteri.",
|
||||
"settings.recording.autostart_label": "Käynnistä WhispAssist kirjautuessa",
|
||||
"settings.recording.autostart_hint": "Käynnistää WhispAssistin automaattisesti, kun kirjaudut Windowsiin. Oletuksena pois; asentaa käyttäjäkohtaisen käynnistysmerkinnän (ei vaadi järjestelmänvalvojaa) eikä aloita tallennusta itsestään.",
|
||||
"settings.recording.close_tray_label": "Sulje ilmaisinalueelle",
|
||||
"settings.recording.close_tray_hint": "Ikkunan sulkeminen jättää WhispAssistin käyntiin taustalle sulkemisen sijaan. Avaa uudelleen ilmaisinalueen kuvakkeesta; lopeta kokonaan valitsemalla sieltä Lopeta. Oletuksena päällä."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "Passez à un modèle multilingue ci-dessus pour choisir une langue.",
|
||||
|
||||
"settings.hardware.title": "Matériel",
|
||||
"settings.hardware.refresh": "Actualiser",
|
||||
"settings.hardware.test_output": "Tester",
|
||||
"settings.hardware.test_mic": "Tester",
|
||||
"settings.hardware.testing": "Écoute…",
|
||||
"settings.hardware.play_tone": "Jouer un son",
|
||||
"settings.hardware.stress_title": "Test de charge rapide",
|
||||
"settings.hardware.stress_hint": "Évalue les modèles installés sur chaque backend disponible et recommande le plus précis qui suit la parole en direct. Prend un instant.",
|
||||
"settings.hardware.stress_run": "Lancer le test",
|
||||
"settings.hardware.stress_running": "En cours…",
|
||||
"settings.hardware.stress_progress": "Évaluation de {pair}…",
|
||||
"settings.hardware.stress_recommend": "Recommandé : {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "Appliquer",
|
||||
"settings.hardware.stress_none": "Aucun modèle installé ne suit la parole en direct sur ce matériel — essayez un modèle plus petit.",
|
||||
"settings.hardware.stress_backend": "Backend",
|
||||
"settings.hardware.stress_model": "Modèle",
|
||||
"settings.hardware.stress_rtf": "Vitesse (×temps réel)",
|
||||
"settings.hardware.stress_realtime": "En direct?",
|
||||
"settings.hardware.stress_yes": "Oui",
|
||||
"settings.hardware.stress_no": "Non",
|
||||
"settings.hardware.active_backend": "Moteur actif",
|
||||
"settings.hardware.model_meta": "· modèle {size}",
|
||||
"settings.hardware.preferred_backend": "Moteur préféré",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "Téléchargement de {name}",
|
||||
"settings.hardware.active_badge": "Actif",
|
||||
"settings.hardware.installed": "Installé",
|
||||
"settings.hardware.quantization_for": "Quantification de {name}",
|
||||
"settings.hardware.quant_recommended": "{quant} · recommandé",
|
||||
"settings.hardware.quant_higher": "{quant} · meilleure qualité, plus volumineux",
|
||||
"settings.hardware.use": "Utiliser",
|
||||
"settings.hardware.remove": "Supprimer",
|
||||
"settings.hardware.cant_remove_active": "Impossible de supprimer le modèle actif",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "Calendrier et Outlook .pst",
|
||||
"settings.calendar.intro_1": "Importez des événements et des participants depuis une sauvegarde Outlook locale",
|
||||
"settings.calendar.intro_2": "— en lecture seule, rien n'est réécrit dans le fichier. Rien ne quitte cet appareil.",
|
||||
"settings.calendar.requires": "Nécessite ceci dans votre PATH (non fourni) :",
|
||||
"settings.calendar.pst_file": "Fichier .pst",
|
||||
"settings.calendar.no_file": "Aucun fichier sélectionné",
|
||||
"settings.calendar.browse": "Parcourir…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "verrouillé",
|
||||
"settings.privacy.vault_locked_2": ". Déverrouillez pour lire les réunions chiffrées.",
|
||||
"settings.privacy.password": "Mot de passe",
|
||||
"settings.privacy.show_password": "Afficher le mot de passe",
|
||||
"settings.privacy.unlock": "Déverrouiller",
|
||||
"settings.privacy.vault_unlocked_1": "Le coffre est ",
|
||||
"settings.privacy.unlocked_word": "déverrouillé",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "facultatif",
|
||||
"import.title_placeholder": "Par défaut, le nom du fichier",
|
||||
"import.filter_av": "Audio / vidéo",
|
||||
"import.requires_1": "Nécessite",
|
||||
"import.requires_2": "installé et dans votre PATH (plus",
|
||||
"import.requires_3": "pour les URL). WhispAssist ne les inclut pas.",
|
||||
"import.model_label": "Modèle de transcription",
|
||||
"import.model_hint": "Enregistré avec la réunion pour voir comment elle a été transcrite.",
|
||||
"import.requires": "Nécessite ceux-ci dans votre PATH (non fournis) :",
|
||||
"import.background_note": "S'exécute en arrière-plan — suivez-le dans la liste.",
|
||||
"import.importing": "Importation… cela peut prendre un moment",
|
||||
"import.import": "Importer",
|
||||
"import.cancel": "Annuler",
|
||||
"import.tracker.label": "Progression de l'importation",
|
||||
"import.tracker.running": "Importation en cours…",
|
||||
"import.tracker.done": "Importation terminée",
|
||||
"import.tracker.failed": "Échec de l'importation",
|
||||
"import.tracker.working": "en cours…",
|
||||
"import.tracker.stopped": "arrêté",
|
||||
"import.phase.prepare": "Transcodage",
|
||||
"import.phase.transcribe": "Transcription",
|
||||
"import.phase.diarize": "Identification des locuteurs",
|
||||
"import.phase.finalize": "Finalisation",
|
||||
|
||||
"tagchip.filter": "Filtrer les réunions marquées « {tag} »",
|
||||
"tagchip.remove": "Supprimer l'étiquette {tag}",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "Annuler",
|
||||
"app.cancel_title": "Abandonner cet enregistrement et le supprimer",
|
||||
"app.recording": "Enregistrement…",
|
||||
"app.mute": "Couper le micro",
|
||||
"app.unmute": "Réactiver le micro",
|
||||
"app.mute_title": "Couper le micro (M)",
|
||||
"app.unmute_title": "Réactiver le micro (M)",
|
||||
"app.backend_title": "Moteur de transcription actif",
|
||||
"app.retention_title": "Enregistrer l'audio en .wav pour cette réunion",
|
||||
"app.saving": "enregistrement",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "erreur",
|
||||
"meetings.resume": "Reprendre la transcription",
|
||||
"meetings.delete_aria": "Supprimer la réunion",
|
||||
"meetings.not_encrypted": "Non chiffré",
|
||||
"meetings.encrypt": "Chiffrer",
|
||||
"meetings.encrypting": "Chiffrement…",
|
||||
"meetings.encrypt_title": "Chiffrer les fichiers de cette réunion avec le coffre",
|
||||
"meetings.delete_confirm": "Supprimer cette réunion ? Cela supprime son enregistrement, sa transcription et ses notes.",
|
||||
|
||||
"transcript.heading": "Transcription",
|
||||
"transcript.title_aria": "Titre de la réunion",
|
||||
"transcript.transcribed_with": "Transcrit avec",
|
||||
"transcript.lang_title": "Langue de transcription",
|
||||
"transcript.lang_auto": "détection en cours…",
|
||||
"transcript.show": "Afficher la transcription",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "Italique",
|
||||
"notes.h1": "Titre 1",
|
||||
"notes.h2": "Titre 2",
|
||||
"notes.h3": "Titre 3",
|
||||
"notes.bullet": "Liste à puces",
|
||||
"notes.numbered": "Liste numérotée",
|
||||
"notes.quote": "Citation",
|
||||
"notes.divider": "Séparateur",
|
||||
"notes.checkbox_title": "Case à cocher",
|
||||
"notes.checkbox_aria": "Élément de liste à cocher",
|
||||
"notes.enhance": "Enrichir",
|
||||
"notes.enhancing": "Enrichissement…",
|
||||
"notes.enhance_title": "Développer ces notes en Markdown structuré à partir de la transcription (IA)",
|
||||
"notes.enhanced_note": "Notes enrichies à partir de la transcription.",
|
||||
"notes.undo_enhance": "Annuler",
|
||||
"notes.edit_raw": "Modifier le markdown brut",
|
||||
"notes.render": "Afficher le markdown",
|
||||
"notes.editor": "Éditeur",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "accepté",
|
||||
"settings.recording.consent_not": "pas encore accepté",
|
||||
"settings.recording.auto_label": "Démarrer automatiquement l'enregistrement au début d'un événement du calendrier",
|
||||
"settings.recording.auto_hint": "Uniquement lorsque WhispAssist est ouvert. Lorsque l'heure de début d'un événement de calendrier importé arrive, un enregistrement commence automatiquement (en utilisant votre réglage de conservation par défaut ci-dessus). Rien ne s'exécute en arrière-plan — la minuterie n'est armée que pendant que l'application est en cours d'exécution. Importez des événements sous Paramètres → Calendrier."
|
||||
"settings.recording.auto_hint": "Uniquement lorsque WhispAssist est ouvert. Lorsque l'heure de début d'un événement de calendrier importé arrive, un enregistrement commence automatiquement (en utilisant votre réglage de conservation par défaut ci-dessus). Rien ne s'exécute en arrière-plan — la minuterie n'est armée que pendant que l'application est en cours d'exécution. Importez des événements sous Paramètres → Calendrier.",
|
||||
"settings.recording.autostart_label": "Lancer WhispAssist à l'ouverture de session",
|
||||
"settings.recording.autostart_hint": "Démarre WhispAssist automatiquement à l'ouverture de session Windows. Désactivé par défaut; installe une entrée de démarrage par utilisateur (aucun droit administrateur requis) et ne commence jamais à enregistrer de lui-même.",
|
||||
"settings.recording.close_tray_label": "Fermer vers la zone de notification",
|
||||
"settings.recording.close_tray_hint": "Fermer la fenêtre laisse WhispAssist tourner en arrière-plan au lieu de quitter. Rouvrez-le depuis l'icône de la zone de notification; utilisez Quitter dans son menu pour fermer complètement. Activé par défaut."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "Passez à un modèle multilingue ci-dessus pour choisir une langue.",
|
||||
|
||||
"settings.hardware.title": "Matériel",
|
||||
"settings.hardware.refresh": "Actualiser",
|
||||
"settings.hardware.test_output": "Tester",
|
||||
"settings.hardware.test_mic": "Tester",
|
||||
"settings.hardware.testing": "Écoute…",
|
||||
"settings.hardware.play_tone": "Jouer un son",
|
||||
"settings.hardware.stress_title": "Test de charge rapide",
|
||||
"settings.hardware.stress_hint": "Évalue les modèles installés sur chaque backend disponible et recommande le plus précis qui suit la parole en direct. Prend un instant.",
|
||||
"settings.hardware.stress_run": "Lancer le test",
|
||||
"settings.hardware.stress_running": "En cours…",
|
||||
"settings.hardware.stress_progress": "Évaluation de {pair}…",
|
||||
"settings.hardware.stress_recommend": "Recommandé : {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "Appliquer",
|
||||
"settings.hardware.stress_none": "Aucun modèle installé ne suit la parole en direct sur ce matériel — essayez un modèle plus petit.",
|
||||
"settings.hardware.stress_backend": "Backend",
|
||||
"settings.hardware.stress_model": "Modèle",
|
||||
"settings.hardware.stress_rtf": "Vitesse (×temps réel)",
|
||||
"settings.hardware.stress_realtime": "En direct ?",
|
||||
"settings.hardware.stress_yes": "Oui",
|
||||
"settings.hardware.stress_no": "Non",
|
||||
"settings.hardware.active_backend": "Backend actif",
|
||||
"settings.hardware.model_meta": "· modèle {size}",
|
||||
"settings.hardware.preferred_backend": "Backend préféré",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "Téléchargement de {name}",
|
||||
"settings.hardware.active_badge": "Actif",
|
||||
"settings.hardware.installed": "Installé",
|
||||
"settings.hardware.quantization_for": "Quantification de {name}",
|
||||
"settings.hardware.quant_recommended": "{quant} · recommandé",
|
||||
"settings.hardware.quant_higher": "{quant} · meilleure qualité, plus volumineux",
|
||||
"settings.hardware.use": "Utiliser",
|
||||
"settings.hardware.remove": "Supprimer",
|
||||
"settings.hardware.cant_remove_active": "Impossible de supprimer le modèle actif",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "Calendrier et Outlook .pst",
|
||||
"settings.calendar.intro_1": "Importez des événements et des participants depuis une sauvegarde Outlook locale",
|
||||
"settings.calendar.intro_2": "— en lecture seule, rien n'est réécrit dans le fichier. Rien ne quitte cet appareil.",
|
||||
"settings.calendar.requires": "Nécessite ceci dans votre PATH (non fourni) :",
|
||||
"settings.calendar.pst_file": "Fichier .pst",
|
||||
"settings.calendar.no_file": "Aucun fichier sélectionné",
|
||||
"settings.calendar.browse": "Parcourir…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "verrouillé",
|
||||
"settings.privacy.vault_locked_2": ". Déverrouillez pour lire les réunions chiffrées.",
|
||||
"settings.privacy.password": "Mot de passe",
|
||||
"settings.privacy.show_password": "Afficher le mot de passe",
|
||||
"settings.privacy.unlock": "Déverrouiller",
|
||||
"settings.privacy.vault_unlocked_1": "Le coffre est ",
|
||||
"settings.privacy.unlocked_word": "déverrouillé",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "facultatif",
|
||||
"import.title_placeholder": "Par défaut, le nom du fichier",
|
||||
"import.filter_av": "Audio / vidéo",
|
||||
"import.requires_1": "Nécessite",
|
||||
"import.requires_2": "installé et dans votre PATH (plus",
|
||||
"import.requires_3": "pour les URL). WhispAssist ne les inclut pas.",
|
||||
"import.model_label": "Modèle de transcription",
|
||||
"import.model_hint": "Enregistré avec la réunion pour voir comment elle a été transcrite.",
|
||||
"import.requires": "Nécessite ceux-ci dans votre PATH (non fournis) :",
|
||||
"import.background_note": "S'exécute en arrière-plan — suivez-le dans la liste.",
|
||||
"import.importing": "Importation… cela peut prendre un moment",
|
||||
"import.import": "Importer",
|
||||
"import.cancel": "Annuler",
|
||||
"import.tracker.label": "Progression de l'import",
|
||||
"import.tracker.running": "Import en cours…",
|
||||
"import.tracker.done": "Import terminé",
|
||||
"import.tracker.failed": "Échec de l'import",
|
||||
"import.tracker.working": "en cours…",
|
||||
"import.tracker.stopped": "arrêté",
|
||||
"import.phase.prepare": "Transcodage",
|
||||
"import.phase.transcribe": "Transcription",
|
||||
"import.phase.diarize": "Identification des locuteurs",
|
||||
"import.phase.finalize": "Finalisation",
|
||||
|
||||
"tagchip.filter": "Filtrer les réunions marquées « {tag} »",
|
||||
"tagchip.remove": "Supprimer l'étiquette {tag}",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "Annuler",
|
||||
"app.cancel_title": "Abandonner cet enregistrement et le supprimer",
|
||||
"app.recording": "Enregistrement…",
|
||||
"app.mute": "Couper le micro",
|
||||
"app.unmute": "Réactiver le micro",
|
||||
"app.mute_title": "Couper le micro (M)",
|
||||
"app.unmute_title": "Réactiver le micro (M)",
|
||||
"app.backend_title": "Backend de transcription actif",
|
||||
"app.retention_title": "Enregistrer l'audio en .wav pour cette réunion",
|
||||
"app.saving": "enregistrement",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "erreur",
|
||||
"meetings.resume": "Reprendre la transcription",
|
||||
"meetings.delete_aria": "Supprimer la réunion",
|
||||
"meetings.not_encrypted": "Non chiffré",
|
||||
"meetings.encrypt": "Chiffrer",
|
||||
"meetings.encrypting": "Chiffrement…",
|
||||
"meetings.encrypt_title": "Chiffrer les fichiers de cette réunion avec le coffre",
|
||||
"meetings.delete_confirm": "Supprimer cette réunion ? Cela supprime son enregistrement, sa transcription et ses notes.",
|
||||
|
||||
"transcript.heading": "Transcription",
|
||||
"transcript.title_aria": "Titre de la réunion",
|
||||
"transcript.transcribed_with": "Transcrit avec",
|
||||
"transcript.lang_title": "Langue de transcription",
|
||||
"transcript.lang_auto": "détection en cours…",
|
||||
"transcript.show": "Afficher la transcription",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "Italique",
|
||||
"notes.h1": "Titre 1",
|
||||
"notes.h2": "Titre 2",
|
||||
"notes.h3": "Titre 3",
|
||||
"notes.bullet": "Liste à puces",
|
||||
"notes.numbered": "Liste numérotée",
|
||||
"notes.quote": "Citation",
|
||||
"notes.divider": "Séparateur",
|
||||
"notes.checkbox_title": "Case à cocher",
|
||||
"notes.checkbox_aria": "Élément de liste à cocher",
|
||||
"notes.enhance": "Enrichir",
|
||||
"notes.enhancing": "Enrichissement…",
|
||||
"notes.enhance_title": "Développer ces notes en Markdown structuré à partir de la transcription (IA)",
|
||||
"notes.enhanced_note": "Notes enrichies à partir de la transcription.",
|
||||
"notes.undo_enhance": "Annuler",
|
||||
"notes.edit_raw": "Modifier le markdown brut",
|
||||
"notes.render": "Afficher le markdown",
|
||||
"notes.editor": "Éditeur",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "accepté",
|
||||
"settings.recording.consent_not": "pas encore accepté",
|
||||
"settings.recording.auto_label": "Démarrer automatiquement l'enregistrement au début d'un événement du calendrier",
|
||||
"settings.recording.auto_hint": "Uniquement lorsque WhispAssist est ouvert. Lorsque l'heure de début d'un événement de calendrier importé arrive, un enregistrement commence automatiquement (en utilisant votre réglage de conservation par défaut ci-dessus). Rien ne s'exécute en arrière-plan — la minuterie n'est armée que pendant que l'application est en cours d'exécution. Importez des événements sous Paramètres → Calendrier."
|
||||
"settings.recording.auto_hint": "Uniquement lorsque WhispAssist est ouvert. Lorsque l'heure de début d'un événement de calendrier importé arrive, un enregistrement commence automatiquement (en utilisant votre réglage de conservation par défaut ci-dessus). Rien ne s'exécute en arrière-plan — la minuterie n'est armée que pendant que l'application est en cours d'exécution. Importez des événements sous Paramètres → Calendrier.",
|
||||
"settings.recording.autostart_label": "Lancer WhispAssist à la connexion",
|
||||
"settings.recording.autostart_hint": "Démarre WhispAssist automatiquement à l'ouverture de session Windows. Désactivé par défaut ; installe une entrée de démarrage par utilisateur (aucun droit administrateur requis) et ne commence jamais à enregistrer de lui-même.",
|
||||
"settings.recording.close_tray_label": "Fermer vers la zone de notification",
|
||||
"settings.recording.close_tray_hint": "Fermer la fenêtre laisse WhispAssist tourner en arrière-plan au lieu de quitter. Rouvrez-le depuis l'icône de la zone de notification ; utilisez Quitter dans son menu pour fermer complètement. Activé par défaut."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "भाषा चुनने के लिए ऊपर किसी बहुभाषी मॉडल पर स्विच करें।",
|
||||
|
||||
"settings.hardware.title": "हार्डवेयर",
|
||||
"settings.hardware.refresh": "रीफ़्रेश करें",
|
||||
"settings.hardware.test_output": "परखें",
|
||||
"settings.hardware.test_mic": "परखें",
|
||||
"settings.hardware.testing": "सुन रहा है…",
|
||||
"settings.hardware.play_tone": "टोन बजाएँ",
|
||||
"settings.hardware.stress_title": "त्वरित स्ट्रेस टेस्ट",
|
||||
"settings.hardware.stress_hint": "इंस्टॉल किए गए मॉडलों को हर उपलब्ध बैकएंड पर बेंचमार्क करता है और सबसे सटीक मॉडल सुझाता है जो लाइव बोलचाल की रफ़्तार बनाए रखे। इसमें थोड़ा समय लगता है।",
|
||||
"settings.hardware.stress_run": "स्ट्रेस टेस्ट चलाएँ",
|
||||
"settings.hardware.stress_running": "चल रहा है…",
|
||||
"settings.hardware.stress_progress": "{pair} का बेंचमार्क हो रहा है…",
|
||||
"settings.hardware.stress_recommend": "अनुशंसित: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "लागू करें",
|
||||
"settings.hardware.stress_none": "इस हार्डवेयर पर कोई भी इंस्टॉल किया गया मॉडल लाइव बोलचाल की रफ़्तार नहीं बनाए रख पाता — कोई छोटा मॉडल आज़माएँ।",
|
||||
"settings.hardware.stress_backend": "बैकएंड",
|
||||
"settings.hardware.stress_model": "मॉडल",
|
||||
"settings.hardware.stress_rtf": "गति (×रियल-टाइम)",
|
||||
"settings.hardware.stress_realtime": "लाइव?",
|
||||
"settings.hardware.stress_yes": "हाँ",
|
||||
"settings.hardware.stress_no": "नहीं",
|
||||
"settings.hardware.active_backend": "सक्रिय बैकएंड",
|
||||
"settings.hardware.model_meta": "· मॉडल {size}",
|
||||
"settings.hardware.preferred_backend": "पसंदीदा बैकएंड",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "{name} डाउनलोड हो रहा है",
|
||||
"settings.hardware.active_badge": "सक्रिय",
|
||||
"settings.hardware.installed": "इंस्टॉल किया गया",
|
||||
"settings.hardware.quantization_for": "{name} के लिए क्वांटाइज़ेशन",
|
||||
"settings.hardware.quant_recommended": "{quant} · अनुशंसित",
|
||||
"settings.hardware.quant_higher": "{quant} · बेहतर गुणवत्ता, बड़ा",
|
||||
"settings.hardware.use": "उपयोग करें",
|
||||
"settings.hardware.remove": "हटाएँ",
|
||||
"settings.hardware.cant_remove_active": "सक्रिय मॉडल को नहीं हटाया जा सकता",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "कैलेंडर और Outlook .pst",
|
||||
"settings.calendar.intro_1": "किसी स्थानीय Outlook बैकअप से ईवेंट और उपस्थित लोगों को आयात करें",
|
||||
"settings.calendar.intro_2": "— केवल पढ़ने योग्य, फ़ाइल में कुछ भी वापस नहीं लिखा जाता। इस डिवाइस से कुछ भी बाहर नहीं जाता।",
|
||||
"settings.calendar.requires": "आपके PATH में यह होना ज़रूरी है (साथ में शामिल नहीं):",
|
||||
"settings.calendar.pst_file": ".pst फ़ाइल",
|
||||
"settings.calendar.no_file": "कोई फ़ाइल चयनित नहीं",
|
||||
"settings.calendar.browse": "ब्राउज़ करें…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "लॉक",
|
||||
"settings.privacy.vault_locked_2": " है। एन्क्रिप्ट की गई मीटिंग पढ़ने के लिए अनलॉक करें।",
|
||||
"settings.privacy.password": "पासवर्ड",
|
||||
"settings.privacy.show_password": "पासवर्ड दिखाएँ",
|
||||
"settings.privacy.unlock": "अनलॉक करें",
|
||||
"settings.privacy.vault_unlocked_1": "वॉल्ट ",
|
||||
"settings.privacy.unlocked_word": "अनलॉक",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "वैकल्पिक",
|
||||
"import.title_placeholder": "डिफ़ॉल्ट रूप से फ़ाइल का नाम",
|
||||
"import.filter_av": "ऑडियो / वीडियो",
|
||||
"import.requires_1": "आवश्यक है",
|
||||
"import.requires_2": "इंस्टॉल किया गया और आपके PATH में (साथ ही",
|
||||
"import.requires_3": "URL के लिए)। WhispAssist उन्हें बंडल नहीं करता।",
|
||||
"import.model_label": "ट्रांसक्रिप्शन मॉडल",
|
||||
"import.model_hint": "मीटिंग के साथ दर्ज होता है ताकि आप देख सकें कि ट्रांसक्रिप्शन किससे हुआ।",
|
||||
"import.requires": "आपके PATH में ये होने ज़रूरी हैं (साथ में शामिल नहीं):",
|
||||
"import.background_note": "बैकग्राउंड में चलता है — सूची में इसकी प्रगति देखें।",
|
||||
"import.importing": "आयात हो रहा है… इसमें कुछ समय लग सकता है",
|
||||
"import.import": "आयात करें",
|
||||
"import.cancel": "रद्द करें",
|
||||
"import.tracker.label": "आयात प्रगति",
|
||||
"import.tracker.running": "आयात हो रहा है…",
|
||||
"import.tracker.done": "आयात पूर्ण",
|
||||
"import.tracker.failed": "आयात विफल",
|
||||
"import.tracker.working": "काम जारी…",
|
||||
"import.tracker.stopped": "रोका गया",
|
||||
"import.phase.prepare": "ट्रांसकोड",
|
||||
"import.phase.transcribe": "ट्रांसक्राइब",
|
||||
"import.phase.diarize": "वक्ताओं की पहचान",
|
||||
"import.phase.finalize": "अंतिम रूप देना",
|
||||
|
||||
"tagchip.filter": "“{tag}” टैग वाली मीटिंग फ़िल्टर करें",
|
||||
"tagchip.remove": "टैग {tag} हटाएँ",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "रद्द करें",
|
||||
"app.cancel_title": "इस रिकॉर्डिंग को छोड़ें और हटाएँ",
|
||||
"app.recording": "रिकॉर्ड हो रहा है…",
|
||||
"app.mute": "माइक्रोफ़ोन म्यूट करें",
|
||||
"app.unmute": "माइक्रोफ़ोन अनम्यूट करें",
|
||||
"app.mute_title": "माइक्रोफ़ोन म्यूट करें (M)",
|
||||
"app.unmute_title": "माइक्रोफ़ोन अनम्यूट करें (M)",
|
||||
"app.backend_title": "सक्रिय ट्रांसक्रिप्शन बैकएंड",
|
||||
"app.retention_title": "इस मीटिंग के लिए ऑडियो को .wav के रूप में सहेजें",
|
||||
"app.saving": "सहेजा जा रहा है",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "त्रुटि",
|
||||
"meetings.resume": "ट्रांसक्रिप्शन फिर से शुरू करें",
|
||||
"meetings.delete_aria": "मीटिंग हटाएँ",
|
||||
"meetings.not_encrypted": "एन्क्रिप्ट नहीं है",
|
||||
"meetings.encrypt": "एन्क्रिप्ट करें",
|
||||
"meetings.encrypting": "एन्क्रिप्ट हो रहा है…",
|
||||
"meetings.encrypt_title": "इस मीटिंग की फ़ाइलें वॉल्ट से एन्क्रिप्ट करें",
|
||||
"meetings.delete_confirm": "इस मीटिंग को हटाएँ? यह इसकी रिकॉर्डिंग, ट्रांसक्रिप्ट और नोट्स हटा देता है।",
|
||||
|
||||
"transcript.heading": "ट्रांसक्रिप्ट",
|
||||
"transcript.title_aria": "मीटिंग शीर्षक",
|
||||
"transcript.transcribed_with": "इससे ट्रांसक्राइब किया गया:",
|
||||
"transcript.lang_title": "ट्रांसक्रिप्शन भाषा",
|
||||
"transcript.lang_auto": "पहचान हो रही है…",
|
||||
"transcript.show": "ट्रांसक्रिप्ट दिखाएँ",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "इटैलिक",
|
||||
"notes.h1": "शीर्षक 1",
|
||||
"notes.h2": "शीर्षक 2",
|
||||
"notes.h3": "शीर्षक 3",
|
||||
"notes.bullet": "बुलेट सूची",
|
||||
"notes.numbered": "क्रमांकित सूची",
|
||||
"notes.quote": "उद्धरण",
|
||||
"notes.divider": "विभाजक",
|
||||
"notes.checkbox_title": "चेकबॉक्स",
|
||||
"notes.checkbox_aria": "चेकबॉक्स सूची आइटम",
|
||||
"notes.enhance": "बेहतर बनाएँ",
|
||||
"notes.enhancing": "बेहतर बनाया जा रहा है…",
|
||||
"notes.enhance_title": "ट्रांसक्रिप्ट की मदद से इन नोट्स को संरचित Markdown में विस्तारित करें (AI)",
|
||||
"notes.enhanced_note": "ट्रांसक्रिप्ट से नोट्स बेहतर बनाए गए।",
|
||||
"notes.undo_enhance": "पूर्ववत करें",
|
||||
"notes.edit_raw": "कच्चा markdown संपादित करें",
|
||||
"notes.render": "markdown रेंडर करें",
|
||||
"notes.editor": "संपादक",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "स्वीकृत",
|
||||
"settings.recording.consent_not": "अभी तक स्वीकृत नहीं",
|
||||
"settings.recording.auto_label": "जब कोई कैलेंडर ईवेंट शुरू होता है तो स्वचालित रूप से रिकॉर्डिंग शुरू करें",
|
||||
"settings.recording.auto_hint": "केवल जब WhispAssist खुला हो। जब किसी आयातित कैलेंडर ईवेंट का प्रारंभ समय आता है, तो रिकॉर्डिंग स्वचालित रूप से शुरू होती है (ऊपर की आपकी डिफ़ॉल्ट प्रतिधारण सेटिंग का उपयोग करते हुए)। पृष्ठभूमि में कुछ नहीं चलता — टाइमर केवल तब सक्रिय होता है जब ऐप चल रहा हो। सेटिंग्स → कैलेंडर के अंतर्गत ईवेंट आयात करें।"
|
||||
"settings.recording.auto_hint": "केवल जब WhispAssist खुला हो। जब किसी आयातित कैलेंडर ईवेंट का प्रारंभ समय आता है, तो रिकॉर्डिंग स्वचालित रूप से शुरू होती है (ऊपर की आपकी डिफ़ॉल्ट प्रतिधारण सेटिंग का उपयोग करते हुए)। पृष्ठभूमि में कुछ नहीं चलता — टाइमर केवल तब सक्रिय होता है जब ऐप चल रहा हो। सेटिंग्स → कैलेंडर के अंतर्गत ईवेंट आयात करें।",
|
||||
"settings.recording.autostart_label": "लॉगिन पर WhispAssist शुरू करें",
|
||||
"settings.recording.autostart_hint": "Windows में साइन इन करते ही WhispAssist अपने आप शुरू हो जाता है। डिफ़ॉल्ट रूप से बंद; प्रति-उपयोगकर्ता स्टार्टअप प्रविष्टि जोड़ता है (एडमिन की ज़रूरत नहीं) और अपने आप रिकॉर्डिंग शुरू नहीं करता।",
|
||||
"settings.recording.close_tray_label": "सिस्टम ट्रे में बंद करें",
|
||||
"settings.recording.close_tray_hint": "विंडो बंद करने पर WhispAssist बंद होने के बजाय बैकग्राउंड में चलता रहता है। ट्रे आइकन से दोबारा खोलें; पूरी तरह बंद करने के लिए ट्रे का 'बाहर निकलें' उपयोग करें। डिफ़ॉल्ट रूप से चालू।"
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "언어를 선택하려면 위에서 다국어 모델로 전환하세요.",
|
||||
|
||||
"settings.hardware.title": "하드웨어",
|
||||
"settings.hardware.refresh": "새로 고침",
|
||||
"settings.hardware.test_output": "테스트",
|
||||
"settings.hardware.test_mic": "테스트",
|
||||
"settings.hardware.testing": "듣는 중…",
|
||||
"settings.hardware.play_tone": "테스트 음 재생",
|
||||
"settings.hardware.stress_title": "빠른 스트레스 테스트",
|
||||
"settings.hardware.stress_hint": "설치된 모델을 사용 가능한 각 백엔드에서 벤치마크하고, 실시간 음성을 따라가면서 가장 정확한 모델을 추천합니다. 잠시 걸립니다.",
|
||||
"settings.hardware.stress_run": "스트레스 테스트 실행",
|
||||
"settings.hardware.stress_running": "실행 중…",
|
||||
"settings.hardware.stress_progress": "{pair} 벤치마크 중…",
|
||||
"settings.hardware.stress_recommend": "추천: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "적용",
|
||||
"settings.hardware.stress_none": "이 하드웨어에서 실시간 음성을 따라가는 설치된 모델이 없습니다. 더 작은 모델을 사용해 보세요.",
|
||||
"settings.hardware.stress_backend": "백엔드",
|
||||
"settings.hardware.stress_model": "모델",
|
||||
"settings.hardware.stress_rtf": "속도 (×실시간)",
|
||||
"settings.hardware.stress_realtime": "실시간?",
|
||||
"settings.hardware.stress_yes": "예",
|
||||
"settings.hardware.stress_no": "아니요",
|
||||
"settings.hardware.active_backend": "활성 백엔드",
|
||||
"settings.hardware.model_meta": "· 모델 {size}",
|
||||
"settings.hardware.preferred_backend": "선호 백엔드",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "{name} 다운로드 중",
|
||||
"settings.hardware.active_badge": "활성",
|
||||
"settings.hardware.installed": "설치됨",
|
||||
"settings.hardware.quantization_for": "{name} 양자화",
|
||||
"settings.hardware.quant_recommended": "{quant} · 권장",
|
||||
"settings.hardware.quant_higher": "{quant} · 더 높은 품질, 더 큼",
|
||||
"settings.hardware.use": "사용",
|
||||
"settings.hardware.remove": "제거",
|
||||
"settings.hardware.cant_remove_active": "활성 모델은 제거할 수 없습니다",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "캘린더 및 Outlook .pst",
|
||||
"settings.calendar.intro_1": "로컬 Outlook 백업에서 이벤트와 참석자를 가져옵니다",
|
||||
"settings.calendar.intro_2": "— 읽기 전용이며 파일에 아무것도 다시 쓰지 않습니다. 이 장치를 벗어나는 것은 없습니다.",
|
||||
"settings.calendar.requires": "PATH에 다음이 필요합니다(포함되어 있지 않음):",
|
||||
"settings.calendar.pst_file": ".pst 파일",
|
||||
"settings.calendar.no_file": "선택된 파일 없음",
|
||||
"settings.calendar.browse": "찾아보기…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "잠김",
|
||||
"settings.privacy.vault_locked_2": " 상태입니다. 암호화된 회의를 읽으려면 잠금을 해제하세요.",
|
||||
"settings.privacy.password": "비밀번호",
|
||||
"settings.privacy.show_password": "비밀번호 표시",
|
||||
"settings.privacy.unlock": "잠금 해제",
|
||||
"settings.privacy.vault_unlocked_1": "보관소가 ",
|
||||
"settings.privacy.unlocked_word": "잠금 해제됨",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "선택 사항",
|
||||
"import.title_placeholder": "기본값은 파일 이름",
|
||||
"import.filter_av": "오디오 / 비디오",
|
||||
"import.requires_1": "다음이 필요합니다:",
|
||||
"import.requires_2": "이(가) 설치되어 PATH에 있어야 합니다(URL의 경우",
|
||||
"import.requires_3": "도 필요). WhispAssist는 이들을 포함하지 않습니다.",
|
||||
"import.model_label": "전사 모델",
|
||||
"import.model_hint": "회의와 함께 기록되어 어떤 모델로 전사되었는지 확인할 수 있습니다.",
|
||||
"import.requires": "PATH에 다음이 필요합니다(포함되어 있지 않음):",
|
||||
"import.background_note": "백그라운드에서 실행됩니다. 목록에서 진행 상황을 확인하세요.",
|
||||
"import.importing": "가져오는 중… 시간이 걸릴 수 있습니다",
|
||||
"import.import": "가져오기",
|
||||
"import.cancel": "취소",
|
||||
"import.tracker.label": "가져오기 진행 상황",
|
||||
"import.tracker.running": "가져오는 중…",
|
||||
"import.tracker.done": "가져오기 완료",
|
||||
"import.tracker.failed": "가져오기 실패",
|
||||
"import.tracker.working": "작업 중…",
|
||||
"import.tracker.stopped": "중지됨",
|
||||
"import.phase.prepare": "트랜스코딩",
|
||||
"import.phase.transcribe": "전사",
|
||||
"import.phase.diarize": "화자 식별",
|
||||
"import.phase.finalize": "마무리",
|
||||
|
||||
"tagchip.filter": "“{tag}” 태그가 지정된 회의 필터링",
|
||||
"tagchip.remove": "{tag} 태그 제거",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "취소",
|
||||
"app.cancel_title": "이 녹음을 취소하고 삭제",
|
||||
"app.recording": "녹음 중…",
|
||||
"app.mute": "마이크 음소거",
|
||||
"app.unmute": "마이크 음소거 해제",
|
||||
"app.mute_title": "마이크 음소거 (M)",
|
||||
"app.unmute_title": "마이크 음소거 해제 (M)",
|
||||
"app.backend_title": "활성 전사 백엔드",
|
||||
"app.retention_title": "이 회의의 오디오를 .wav로 저장",
|
||||
"app.saving": "저장 중",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "오류",
|
||||
"meetings.resume": "전사 재개",
|
||||
"meetings.delete_aria": "회의 삭제",
|
||||
"meetings.not_encrypted": "암호화되지 않음",
|
||||
"meetings.encrypt": "암호화",
|
||||
"meetings.encrypting": "암호화 중…",
|
||||
"meetings.encrypt_title": "볼트로 이 회의의 파일을 암호화",
|
||||
"meetings.delete_confirm": "이 회의를 삭제하시겠습니까? 녹음, 전사, 노트가 제거됩니다.",
|
||||
|
||||
"transcript.heading": "전사",
|
||||
"transcript.title_aria": "회의 제목",
|
||||
"transcript.transcribed_with": "전사에 사용된 모델:",
|
||||
"transcript.lang_title": "전사 언어",
|
||||
"transcript.lang_auto": "감지 중…",
|
||||
"transcript.show": "전사 표시",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "기울임꼴",
|
||||
"notes.h1": "제목 1",
|
||||
"notes.h2": "제목 2",
|
||||
"notes.h3": "제목 3",
|
||||
"notes.bullet": "글머리 기호 목록",
|
||||
"notes.numbered": "번호 매기기 목록",
|
||||
"notes.quote": "인용",
|
||||
"notes.divider": "구분선",
|
||||
"notes.checkbox_title": "체크박스",
|
||||
"notes.checkbox_aria": "체크박스 목록 항목",
|
||||
"notes.enhance": "향상",
|
||||
"notes.enhancing": "향상 중…",
|
||||
"notes.enhance_title": "전사 내용을 바탕으로 이 메모를 구조화된 Markdown으로 확장 (AI)",
|
||||
"notes.enhanced_note": "전사 내용을 바탕으로 메모를 향상했습니다.",
|
||||
"notes.undo_enhance": "실행 취소",
|
||||
"notes.edit_raw": "원본 markdown 편집",
|
||||
"notes.render": "markdown 렌더링",
|
||||
"notes.editor": "편집기",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "확인됨",
|
||||
"settings.recording.consent_not": "아직 확인되지 않음",
|
||||
"settings.recording.auto_label": "캘린더 이벤트가 시작될 때 자동으로 녹음 시작",
|
||||
"settings.recording.auto_hint": "WhispAssist가 열려 있는 동안에만 작동합니다. 가져온 캘린더 이벤트의 시작 시간이 되면 녹음이 자동으로 시작됩니다(위의 기본 보존 설정 사용). 백그라운드에서 실행되는 것은 없습니다 — 타이머는 앱이 실행 중일 때만 설정됩니다. 설정 → 캘린더에서 이벤트를 가져오세요."
|
||||
"settings.recording.auto_hint": "WhispAssist가 열려 있는 동안에만 작동합니다. 가져온 캘린더 이벤트의 시작 시간이 되면 녹음이 자동으로 시작됩니다(위의 기본 보존 설정 사용). 백그라운드에서 실행되는 것은 없습니다 — 타이머는 앱이 실행 중일 때만 설정됩니다. 설정 → 캘린더에서 이벤트를 가져오세요.",
|
||||
"settings.recording.autostart_label": "로그인 시 WhispAssist 시작",
|
||||
"settings.recording.autostart_hint": "Windows에 로그인하면 WhispAssist를 자동으로 시작합니다. 기본값은 꺼짐이며, 사용자별 시작 항목을 설치하고(관리자 권한 불필요) 스스로 녹음을 시작하지 않습니다.",
|
||||
"settings.recording.close_tray_label": "시스템 트레이로 닫기",
|
||||
"settings.recording.close_tray_hint": "창을 닫아도 WhispAssist는 종료되지 않고 백그라운드에서 계속 실행됩니다. 트레이 아이콘에서 다시 열 수 있으며, 완전히 종료하려면 트레이의 종료를 사용하세요. 기본값은 켜짐입니다."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "Bytt til en flerspråklig modell ovenfor for å velge et språk.",
|
||||
|
||||
"settings.hardware.title": "Maskinvare",
|
||||
"settings.hardware.refresh": "Oppdater",
|
||||
"settings.hardware.test_output": "Test",
|
||||
"settings.hardware.test_mic": "Test",
|
||||
"settings.hardware.testing": "Lytter…",
|
||||
"settings.hardware.play_tone": "Spill av tone",
|
||||
"settings.hardware.stress_title": "Rask stresstest",
|
||||
"settings.hardware.stress_hint": "Måler de installerte modellene på hver tilgjengelige backend og anbefaler den mest nøyaktige som holder følge med direkte tale. Tar et øyeblikk.",
|
||||
"settings.hardware.stress_run": "Kjør stresstest",
|
||||
"settings.hardware.stress_running": "Kjører…",
|
||||
"settings.hardware.stress_progress": "Måler {pair}…",
|
||||
"settings.hardware.stress_recommend": "Anbefalt: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "Bruk",
|
||||
"settings.hardware.stress_none": "Ingen installert modell holder følge med direkte tale på denne maskinvaren — prøv en mindre modell.",
|
||||
"settings.hardware.stress_backend": "Backend",
|
||||
"settings.hardware.stress_model": "Modell",
|
||||
"settings.hardware.stress_rtf": "Hastighet (×sanntid)",
|
||||
"settings.hardware.stress_realtime": "Direkte?",
|
||||
"settings.hardware.stress_yes": "Ja",
|
||||
"settings.hardware.stress_no": "Nei",
|
||||
"settings.hardware.active_backend": "Aktiv backend",
|
||||
"settings.hardware.model_meta": "· modell {size}",
|
||||
"settings.hardware.preferred_backend": "Foretrukket backend",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "Laster ned {name}",
|
||||
"settings.hardware.active_badge": "Aktiv",
|
||||
"settings.hardware.installed": "Installert",
|
||||
"settings.hardware.quantization_for": "Kvantisering for {name}",
|
||||
"settings.hardware.quant_recommended": "{quant} · anbefalt",
|
||||
"settings.hardware.quant_higher": "{quant} · høyere kvalitet, større",
|
||||
"settings.hardware.use": "Bruk",
|
||||
"settings.hardware.remove": "Fjern",
|
||||
"settings.hardware.cant_remove_active": "Kan ikke fjerne den aktive modellen",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "Kalender og Outlook .pst",
|
||||
"settings.calendar.intro_1": "Importer hendelser og deltakere fra en lokal Outlook-",
|
||||
"settings.calendar.intro_2": "sikkerhetskopi — skrivebeskyttet, ingenting skrives tilbake til filen. Ingenting forlater denne enheten.",
|
||||
"settings.calendar.requires": "Trenger dette i PATH (følger ikke med):",
|
||||
"settings.calendar.pst_file": ".pst-fil",
|
||||
"settings.calendar.no_file": "Ingen fil valgt",
|
||||
"settings.calendar.browse": "Bla gjennom…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "låst",
|
||||
"settings.privacy.vault_locked_2": ". Lås opp for å lese krypterte møter.",
|
||||
"settings.privacy.password": "Passord",
|
||||
"settings.privacy.show_password": "Vis passord",
|
||||
"settings.privacy.unlock": "Lås opp",
|
||||
"settings.privacy.vault_unlocked_1": "Hvelvet er ",
|
||||
"settings.privacy.unlocked_word": "opplåst",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "valgfritt",
|
||||
"import.title_placeholder": "Standard er filnavnet",
|
||||
"import.filter_av": "Lyd / video",
|
||||
"import.requires_1": "Krever",
|
||||
"import.requires_2": "installert og i din PATH (pluss",
|
||||
"import.requires_3": "for URL-er). WhispAssist leverer dem ikke med.",
|
||||
"import.model_label": "Transkripsjonsmodell",
|
||||
"import.model_hint": "Lagres med møtet så du ser hvordan det ble transkribert.",
|
||||
"import.requires": "Trenger disse i PATH (følger ikke med):",
|
||||
"import.background_note": "Kjører i bakgrunnen — følg med i listen.",
|
||||
"import.importing": "Importerer… dette kan ta en stund",
|
||||
"import.import": "Importer",
|
||||
"import.cancel": "Avbryt",
|
||||
"import.tracker.label": "Importfremdrift",
|
||||
"import.tracker.running": "Importerer…",
|
||||
"import.tracker.done": "Import fullført",
|
||||
"import.tracker.failed": "Import mislyktes",
|
||||
"import.tracker.working": "arbeider…",
|
||||
"import.tracker.stopped": "stoppet",
|
||||
"import.phase.prepare": "Omkode",
|
||||
"import.phase.transcribe": "Transkriber",
|
||||
"import.phase.diarize": "Identifiser talere",
|
||||
"import.phase.finalize": "Fullfør",
|
||||
|
||||
"tagchip.filter": "Filtrer møter merket «{tag}»",
|
||||
"tagchip.remove": "Fjern taggen {tag}",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "Avbryt",
|
||||
"app.cancel_title": "Forkast dette opptaket og slett det",
|
||||
"app.recording": "Tar opp…",
|
||||
"app.mute": "Demp mikrofonen",
|
||||
"app.unmute": "Slå på mikrofonen",
|
||||
"app.mute_title": "Demp mikrofonen (M)",
|
||||
"app.unmute_title": "Slå på mikrofonen (M)",
|
||||
"app.backend_title": "Aktiv transkripsjonsbackend",
|
||||
"app.retention_title": "Lagre lyd som .wav for dette møtet",
|
||||
"app.saving": "lagrer",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "feil",
|
||||
"meetings.resume": "Gjenoppta transkripsjon",
|
||||
"meetings.delete_aria": "Slett møte",
|
||||
"meetings.not_encrypted": "Ikke kryptert",
|
||||
"meetings.encrypt": "Krypter",
|
||||
"meetings.encrypting": "Krypterer…",
|
||||
"meetings.encrypt_title": "Krypter møtets filer med hvelvet",
|
||||
"meetings.delete_confirm": "Slette dette møtet? Dette fjerner opptaket, transkripsjonen og notatene.",
|
||||
|
||||
"transcript.heading": "Transkripsjon",
|
||||
"transcript.title_aria": "Møtetittel",
|
||||
"transcript.transcribed_with": "Transkribert med",
|
||||
"transcript.lang_title": "Transkripsjonsspråk",
|
||||
"transcript.lang_auto": "oppdager…",
|
||||
"transcript.show": "Vis transkripsjon",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "Kursiv",
|
||||
"notes.h1": "Overskrift 1",
|
||||
"notes.h2": "Overskrift 2",
|
||||
"notes.h3": "Overskrift 3",
|
||||
"notes.bullet": "Punktliste",
|
||||
"notes.numbered": "Nummerert liste",
|
||||
"notes.quote": "Sitat",
|
||||
"notes.divider": "Skillelinje",
|
||||
"notes.checkbox_title": "Avkrysningsboks",
|
||||
"notes.checkbox_aria": "Listeelement med avkrysningsboks",
|
||||
"notes.enhance": "Forbedre",
|
||||
"notes.enhancing": "Forbedrer…",
|
||||
"notes.enhance_title": "Utvid notatene til strukturert Markdown ved hjelp av transkripsjonen (KI)",
|
||||
"notes.enhanced_note": "Notater forbedret ut fra transkripsjonen.",
|
||||
"notes.undo_enhance": "Angre",
|
||||
"notes.edit_raw": "Rediger rå markdown",
|
||||
"notes.render": "Gjengi markdown",
|
||||
"notes.editor": "Redigering",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "bekreftet",
|
||||
"settings.recording.consent_not": "ennå ikke bekreftet",
|
||||
"settings.recording.auto_label": "Start opptak automatisk når en kalenderhendelse begynner",
|
||||
"settings.recording.auto_hint": "Kun mens WhispAssist er åpen. Når starttiden for en importert kalenderhendelse inntreffer, begynner et opptak automatisk (med din standardinnstilling for oppbevaring ovenfor). Ingenting kjører i bakgrunnen — tidtakeren er kun aktivert mens appen kjører. Importer hendelser under Innstillinger → Kalender."
|
||||
"settings.recording.auto_hint": "Kun mens WhispAssist er åpen. Når starttiden for en importert kalenderhendelse inntreffer, begynner et opptak automatisk (med din standardinnstilling for oppbevaring ovenfor). Ingenting kjører i bakgrunnen — tidtakeren er kun aktivert mens appen kjører. Importer hendelser under Innstillinger → Kalender.",
|
||||
"settings.recording.autostart_label": "Start WhispAssist ved pålogging",
|
||||
"settings.recording.autostart_hint": "Starter WhispAssist automatisk når du logger på Windows. Av som standard; installerer en oppstartsoppføring per bruker (krever ikke administrator) og begynner aldri å ta opp på egen hånd.",
|
||||
"settings.recording.close_tray_label": "Lukk til systemstatusfeltet",
|
||||
"settings.recording.close_tray_hint": "Å lukke vinduet lar WhispAssist kjøre videre i bakgrunnen i stedet for å avslutte. Åpne igjen fra ikonet i systemstatusfeltet; bruk Avslutt der for å avslutte helt. På som standard."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "Mude para um modelo multilíngue acima para escolher um idioma.",
|
||||
|
||||
"settings.hardware.title": "Hardware",
|
||||
"settings.hardware.refresh": "Atualizar",
|
||||
"settings.hardware.test_output": "Testar",
|
||||
"settings.hardware.test_mic": "Testar",
|
||||
"settings.hardware.testing": "Escutando…",
|
||||
"settings.hardware.play_tone": "Reproduzir som",
|
||||
"settings.hardware.stress_title": "Teste rápido de desempenho",
|
||||
"settings.hardware.stress_hint": "Avalia os modelos instalados em cada backend disponível e recomenda o mais preciso que acompanha a fala ao vivo. Leva um momento.",
|
||||
"settings.hardware.stress_run": "Executar teste",
|
||||
"settings.hardware.stress_running": "Executando…",
|
||||
"settings.hardware.stress_progress": "Avaliando {pair}…",
|
||||
"settings.hardware.stress_recommend": "Recomendado: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "Aplicar",
|
||||
"settings.hardware.stress_none": "Nenhum modelo instalado acompanha a fala ao vivo neste hardware — tente um modelo menor.",
|
||||
"settings.hardware.stress_backend": "Backend",
|
||||
"settings.hardware.stress_model": "Modelo",
|
||||
"settings.hardware.stress_rtf": "Velocidade (×tempo real)",
|
||||
"settings.hardware.stress_realtime": "Ao vivo?",
|
||||
"settings.hardware.stress_yes": "Sim",
|
||||
"settings.hardware.stress_no": "Não",
|
||||
"settings.hardware.active_backend": "Backend ativo",
|
||||
"settings.hardware.model_meta": "· modelo {size}",
|
||||
"settings.hardware.preferred_backend": "Backend preferido",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "Baixando {name}",
|
||||
"settings.hardware.active_badge": "Ativo",
|
||||
"settings.hardware.installed": "Instalado",
|
||||
"settings.hardware.quantization_for": "Quantização de {name}",
|
||||
"settings.hardware.quant_recommended": "{quant} · recomendado",
|
||||
"settings.hardware.quant_higher": "{quant} · qualidade superior, maior",
|
||||
"settings.hardware.use": "Usar",
|
||||
"settings.hardware.remove": "Remover",
|
||||
"settings.hardware.cant_remove_active": "Não é possível remover o modelo ativo",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "Calendário e Outlook .pst",
|
||||
"settings.calendar.intro_1": "Importe eventos e participantes de um backup local do Outlook",
|
||||
"settings.calendar.intro_2": "— somente leitura, nada é reescrito no arquivo. Nada sai deste dispositivo.",
|
||||
"settings.calendar.requires": "Precisa disto no seu PATH (não incluído):",
|
||||
"settings.calendar.pst_file": "Arquivo .pst",
|
||||
"settings.calendar.no_file": "Nenhum arquivo selecionado",
|
||||
"settings.calendar.browse": "Procurar…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "bloqueado",
|
||||
"settings.privacy.vault_locked_2": ". Desbloqueie para ler as reuniões criptografadas.",
|
||||
"settings.privacy.password": "Senha",
|
||||
"settings.privacy.show_password": "Mostrar senha",
|
||||
"settings.privacy.unlock": "Desbloquear",
|
||||
"settings.privacy.vault_unlocked_1": "O cofre está ",
|
||||
"settings.privacy.unlocked_word": "desbloqueado",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "opcional",
|
||||
"import.title_placeholder": "Por padrão, o nome do arquivo",
|
||||
"import.filter_av": "Áudio / vídeo",
|
||||
"import.requires_1": "Requer",
|
||||
"import.requires_2": "instalado e no seu PATH (mais",
|
||||
"import.requires_3": "para URLs). O WhispAssist não os inclui.",
|
||||
"import.model_label": "Modelo de transcrição",
|
||||
"import.model_hint": "Salvo com a reunião para você ver como foi transcrita.",
|
||||
"import.requires": "Precisa destes no seu PATH (não incluídos):",
|
||||
"import.background_note": "Roda em segundo plano — acompanhe na lista.",
|
||||
"import.importing": "Importando… isso pode demorar um pouco",
|
||||
"import.import": "Importar",
|
||||
"import.cancel": "Cancelar",
|
||||
"import.tracker.label": "Progresso da importação",
|
||||
"import.tracker.running": "Importando…",
|
||||
"import.tracker.done": "Importação concluída",
|
||||
"import.tracker.failed": "Falha na importação",
|
||||
"import.tracker.working": "trabalhando…",
|
||||
"import.tracker.stopped": "parado",
|
||||
"import.phase.prepare": "Transcodificar",
|
||||
"import.phase.transcribe": "Transcrever",
|
||||
"import.phase.diarize": "Identificar falantes",
|
||||
"import.phase.finalize": "Finalizar",
|
||||
|
||||
"tagchip.filter": "Filtrar reuniões marcadas com \"{tag}\"",
|
||||
"tagchip.remove": "Remover marcador {tag}",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "Cancelar",
|
||||
"app.cancel_title": "Descartar esta gravação e excluí-la",
|
||||
"app.recording": "Gravando…",
|
||||
"app.mute": "Silenciar microfone",
|
||||
"app.unmute": "Ativar microfone",
|
||||
"app.mute_title": "Silenciar microfone (M)",
|
||||
"app.unmute_title": "Ativar microfone (M)",
|
||||
"app.backend_title": "Backend de transcrição ativo",
|
||||
"app.retention_title": "Salvar o áudio como .wav para esta reunião",
|
||||
"app.saving": "salvando",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "erro",
|
||||
"meetings.resume": "Retomar transcrição",
|
||||
"meetings.delete_aria": "Excluir reunião",
|
||||
"meetings.not_encrypted": "Não criptografado",
|
||||
"meetings.encrypt": "Criptografar",
|
||||
"meetings.encrypting": "Criptografando…",
|
||||
"meetings.encrypt_title": "Criptografar os arquivos desta reunião com o cofre",
|
||||
"meetings.delete_confirm": "Excluir esta reunião? Isso remove sua gravação, transcrição e notas.",
|
||||
|
||||
"transcript.heading": "Transcrição",
|
||||
"transcript.title_aria": "Título da reunião",
|
||||
"transcript.transcribed_with": "Transcrito com",
|
||||
"transcript.lang_title": "Idioma de transcrição",
|
||||
"transcript.lang_auto": "detectando…",
|
||||
"transcript.show": "Mostrar transcrição",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "Itálico",
|
||||
"notes.h1": "Título 1",
|
||||
"notes.h2": "Título 2",
|
||||
"notes.h3": "Título 3",
|
||||
"notes.bullet": "Lista com marcadores",
|
||||
"notes.numbered": "Lista numerada",
|
||||
"notes.quote": "Citação",
|
||||
"notes.divider": "Separador",
|
||||
"notes.checkbox_title": "Caixa de seleção",
|
||||
"notes.checkbox_aria": "Item de lista com caixa de seleção",
|
||||
"notes.enhance": "Melhorar",
|
||||
"notes.enhancing": "Melhorando…",
|
||||
"notes.enhance_title": "Expandir estas notas para Markdown estruturado com base na transcrição (IA)",
|
||||
"notes.enhanced_note": "Notas melhoradas a partir da transcrição.",
|
||||
"notes.undo_enhance": "Desfazer",
|
||||
"notes.edit_raw": "Editar o markdown bruto",
|
||||
"notes.render": "Renderizar o markdown",
|
||||
"notes.editor": "Editor",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "confirmado",
|
||||
"settings.recording.consent_not": "ainda não confirmado",
|
||||
"settings.recording.auto_label": "Iniciar a gravação automaticamente quando um evento do calendário começar",
|
||||
"settings.recording.auto_hint": "Apenas enquanto o WhispAssist estiver aberto. Quando chega a hora de início de um evento do calendário importado, uma gravação começa automaticamente (usando sua configuração de retenção padrão acima). Nada é executado em segundo plano — o temporizador só é armado enquanto o aplicativo está em execução. Importe eventos em Configurações → Calendário."
|
||||
"settings.recording.auto_hint": "Apenas enquanto o WhispAssist estiver aberto. Quando chega a hora de início de um evento do calendário importado, uma gravação começa automaticamente (usando sua configuração de retenção padrão acima). Nada é executado em segundo plano — o temporizador só é armado enquanto o aplicativo está em execução. Importe eventos em Configurações → Calendário.",
|
||||
"settings.recording.autostart_label": "Iniciar o WhispAssist ao fazer login",
|
||||
"settings.recording.autostart_hint": "Inicia o WhispAssist automaticamente quando você entra no Windows. Desativado por padrão; instala uma entrada de inicialização por usuário (sem administrador) e não começa a gravar sozinho.",
|
||||
"settings.recording.close_tray_label": "Fechar para a bandeja do sistema",
|
||||
"settings.recording.close_tray_hint": "Fechar a janela mantém o WhispAssist rodando em segundo plano em vez de encerrar. Reabra pelo ícone da bandeja; use Sair no menu da bandeja para encerrar de vez. Ativado por padrão."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "Mude para um modelo multilingue acima para escolher um idioma.",
|
||||
|
||||
"settings.hardware.title": "Hardware",
|
||||
"settings.hardware.refresh": "Atualizar",
|
||||
"settings.hardware.test_output": "Testar",
|
||||
"settings.hardware.test_mic": "Testar",
|
||||
"settings.hardware.testing": "A escutar…",
|
||||
"settings.hardware.play_tone": "Reproduzir som",
|
||||
"settings.hardware.stress_title": "Teste rápido de desempenho",
|
||||
"settings.hardware.stress_hint": "Avalia os modelos instalados em cada backend disponível e recomenda o mais preciso que acompanha a fala em direto. Demora um momento.",
|
||||
"settings.hardware.stress_run": "Executar teste",
|
||||
"settings.hardware.stress_running": "A executar…",
|
||||
"settings.hardware.stress_progress": "A avaliar {pair}…",
|
||||
"settings.hardware.stress_recommend": "Recomendado: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "Aplicar",
|
||||
"settings.hardware.stress_none": "Nenhum modelo instalado acompanha a fala em direto neste hardware — experimente um modelo mais pequeno.",
|
||||
"settings.hardware.stress_backend": "Backend",
|
||||
"settings.hardware.stress_model": "Modelo",
|
||||
"settings.hardware.stress_rtf": "Velocidade (×tempo real)",
|
||||
"settings.hardware.stress_realtime": "Em direto?",
|
||||
"settings.hardware.stress_yes": "Sim",
|
||||
"settings.hardware.stress_no": "Não",
|
||||
"settings.hardware.active_backend": "Backend ativo",
|
||||
"settings.hardware.model_meta": "· modelo {size}",
|
||||
"settings.hardware.preferred_backend": "Backend preferido",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "A transferir {name}",
|
||||
"settings.hardware.active_badge": "Ativo",
|
||||
"settings.hardware.installed": "Instalado",
|
||||
"settings.hardware.quantization_for": "Quantização de {name}",
|
||||
"settings.hardware.quant_recommended": "{quant} · recomendado",
|
||||
"settings.hardware.quant_higher": "{quant} · qualidade superior, maior",
|
||||
"settings.hardware.use": "Usar",
|
||||
"settings.hardware.remove": "Remover",
|
||||
"settings.hardware.cant_remove_active": "Não é possível remover o modelo ativo",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "Calendário e Outlook .pst",
|
||||
"settings.calendar.intro_1": "Importe eventos e participantes de uma cópia de segurança local do Outlook",
|
||||
"settings.calendar.intro_2": "— apenas leitura, nada é reescrito no ficheiro. Nada sai deste dispositivo.",
|
||||
"settings.calendar.requires": "Precisa disto no seu PATH (não incluído):",
|
||||
"settings.calendar.pst_file": "Ficheiro .pst",
|
||||
"settings.calendar.no_file": "Nenhum ficheiro selecionado",
|
||||
"settings.calendar.browse": "Procurar…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "bloqueado",
|
||||
"settings.privacy.vault_locked_2": ". Desbloqueie para ler as reuniões cifradas.",
|
||||
"settings.privacy.password": "Palavra-passe",
|
||||
"settings.privacy.show_password": "Mostrar palavra-passe",
|
||||
"settings.privacy.unlock": "Desbloquear",
|
||||
"settings.privacy.vault_unlocked_1": "O cofre está ",
|
||||
"settings.privacy.unlocked_word": "desbloqueado",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "opcional",
|
||||
"import.title_placeholder": "Por predefinição, o nome do ficheiro",
|
||||
"import.filter_av": "Áudio / vídeo",
|
||||
"import.requires_1": "Requer",
|
||||
"import.requires_2": "instalado e no seu PATH (mais",
|
||||
"import.requires_3": "para URLs). O WhispAssist não os inclui.",
|
||||
"import.model_label": "Modelo de transcrição",
|
||||
"import.model_hint": "Guardado com a reunião para ver como foi transcrita.",
|
||||
"import.requires": "Precisa destes no seu PATH (não incluídos):",
|
||||
"import.background_note": "Corre em segundo plano — acompanhe na lista.",
|
||||
"import.importing": "A importar… isto pode demorar um pouco",
|
||||
"import.import": "Importar",
|
||||
"import.cancel": "Cancelar",
|
||||
"import.tracker.label": "Progresso da importação",
|
||||
"import.tracker.running": "A importar…",
|
||||
"import.tracker.done": "Importação concluída",
|
||||
"import.tracker.failed": "Falha na importação",
|
||||
"import.tracker.working": "a trabalhar…",
|
||||
"import.tracker.stopped": "parado",
|
||||
"import.phase.prepare": "Transcodificar",
|
||||
"import.phase.transcribe": "Transcrever",
|
||||
"import.phase.diarize": "Identificar oradores",
|
||||
"import.phase.finalize": "Finalizar",
|
||||
|
||||
"tagchip.filter": "Filtrar reuniões etiquetadas com «{tag}»",
|
||||
"tagchip.remove": "Remover etiqueta {tag}",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "Cancelar",
|
||||
"app.cancel_title": "Descartar esta gravação e eliminá-la",
|
||||
"app.recording": "A gravar…",
|
||||
"app.mute": "Silenciar microfone",
|
||||
"app.unmute": "Reativar microfone",
|
||||
"app.mute_title": "Silenciar microfone (M)",
|
||||
"app.unmute_title": "Reativar microfone (M)",
|
||||
"app.backend_title": "Backend de transcrição ativo",
|
||||
"app.retention_title": "Guardar o áudio como .wav para esta reunião",
|
||||
"app.saving": "a guardar",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "erro",
|
||||
"meetings.resume": "Retomar transcrição",
|
||||
"meetings.delete_aria": "Eliminar reunião",
|
||||
"meetings.not_encrypted": "Não encriptado",
|
||||
"meetings.encrypt": "Encriptar",
|
||||
"meetings.encrypting": "A encriptar…",
|
||||
"meetings.encrypt_title": "Encriptar os ficheiros desta reunião com o cofre",
|
||||
"meetings.delete_confirm": "Eliminar esta reunião? Isto remove a sua gravação, transcrição e notas.",
|
||||
|
||||
"transcript.heading": "Transcrição",
|
||||
"transcript.title_aria": "Título da reunião",
|
||||
"transcript.transcribed_with": "Transcrito com",
|
||||
"transcript.lang_title": "Idioma de transcrição",
|
||||
"transcript.lang_auto": "a detetar…",
|
||||
"transcript.show": "Mostrar transcrição",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "Itálico",
|
||||
"notes.h1": "Título 1",
|
||||
"notes.h2": "Título 2",
|
||||
"notes.h3": "Título 3",
|
||||
"notes.bullet": "Lista com marcadores",
|
||||
"notes.numbered": "Lista numerada",
|
||||
"notes.quote": "Citação",
|
||||
"notes.divider": "Separador",
|
||||
"notes.checkbox_title": "Caixa de verificação",
|
||||
"notes.checkbox_aria": "Item de lista com caixa de verificação",
|
||||
"notes.enhance": "Melhorar",
|
||||
"notes.enhancing": "A melhorar…",
|
||||
"notes.enhance_title": "Expandir estas notas para Markdown estruturado com base na transcrição (IA)",
|
||||
"notes.enhanced_note": "Notas melhoradas a partir da transcrição.",
|
||||
"notes.undo_enhance": "Anular",
|
||||
"notes.edit_raw": "Editar o markdown em bruto",
|
||||
"notes.render": "Compor o markdown",
|
||||
"notes.editor": "Editor",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "confirmado",
|
||||
"settings.recording.consent_not": "ainda não confirmado",
|
||||
"settings.recording.auto_label": "Iniciar a gravação automaticamente quando um evento do calendário começa",
|
||||
"settings.recording.auto_hint": "Apenas enquanto o WhispAssist estiver aberto. Quando chega a hora de início de um evento do calendário importado, uma gravação começa automaticamente (usando a sua definição de retenção predefinida acima). Nada é executado em segundo plano — o temporizador só é armado enquanto a aplicação está em execução. Importe eventos em Definições → Calendário."
|
||||
"settings.recording.auto_hint": "Apenas enquanto o WhispAssist estiver aberto. Quando chega a hora de início de um evento do calendário importado, uma gravação começa automaticamente (usando a sua definição de retenção predefinida acima). Nada é executado em segundo plano — o temporizador só é armado enquanto a aplicação está em execução. Importe eventos em Definições → Calendário.",
|
||||
"settings.recording.autostart_label": "Iniciar o WhispAssist ao iniciar sessão",
|
||||
"settings.recording.autostart_hint": "Inicia o WhispAssist automaticamente quando inicia sessão no Windows. Desativado por predefinição; instala uma entrada de arranque por utilizador (sem privilégios de administrador) e não começa a gravar sozinho.",
|
||||
"settings.recording.close_tray_label": "Fechar para a área de notificação",
|
||||
"settings.recording.close_tray_hint": "Fechar a janela mantém o WhispAssist em execução em segundo plano em vez de sair. Reabra-o a partir do ícone na área de notificação; use Sair nesse menu para terminar por completo. Ativado por predefinição."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "Переключитесь на многоязычную модель выше, чтобы выбрать язык.",
|
||||
|
||||
"settings.hardware.title": "Оборудование",
|
||||
"settings.hardware.refresh": "Обновить",
|
||||
"settings.hardware.test_output": "Проверить",
|
||||
"settings.hardware.test_mic": "Проверить",
|
||||
"settings.hardware.testing": "Слушаю…",
|
||||
"settings.hardware.play_tone": "Воспроизвести сигнал",
|
||||
"settings.hardware.stress_title": "Быстрый стресс-тест",
|
||||
"settings.hardware.stress_hint": "Замеряет установленные модели на каждом доступном бэкенде и рекомендует самую точную, которая успевает за живой речью. Займёт немного времени.",
|
||||
"settings.hardware.stress_run": "Запустить тест",
|
||||
"settings.hardware.stress_running": "Выполняется…",
|
||||
"settings.hardware.stress_progress": "Тестирование {pair}…",
|
||||
"settings.hardware.stress_recommend": "Рекомендуется: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "Применить",
|
||||
"settings.hardware.stress_none": "Ни одна установленная модель не успевает за живой речью на этом оборудовании — попробуйте модель поменьше.",
|
||||
"settings.hardware.stress_backend": "Бэкенд",
|
||||
"settings.hardware.stress_model": "Модель",
|
||||
"settings.hardware.stress_rtf": "Скорость (×реального времени)",
|
||||
"settings.hardware.stress_realtime": "В реальном времени?",
|
||||
"settings.hardware.stress_yes": "Да",
|
||||
"settings.hardware.stress_no": "Нет",
|
||||
"settings.hardware.active_backend": "Активный бэкенд",
|
||||
"settings.hardware.model_meta": "· модель {size}",
|
||||
"settings.hardware.preferred_backend": "Предпочитаемый бэкенд",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "Загрузка {name}",
|
||||
"settings.hardware.active_badge": "Активно",
|
||||
"settings.hardware.installed": "Установлено",
|
||||
"settings.hardware.quantization_for": "Квантование для {name}",
|
||||
"settings.hardware.quant_recommended": "{quant} · рекомендуется",
|
||||
"settings.hardware.quant_higher": "{quant} · выше качество, больше размер",
|
||||
"settings.hardware.use": "Использовать",
|
||||
"settings.hardware.remove": "Удалить",
|
||||
"settings.hardware.cant_remove_active": "Нельзя удалить активную модель",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "Календарь и Outlook .pst",
|
||||
"settings.calendar.intro_1": "Импортируйте события и участников из локальной резервной копии Outlook",
|
||||
"settings.calendar.intro_2": "— только чтение, ничего не записывается обратно в файл. Ничего не покидает это устройство.",
|
||||
"settings.calendar.requires": "Требуется в PATH (не входит в комплект):",
|
||||
"settings.calendar.pst_file": "Файл .pst",
|
||||
"settings.calendar.no_file": "Файл не выбран",
|
||||
"settings.calendar.browse": "Обзор…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "заблокировано",
|
||||
"settings.privacy.vault_locked_2": ". Разблокируйте, чтобы читать зашифрованные встречи.",
|
||||
"settings.privacy.password": "Пароль",
|
||||
"settings.privacy.show_password": "Показать пароль",
|
||||
"settings.privacy.unlock": "Разблокировать",
|
||||
"settings.privacy.vault_unlocked_1": "Хранилище ",
|
||||
"settings.privacy.unlocked_word": "разблокировано",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "необязательно",
|
||||
"import.title_placeholder": "По умолчанию — имя файла",
|
||||
"import.filter_av": "Аудио / видео",
|
||||
"import.requires_1": "Требует",
|
||||
"import.requires_2": "установленным и в вашем PATH (плюс",
|
||||
"import.requires_3": "для URL). WhispAssist их не включает.",
|
||||
"import.model_label": "Модель транскрипции",
|
||||
"import.model_hint": "Сохраняется вместе со встречей, чтобы было видно, чем она транскрибирована.",
|
||||
"import.requires": "Требуются в PATH (не входят в комплект):",
|
||||
"import.background_note": "Выполняется в фоне — следите за ходом в списке.",
|
||||
"import.importing": "Импорт… это может занять некоторое время",
|
||||
"import.import": "Импортировать",
|
||||
"import.cancel": "Отмена",
|
||||
"import.tracker.label": "Ход импорта",
|
||||
"import.tracker.running": "Импорт…",
|
||||
"import.tracker.done": "Импорт завершён",
|
||||
"import.tracker.failed": "Импорт не удался",
|
||||
"import.tracker.working": "в работе…",
|
||||
"import.tracker.stopped": "остановлен",
|
||||
"import.phase.prepare": "Перекодирование",
|
||||
"import.phase.transcribe": "Транскрибирование",
|
||||
"import.phase.diarize": "Определение говорящих",
|
||||
"import.phase.finalize": "Завершение",
|
||||
|
||||
"tagchip.filter": "Фильтровать встречи с меткой «{tag}»",
|
||||
"tagchip.remove": "Удалить метку {tag}",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "Отмена",
|
||||
"app.cancel_title": "Отклонить эту запись и удалить её",
|
||||
"app.recording": "Запись…",
|
||||
"app.mute": "Выключить микрофон",
|
||||
"app.unmute": "Включить микрофон",
|
||||
"app.mute_title": "Выключить микрофон (M)",
|
||||
"app.unmute_title": "Включить микрофон (M)",
|
||||
"app.backend_title": "Активный бэкенд транскрипции",
|
||||
"app.retention_title": "Сохранить аудио как .wav для этой встречи",
|
||||
"app.saving": "сохранение",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "ошибка",
|
||||
"meetings.resume": "Возобновить транскрипцию",
|
||||
"meetings.delete_aria": "Удалить встречу",
|
||||
"meetings.not_encrypted": "Не зашифровано",
|
||||
"meetings.encrypt": "Зашифровать",
|
||||
"meetings.encrypting": "Шифрование…",
|
||||
"meetings.encrypt_title": "Зашифровать файлы этой встречи с помощью хранилища",
|
||||
"meetings.delete_confirm": "Удалить эту встречу? Это удалит её запись, транскрипцию и заметки.",
|
||||
|
||||
"transcript.heading": "Транскрипция",
|
||||
"transcript.title_aria": "Название встречи",
|
||||
"transcript.transcribed_with": "Транскрибировано с помощью",
|
||||
"transcript.lang_title": "Язык транскрипции",
|
||||
"transcript.lang_auto": "определение…",
|
||||
"transcript.show": "Показать транскрипцию",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "Курсив",
|
||||
"notes.h1": "Заголовок 1",
|
||||
"notes.h2": "Заголовок 2",
|
||||
"notes.h3": "Заголовок 3",
|
||||
"notes.bullet": "Маркированный список",
|
||||
"notes.numbered": "Нумерованный список",
|
||||
"notes.quote": "Цитата",
|
||||
"notes.divider": "Разделитель",
|
||||
"notes.checkbox_title": "Флажок",
|
||||
"notes.checkbox_aria": "Элемент списка с флажком",
|
||||
"notes.enhance": "Улучшить",
|
||||
"notes.enhancing": "Улучшение…",
|
||||
"notes.enhance_title": "Развернуть эти заметки в структурированный Markdown по транскрипту (ИИ)",
|
||||
"notes.enhanced_note": "Заметки улучшены на основе транскрипта.",
|
||||
"notes.undo_enhance": "Отменить",
|
||||
"notes.edit_raw": "Редактировать исходный markdown",
|
||||
"notes.render": "Отобразить markdown",
|
||||
"notes.editor": "Редактор",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "подтверждено",
|
||||
"settings.recording.consent_not": "ещё не подтверждено",
|
||||
"settings.recording.auto_label": "Автоматически начинать запись, когда начинается событие календаря",
|
||||
"settings.recording.auto_hint": "Только пока WhispAssist открыт. Когда наступает время начала импортированного события календаря, запись начинается автоматически (используя вашу настройку хранения по умолчанию выше). Ничего не выполняется в фоне — таймер взведён только пока приложение запущено. Импортируйте события в Настройки → Календарь."
|
||||
"settings.recording.auto_hint": "Только пока WhispAssist открыт. Когда наступает время начала импортированного события календаря, запись начинается автоматически (используя вашу настройку хранения по умолчанию выше). Ничего не выполняется в фоне — таймер взведён только пока приложение запущено. Импортируйте события в Настройки → Календарь.",
|
||||
"settings.recording.autostart_label": "Запускать WhispAssist при входе в систему",
|
||||
"settings.recording.autostart_hint": "Автоматически запускает WhispAssist при входе в Windows. По умолчанию выключено; создаёт запись автозапуска для текущего пользователя (без прав администратора) и не начинает запись самостоятельно.",
|
||||
"settings.recording.close_tray_label": "Закрывать в системный трей",
|
||||
"settings.recording.close_tray_hint": "При закрытии окна WhispAssist продолжает работать в фоне, а не завершается. Откройте его снова через значок в трее; для полного выхода используйте «Выход» в меню трея. По умолчанию включено."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "භාෂාවක් තේරීමට ඉහත බහුභාෂා ආකෘතියකට මාරු වන්න.",
|
||||
|
||||
"settings.hardware.title": "දෘඪාංග",
|
||||
"settings.hardware.refresh": "නැවුම් කරන්න",
|
||||
"settings.hardware.test_output": "පරීක්ෂා කරන්න",
|
||||
"settings.hardware.test_mic": "පරීක්ෂා කරන්න",
|
||||
"settings.hardware.testing": "සවන් දෙමින්…",
|
||||
"settings.hardware.play_tone": "නාදයක් වාදනය කරන්න",
|
||||
"settings.hardware.stress_title": "ඉක්මන් පීඩන පරීක්ෂණය",
|
||||
"settings.hardware.stress_hint": "ස්ථාපිත ආකෘති ලබා ගත හැකි සෑම පසුබිම් පද්ධතියකම මිනුම් කර, සජීවී කථනයට සමගාමීව යා හැකි වඩාත්ම නිවැරදි ආකෘතිය නිර්දේශ කරයි. මොහොතක් ගතවේ.",
|
||||
"settings.hardware.stress_run": "පීඩන පරීක්ෂණය ධාවනය කරන්න",
|
||||
"settings.hardware.stress_running": "ධාවනය වෙමින්…",
|
||||
"settings.hardware.stress_progress": "{pair} මිනුම් කරමින්…",
|
||||
"settings.hardware.stress_recommend": "නිර්දේශය: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "යොදන්න",
|
||||
"settings.hardware.stress_none": "මෙම දෘඩාංගයේ ස්ථාපිත කිසිදු ආකෘතියක් සජීවී කථනයට සමගාමීව යන්නේ නැත — කුඩා ආකෘතියක් උත්සාහ කරන්න.",
|
||||
"settings.hardware.stress_backend": "පසුබිම් පද්ධතිය",
|
||||
"settings.hardware.stress_model": "ආකෘතිය",
|
||||
"settings.hardware.stress_rtf": "වේගය (×තත්ය කාලය)",
|
||||
"settings.hardware.stress_realtime": "සජීවීද?",
|
||||
"settings.hardware.stress_yes": "ඔව්",
|
||||
"settings.hardware.stress_no": "නැත",
|
||||
"settings.hardware.active_backend": "සක්රීය පසුඅන්තය",
|
||||
"settings.hardware.model_meta": "· ආකෘතිය {size}",
|
||||
"settings.hardware.preferred_backend": "කැමති පසුඅන්තය",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "{name} බාගත වෙමින්",
|
||||
"settings.hardware.active_badge": "සක්රීය",
|
||||
"settings.hardware.installed": "ස්ථාපනය කර ඇත",
|
||||
"settings.hardware.quantization_for": "{name} සඳහා ක්වොන්ටයිසේෂන්",
|
||||
"settings.hardware.quant_recommended": "{quant} · නිර්දේශිත",
|
||||
"settings.hardware.quant_higher": "{quant} · ඉහළ ගුණත්වය, විශාලයි",
|
||||
"settings.hardware.use": "භාවිත කරන්න",
|
||||
"settings.hardware.remove": "ඉවත් කරන්න",
|
||||
"settings.hardware.cant_remove_active": "සක්රීය ආකෘතිය ඉවත් කළ නොහැක",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "දින දර්ශනය සහ Outlook .pst",
|
||||
"settings.calendar.intro_1": "දේශීය Outlook උපස්ථයකින් සිදුවීම් සහ සහභාගිවන්නන් ආයාත කරන්න",
|
||||
"settings.calendar.intro_2": "— කියවීමට පමණි, ගොනුවට කිසිවක් නැවත ලියන්නේ නැත. මෙම උපාංගයෙන් කිසිවක් පිටතට යන්නේ නැත.",
|
||||
"settings.calendar.requires": "ඔබගේ PATH තුළ මෙය අවශ්යයි (සමඟ ලැබෙන්නේ නැත):",
|
||||
"settings.calendar.pst_file": ".pst ගොනුව",
|
||||
"settings.calendar.no_file": "ගොනුවක් තෝරා නැත",
|
||||
"settings.calendar.browse": "බ්රවුස් කරන්න…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "අගුළු දමා ඇත",
|
||||
"settings.privacy.vault_locked_2": ". සංකේතනය කළ රැස්වීම් කියවීමට අගුළු හරින්න.",
|
||||
"settings.privacy.password": "මුරපදය",
|
||||
"settings.privacy.show_password": "මුරපදය පෙන්වන්න",
|
||||
"settings.privacy.unlock": "අගුළු හරින්න",
|
||||
"settings.privacy.vault_unlocked_1": "සුරක්ෂිතාගාරය ",
|
||||
"settings.privacy.unlocked_word": "අගුළු හැර ඇත",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "විකල්ප",
|
||||
"import.title_placeholder": "පෙරනිමියෙන් ගොනුවේ නම",
|
||||
"import.filter_av": "ශ්රව්ය / වීඩියෝ",
|
||||
"import.requires_1": "අවශ්යයි",
|
||||
"import.requires_2": "ස්ථාපනය කර ඔබේ PATH හි (සහ",
|
||||
"import.requires_3": "URL සඳහා). WhispAssist ඒවා ඇතුළත් නොකරයි.",
|
||||
"import.model_label": "පිටපත්කරණ ආකෘතිය",
|
||||
"import.model_hint": "කුමන ආකෘතියෙන් පිටපත් වූවාදැයි බැලීමට රැස්වීම සමඟ සටහන් වේ.",
|
||||
"import.requires": "ඔබගේ PATH තුළ මේවා අවශ්යයි (සමඟ ලැබෙන්නේ නැත):",
|
||||
"import.background_note": "පසුබිමේ ධාවනය වේ — ලැයිස්තුවෙන් ප්රගතිය බලන්න.",
|
||||
"import.importing": "ආයාත වෙමින්… මෙයට යම් කාලයක් ගත විය හැක",
|
||||
"import.import": "ආයාත කරන්න",
|
||||
"import.cancel": "අවලංගු කරන්න",
|
||||
"import.tracker.label": "ආයාත ප්රගතිය",
|
||||
"import.tracker.running": "ආයාත වෙමින්…",
|
||||
"import.tracker.done": "ආයාතය සම්පූර්ණයි",
|
||||
"import.tracker.failed": "ආයාතය අසාර්ථකයි",
|
||||
"import.tracker.working": "ක්රියාත්මක වෙමින්…",
|
||||
"import.tracker.stopped": "නවත්වා ඇත",
|
||||
"import.phase.prepare": "ට්රාන්ස්කෝඩ්",
|
||||
"import.phase.transcribe": "පිටපත් කිරීම",
|
||||
"import.phase.diarize": "කථිකයන් හඳුනා ගැනීම",
|
||||
"import.phase.finalize": "අවසන් කිරීම",
|
||||
|
||||
"tagchip.filter": "“{tag}” ටැගය සහිත රැස්වීම් පෙරන්න",
|
||||
"tagchip.remove": "{tag} ටැගය ඉවත් කරන්න",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "අවලංගු කරන්න",
|
||||
"app.cancel_title": "මෙම පටිගත කිරීම ඉවත දමා මකන්න",
|
||||
"app.recording": "පටිගත වෙමින්…",
|
||||
"app.mute": "මයික්රෆෝනය නිහඬ කරන්න",
|
||||
"app.unmute": "මයික්රෆෝනය සක්රීය කරන්න",
|
||||
"app.mute_title": "මයික්රෆෝනය නිහඬ කරන්න (M)",
|
||||
"app.unmute_title": "මයික්රෆෝනය සක්රීය කරන්න (M)",
|
||||
"app.backend_title": "සක්රීය පිටපත් කිරීමේ පසුඅන්තය",
|
||||
"app.retention_title": "මෙම රැස්වීම සඳහා ශ්රව්ය .wav ලෙස සුරකින්න",
|
||||
"app.saving": "සුරකිමින්",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "දෝෂය",
|
||||
"meetings.resume": "පිටපත් කිරීම නැවත ආරම්භ කරන්න",
|
||||
"meetings.delete_aria": "රැස්වීම මකන්න",
|
||||
"meetings.not_encrypted": "සංකේතනය කර නැත",
|
||||
"meetings.encrypt": "සංකේතනය කරන්න",
|
||||
"meetings.encrypting": "සංකේතනය වෙමින්…",
|
||||
"meetings.encrypt_title": "මෙම රැස්වීමේ ගොනු සුරක්ෂිතාගාරයෙන් සංකේතනය කරන්න",
|
||||
"meetings.delete_confirm": "මෙම රැස්වීම මකන්නද? මෙය එහි පටිගත කිරීම, පිටපත සහ සටහන් ඉවත් කරයි.",
|
||||
|
||||
"transcript.heading": "පිටපත",
|
||||
"transcript.title_aria": "රැස්වීමේ මාතෘකාව",
|
||||
"transcript.transcribed_with": "පිටපත් කළේ:",
|
||||
"transcript.lang_title": "පිටපත් කිරීමේ භාෂාව",
|
||||
"transcript.lang_auto": "හඳුනා ගනිමින්…",
|
||||
"transcript.show": "පිටපත පෙන්වන්න",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "ඇල අකුරු",
|
||||
"notes.h1": "ශීර්ෂය 1",
|
||||
"notes.h2": "ශීර්ෂය 2",
|
||||
"notes.h3": "ශීර්ෂය 3",
|
||||
"notes.bullet": "බුලට් ලැයිස්තුව",
|
||||
"notes.numbered": "අංකිත ලැයිස්තුව",
|
||||
"notes.quote": "උද්ධෘතය",
|
||||
"notes.divider": "බෙදුම් ඉර",
|
||||
"notes.checkbox_title": "සලකුණු කොටුව",
|
||||
"notes.checkbox_aria": "සලකුණු කොටු ලැයිස්තු අයිතමය",
|
||||
"notes.enhance": "වැඩිදියුණු කරන්න",
|
||||
"notes.enhancing": "වැඩිදියුණු කරමින්…",
|
||||
"notes.enhance_title": "පිටපත ඇසුරෙන් මෙම සටහන් ව්යුහගත Markdown ලෙස පුළුල් කරන්න (AI)",
|
||||
"notes.enhanced_note": "පිටපත ඇසුරෙන් සටහන් වැඩිදියුණු කරන ලදී.",
|
||||
"notes.undo_enhance": "අහෝසි කරන්න",
|
||||
"notes.edit_raw": "අමු markdown සංස්කරණය කරන්න",
|
||||
"notes.render": "markdown විදහන්න",
|
||||
"notes.editor": "සංස්කාරකය",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "පිළිගත්තා",
|
||||
"settings.recording.consent_not": "තවම පිළිගෙන නැත",
|
||||
"settings.recording.auto_label": "දින දර්ශන සිදුවීමක් ආරම්භ වන විට ස්වයංක්රීයව පටිගත කිරීම ආරම්භ කරන්න",
|
||||
"settings.recording.auto_hint": "WhispAssist විවෘතව තිබෙන විට පමණි. ආයාත කළ දින දර්ශන සිදුවීමක ආරම්භක වේලාව පැමිණි විට, පටිගත කිරීමක් ස්වයංක්රීයව ආරම්භ වේ (ඉහත ඔබේ පෙරනිමි රඳවා තබා ගැනීමේ සැකසුම භාවිත කරමින්). පසුබිමින් කිසිවක් ධාවනය නොවේ — යෙදුම ධාවනය වන විට පමණක් කාල නියාමකය සක්රීයයි. සැකසුම් → දින දර්ශනය යටතේ සිදුවීම් ආයාත කරන්න."
|
||||
"settings.recording.auto_hint": "WhispAssist විවෘතව තිබෙන විට පමණි. ආයාත කළ දින දර්ශන සිදුවීමක ආරම්භක වේලාව පැමිණි විට, පටිගත කිරීමක් ස්වයංක්රීයව ආරම්භ වේ (ඉහත ඔබේ පෙරනිමි රඳවා තබා ගැනීමේ සැකසුම භාවිත කරමින්). පසුබිමින් කිසිවක් ධාවනය නොවේ — යෙදුම ධාවනය වන විට පමණක් කාල නියාමකය සක්රීයයි. සැකසුම් → දින දර්ශනය යටතේ සිදුවීම් ආයාත කරන්න.",
|
||||
"settings.recording.autostart_label": "පිවිසීමේදී WhispAssist අරඹන්න",
|
||||
"settings.recording.autostart_hint": "Windows වෙත පිවිසෙන විට WhispAssist ස්වයංක්රීයව අරඹයි. පෙරනිමියෙන් අක්රීයයි; පරිශීලකයාට පමණක් අදාළ ආරම්භක සටහනක් ස්ථාපනය කරයි (පරිපාලක අවශ්ය නැත), තනිවම පටිගත කිරීම අරඹන්නේ නැත.",
|
||||
"settings.recording.close_tray_label": "පද්ධති තැටියට වසන්න",
|
||||
"settings.recording.close_tray_hint": "කවුළුව වැසූ විට WhispAssist වැසී යනවා වෙනුවට පසුබිමේ ක්රියාත්මකව පවතී. තැටි නිරූපකයෙන් නැවත විවෘත කරන්න; සම්පූර්ණයෙන් වැසීමට තැටියේ 'ඉවත් වන්න' භාවිතා කරන්න. පෙරනිමියෙන් සක්රීයයි."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "Byt till en flerspråkig modell ovan för att välja ett språk.",
|
||||
|
||||
"settings.hardware.title": "Maskinvara",
|
||||
"settings.hardware.refresh": "Uppdatera",
|
||||
"settings.hardware.test_output": "Testa",
|
||||
"settings.hardware.test_mic": "Testa",
|
||||
"settings.hardware.testing": "Lyssnar…",
|
||||
"settings.hardware.play_tone": "Spela upp ton",
|
||||
"settings.hardware.stress_title": "Snabbt stresstest",
|
||||
"settings.hardware.stress_hint": "Mäter de installerade modellerna på varje tillgänglig backend och rekommenderar den mest träffsäkra som hinner med live-tal. Tar en stund.",
|
||||
"settings.hardware.stress_run": "Kör stresstest",
|
||||
"settings.hardware.stress_running": "Kör…",
|
||||
"settings.hardware.stress_progress": "Mäter {pair}…",
|
||||
"settings.hardware.stress_recommend": "Rekommenderas: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "Använd",
|
||||
"settings.hardware.stress_none": "Ingen installerad modell hinner med live-tal på den här hårdvaran — prova en mindre modell.",
|
||||
"settings.hardware.stress_backend": "Backend",
|
||||
"settings.hardware.stress_model": "Modell",
|
||||
"settings.hardware.stress_rtf": "Hastighet (×realtid)",
|
||||
"settings.hardware.stress_realtime": "Live?",
|
||||
"settings.hardware.stress_yes": "Ja",
|
||||
"settings.hardware.stress_no": "Nej",
|
||||
"settings.hardware.active_backend": "Aktiv backend",
|
||||
"settings.hardware.model_meta": "· modell {size}",
|
||||
"settings.hardware.preferred_backend": "Föredragen backend",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "Laddar ner {name}",
|
||||
"settings.hardware.active_badge": "Aktiv",
|
||||
"settings.hardware.installed": "Installerad",
|
||||
"settings.hardware.quantization_for": "Kvantisering för {name}",
|
||||
"settings.hardware.quant_recommended": "{quant} · rekommenderas",
|
||||
"settings.hardware.quant_higher": "{quant} · högre kvalitet, större",
|
||||
"settings.hardware.use": "Använd",
|
||||
"settings.hardware.remove": "Ta bort",
|
||||
"settings.hardware.cant_remove_active": "Kan inte ta bort den aktiva modellen",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "Kalender och Outlook .pst",
|
||||
"settings.calendar.intro_1": "Importera händelser och deltagare från en lokal Outlook-",
|
||||
"settings.calendar.intro_2": "säkerhetskopia — skrivskyddad, inget skrivs tillbaka till filen. Inget lämnar den här enheten.",
|
||||
"settings.calendar.requires": "Kräver detta i din PATH (medföljer inte):",
|
||||
"settings.calendar.pst_file": ".pst-fil",
|
||||
"settings.calendar.no_file": "Ingen fil vald",
|
||||
"settings.calendar.browse": "Bläddra…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "låst",
|
||||
"settings.privacy.vault_locked_2": ". Lås upp för att läsa krypterade möten.",
|
||||
"settings.privacy.password": "Lösenord",
|
||||
"settings.privacy.show_password": "Visa lösenord",
|
||||
"settings.privacy.unlock": "Lås upp",
|
||||
"settings.privacy.vault_unlocked_1": "Valvet är ",
|
||||
"settings.privacy.unlocked_word": "upplåst",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "valfritt",
|
||||
"import.title_placeholder": "Standard är filnamnet",
|
||||
"import.filter_av": "Ljud / video",
|
||||
"import.requires_1": "Kräver",
|
||||
"import.requires_2": "installerat och i din PATH (plus",
|
||||
"import.requires_3": "för URL:er). WhispAssist levererar dem inte.",
|
||||
"import.model_label": "Transkriberingsmodell",
|
||||
"import.model_hint": "Sparas med mötet så att du ser hur det transkriberades.",
|
||||
"import.requires": "Kräver dessa i din PATH (medföljer inte):",
|
||||
"import.background_note": "Körs i bakgrunden — följ förloppet i listan.",
|
||||
"import.importing": "Importerar… detta kan ta en stund",
|
||||
"import.import": "Importera",
|
||||
"import.cancel": "Avbryt",
|
||||
"import.tracker.label": "Importförlopp",
|
||||
"import.tracker.running": "Importerar…",
|
||||
"import.tracker.done": "Import klar",
|
||||
"import.tracker.failed": "Import misslyckades",
|
||||
"import.tracker.working": "arbetar…",
|
||||
"import.tracker.stopped": "stoppad",
|
||||
"import.phase.prepare": "Omkoda",
|
||||
"import.phase.transcribe": "Transkribera",
|
||||
"import.phase.diarize": "Identifiera talare",
|
||||
"import.phase.finalize": "Slutför",
|
||||
|
||||
"tagchip.filter": "Filtrera möten taggade ”{tag}”",
|
||||
"tagchip.remove": "Ta bort taggen {tag}",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "Avbryt",
|
||||
"app.cancel_title": "Kassera den här inspelningen och ta bort den",
|
||||
"app.recording": "Spelar in…",
|
||||
"app.mute": "Stäng av mikrofonen",
|
||||
"app.unmute": "Slå på mikrofonen",
|
||||
"app.mute_title": "Stäng av mikrofonen (M)",
|
||||
"app.unmute_title": "Slå på mikrofonen (M)",
|
||||
"app.backend_title": "Aktiv transkriberingsbackend",
|
||||
"app.retention_title": "Spara ljud som .wav för det här mötet",
|
||||
"app.saving": "sparar",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "fel",
|
||||
"meetings.resume": "Återuppta transkription",
|
||||
"meetings.delete_aria": "Ta bort möte",
|
||||
"meetings.not_encrypted": "Inte krypterad",
|
||||
"meetings.encrypt": "Kryptera",
|
||||
"meetings.encrypting": "Krypterar…",
|
||||
"meetings.encrypt_title": "Kryptera mötets filer med valvet",
|
||||
"meetings.delete_confirm": "Ta bort det här mötet? Detta tar bort dess inspelning, transkription och anteckningar.",
|
||||
|
||||
"transcript.heading": "Transkription",
|
||||
"transcript.title_aria": "Mötestitel",
|
||||
"transcript.transcribed_with": "Transkriberad med",
|
||||
"transcript.lang_title": "Transkriberingsspråk",
|
||||
"transcript.lang_auto": "identifierar…",
|
||||
"transcript.show": "Visa transkription",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "Kursiv",
|
||||
"notes.h1": "Rubrik 1",
|
||||
"notes.h2": "Rubrik 2",
|
||||
"notes.h3": "Rubrik 3",
|
||||
"notes.bullet": "Punktlista",
|
||||
"notes.numbered": "Numrerad lista",
|
||||
"notes.quote": "Citat",
|
||||
"notes.divider": "Avdelare",
|
||||
"notes.checkbox_title": "Kryssruta",
|
||||
"notes.checkbox_aria": "Listobjekt med kryssruta",
|
||||
"notes.enhance": "Förbättra",
|
||||
"notes.enhancing": "Förbättrar…",
|
||||
"notes.enhance_title": "Utöka anteckningarna till strukturerad Markdown med hjälp av transkriptet (AI)",
|
||||
"notes.enhanced_note": "Anteckningar förbättrade utifrån transkriptet.",
|
||||
"notes.undo_enhance": "Ångra",
|
||||
"notes.edit_raw": "Redigera rå markdown",
|
||||
"notes.render": "Rendera markdown",
|
||||
"notes.editor": "Redigerare",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "bekräftat",
|
||||
"settings.recording.consent_not": "ännu inte bekräftat",
|
||||
"settings.recording.auto_label": "Starta inspelning automatiskt när en kalenderhändelse börjar",
|
||||
"settings.recording.auto_hint": "Endast medan WhispAssist är öppet. När starttiden för en importerad kalenderhändelse infaller startar en inspelning automatiskt (med din standardinställning för kvarhållning ovan). Inget körs i bakgrunden — timern är bara aktiverad medan appen körs. Importera händelser under Inställningar → Kalender."
|
||||
"settings.recording.auto_hint": "Endast medan WhispAssist är öppet. När starttiden för en importerad kalenderhändelse infaller startar en inspelning automatiskt (med din standardinställning för kvarhållning ovan). Inget körs i bakgrunden — timern är bara aktiverad medan appen körs. Importera händelser under Inställningar → Kalender.",
|
||||
"settings.recording.autostart_label": "Starta WhispAssist vid inloggning",
|
||||
"settings.recording.autostart_hint": "Startar WhispAssist automatiskt när du loggar in i Windows. Av som standard; installerar en startpost per användare (inga administratörsrättigheter krävs) och börjar aldrig spela in av sig själv.",
|
||||
"settings.recording.close_tray_label": "Stäng till systemfältet",
|
||||
"settings.recording.close_tray_hint": "När fönstret stängs fortsätter WhispAssist att köras i bakgrunden i stället för att avslutas. Öppna igen från ikonen i systemfältet; välj Avsluta där för att stänga helt. På som standard."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "ஒரு மொழியைத் தேர்ந்தெடுக்க மேலே ஒரு பன்மொழி மாதிரிக்கு மாறவும்.",
|
||||
|
||||
"settings.hardware.title": "வன்பொருள்",
|
||||
"settings.hardware.refresh": "புதுப்பி",
|
||||
"settings.hardware.test_output": "சோதனை",
|
||||
"settings.hardware.test_mic": "சோதனை",
|
||||
"settings.hardware.testing": "கேட்கிறது…",
|
||||
"settings.hardware.play_tone": "ஒலியை இயக்கு",
|
||||
"settings.hardware.stress_title": "விரைவு அழுத்தச் சோதனை",
|
||||
"settings.hardware.stress_hint": "நிறுவப்பட்ட மாதிரிகளை கிடைக்கும் ஒவ்வொரு பின்புலத்திலும் அளவிட்டு, நேரடி பேச்சின் வேகத்தை ஈடுகொடுக்கும் மிகத் துல்லியமான மாதிரியைப் பரிந்துரைக்கிறது. சிறிது நேரம் ஆகும்.",
|
||||
"settings.hardware.stress_run": "அழுத்தச் சோதனையை இயக்கு",
|
||||
"settings.hardware.stress_running": "இயங்குகிறது…",
|
||||
"settings.hardware.stress_progress": "{pair} அளவிடப்படுகிறது…",
|
||||
"settings.hardware.stress_recommend": "பரிந்துரை: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "பயன்படுத்து",
|
||||
"settings.hardware.stress_none": "இந்த வன்பொருளில் நிறுவப்பட்ட எந்த மாதிரியும் நேரடி பேச்சின் வேகத்தை ஈடுகொடுக்கவில்லை — சிறிய மாதிரியை முயற்சிக்கவும்.",
|
||||
"settings.hardware.stress_backend": "பின்புலம்",
|
||||
"settings.hardware.stress_model": "மாதிரி",
|
||||
"settings.hardware.stress_rtf": "வேகம் (×நிகழ்நேரம்)",
|
||||
"settings.hardware.stress_realtime": "நேரடி?",
|
||||
"settings.hardware.stress_yes": "ஆம்",
|
||||
"settings.hardware.stress_no": "இல்லை",
|
||||
"settings.hardware.active_backend": "செயலில் உள்ள பின்தளம்",
|
||||
"settings.hardware.model_meta": "· மாதிரி {size}",
|
||||
"settings.hardware.preferred_backend": "விருப்பமான பின்தளம்",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "{name} பதிவிறக்குகிறது",
|
||||
"settings.hardware.active_badge": "செயலில்",
|
||||
"settings.hardware.installed": "நிறுவப்பட்டது",
|
||||
"settings.hardware.quantization_for": "{name}க்கான குவாண்டைசேஷன்",
|
||||
"settings.hardware.quant_recommended": "{quant} · பரிந்துரைக்கப்படுகிறது",
|
||||
"settings.hardware.quant_higher": "{quant} · உயர் தரம், பெரியது",
|
||||
"settings.hardware.use": "பயன்படுத்து",
|
||||
"settings.hardware.remove": "அகற்று",
|
||||
"settings.hardware.cant_remove_active": "செயலில் உள்ள மாதிரியை அகற்ற முடியாது",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "நாட்காட்டி & Outlook .pst",
|
||||
"settings.calendar.intro_1": "ஒரு உள்ளூர் Outlook காப்புப்பிரதியிலிருந்து நிகழ்வுகளையும் பங்கேற்பாளர்களையும் இறக்குமதி செய்யவும்",
|
||||
"settings.calendar.intro_2": "— படிக்க மட்டும், கோப்பிற்கு எதுவும் மீண்டும் எழுதப்படாது. இந்தச் சாதனத்திலிருந்து எதுவும் வெளியேறாது.",
|
||||
"settings.calendar.requires": "உங்கள் PATH-இல் இது தேவை (உடன் வழங்கப்படவில்லை):",
|
||||
"settings.calendar.pst_file": ".pst கோப்பு",
|
||||
"settings.calendar.no_file": "கோப்பு எதுவும் தேர்ந்தெடுக்கப்படவில்லை",
|
||||
"settings.calendar.browse": "உலாவு…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "பூட்டப்பட்டுள்ளது",
|
||||
"settings.privacy.vault_locked_2": ". மறையாக்கப்பட்ட கூட்டங்களைப் படிக்க திறக்கவும்.",
|
||||
"settings.privacy.password": "கடவுச்சொல்",
|
||||
"settings.privacy.show_password": "கடவுச்சொல்லைக் காட்டு",
|
||||
"settings.privacy.unlock": "திற",
|
||||
"settings.privacy.vault_unlocked_1": "பெட்டகம் ",
|
||||
"settings.privacy.unlocked_word": "திறக்கப்பட்டுள்ளது",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "விருப்பமானது",
|
||||
"import.title_placeholder": "இயல்பாகக் கோப்பின் பெயர்",
|
||||
"import.filter_av": "ஒலி / வீடியோ",
|
||||
"import.requires_1": "தேவை",
|
||||
"import.requires_2": "நிறுவப்பட்டு உங்கள் PATH இல் (மேலும்",
|
||||
"import.requires_3": "URL-களுக்கு). WhispAssist அவற்றை உள்ளடக்காது.",
|
||||
"import.model_label": "எழுத்தாக்க மாதிரி",
|
||||
"import.model_hint": "எந்த மாதிரியால் எழுத்தாக்கம் செய்யப்பட்டது என்பதைக் காண, கூட்டத்துடன் சேமிக்கப்படுகிறது.",
|
||||
"import.requires": "உங்கள் PATH-இல் இவை தேவை (உடன் வழங்கப்படவில்லை):",
|
||||
"import.background_note": "பின்னணியில் இயங்கும் — பட்டியலில் முன்னேற்றத்தைக் கண்காணிக்கவும்.",
|
||||
"import.importing": "இறக்குமதி செய்கிறது… இதற்கு சிறிது நேரம் ஆகலாம்",
|
||||
"import.import": "இறக்குமதி செய்",
|
||||
"import.cancel": "ரத்து செய்",
|
||||
"import.tracker.label": "இறக்குமதி முன்னேற்றம்",
|
||||
"import.tracker.running": "இறக்குமதி நடக்கிறது…",
|
||||
"import.tracker.done": "இறக்குமதி முடிந்தது",
|
||||
"import.tracker.failed": "இறக்குமதி தோல்வி",
|
||||
"import.tracker.working": "வேலை நடக்கிறது…",
|
||||
"import.tracker.stopped": "நிறுத்தப்பட்டது",
|
||||
"import.phase.prepare": "மறுகுறியாக்கம்",
|
||||
"import.phase.transcribe": "எழுத்தாக்கம்",
|
||||
"import.phase.diarize": "பேச்சாளர்களை அடையாளம் காணல்",
|
||||
"import.phase.finalize": "இறுதி செய்தல்",
|
||||
|
||||
"tagchip.filter": "“{tag}” குறிச்சொல் கொண்ட கூட்டங்களை வடிகட்டு",
|
||||
"tagchip.remove": "குறிச்சொல் {tag} ஐ அகற்று",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "ரத்து செய்",
|
||||
"app.cancel_title": "இந்தப் பதிவை நிராகரித்து நீக்கு",
|
||||
"app.recording": "பதிவு செய்கிறது…",
|
||||
"app.mute": "ஒலிவாங்கியை முடக்கு",
|
||||
"app.unmute": "ஒலிவாங்கியை இயக்கு",
|
||||
"app.mute_title": "ஒலிவாங்கியை முடக்கு (M)",
|
||||
"app.unmute_title": "ஒலிவாங்கியை இயக்கு (M)",
|
||||
"app.backend_title": "செயலில் உள்ள படியெடுப்பு பின்தளம்",
|
||||
"app.retention_title": "இந்தக் கூட்டத்திற்கு ஒலியை .wav ஆகச் சேமி",
|
||||
"app.saving": "சேமிக்கிறது",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "பிழை",
|
||||
"meetings.resume": "படியெடுப்பைத் தொடர்",
|
||||
"meetings.delete_aria": "கூட்டத்தை நீக்கு",
|
||||
"meetings.not_encrypted": "மறைகுறியாக்கப்படவில்லை",
|
||||
"meetings.encrypt": "மறைகுறியாக்கு",
|
||||
"meetings.encrypting": "மறைகுறியாக்கப்படுகிறது…",
|
||||
"meetings.encrypt_title": "இந்தக் கூட்டத்தின் கோப்புகளை காப்பகத்தால் மறைகுறியாக்கவும்",
|
||||
"meetings.delete_confirm": "இந்தக் கூட்டத்தை நீக்கவா? இது அதன் பதிவு, படியெடுப்பு மற்றும் குறிப்புகளை அகற்றும்.",
|
||||
|
||||
"transcript.heading": "படியெடுப்பு",
|
||||
"transcript.title_aria": "கூட்டத் தலைப்பு",
|
||||
"transcript.transcribed_with": "எழுத்தாக்கம் செய்தது:",
|
||||
"transcript.lang_title": "படியெடுப்பு மொழி",
|
||||
"transcript.lang_auto": "கண்டறிகிறது…",
|
||||
"transcript.show": "படியெடுப்பைக் காட்டு",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "சாய்வு",
|
||||
"notes.h1": "தலைப்பு 1",
|
||||
"notes.h2": "தலைப்பு 2",
|
||||
"notes.h3": "தலைப்பு 3",
|
||||
"notes.bullet": "புள்ளிப் பட்டியல்",
|
||||
"notes.numbered": "எண்ணிடப்பட்ட பட்டியல்",
|
||||
"notes.quote": "மேற்கோள்",
|
||||
"notes.divider": "பிரிப்பான்",
|
||||
"notes.checkbox_title": "தேர்வுப்பெட்டி",
|
||||
"notes.checkbox_aria": "தேர்வுப்பெட்டி பட்டியல் உருப்படி",
|
||||
"notes.enhance": "மேம்படுத்து",
|
||||
"notes.enhancing": "மேம்படுத்தப்படுகிறது…",
|
||||
"notes.enhance_title": "எழுத்தாக்கத்தைப் பயன்படுத்தி இந்தக் குறிப்புகளை கட்டமைந்த Markdown ஆக விரிவாக்கவும் (AI)",
|
||||
"notes.enhanced_note": "எழுத்தாக்கத்திலிருந்து குறிப்புகள் மேம்படுத்தப்பட்டன.",
|
||||
"notes.undo_enhance": "செயல்தவிர்",
|
||||
"notes.edit_raw": "மூல markdown ஐத் திருத்து",
|
||||
"notes.render": "markdown ஐ வழங்கு",
|
||||
"notes.editor": "திருத்தி",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "ஒப்புக்கொள்ளப்பட்டது",
|
||||
"settings.recording.consent_not": "இன்னும் ஒப்புக்கொள்ளப்படவில்லை",
|
||||
"settings.recording.auto_label": "ஒரு நாட்காட்டி நிகழ்வு தொடங்கும்போது பதிவைத் தானாகத் தொடங்கு",
|
||||
"settings.recording.auto_hint": "WhispAssist திறந்திருக்கும்போது மட்டும். இறக்குமதி செய்யப்பட்ட நாட்காட்டி நிகழ்வின் தொடக்க நேரம் வரும்போது, ஒரு பதிவு தானாகத் தொடங்கும் (மேலே உள்ள உங்கள் இயல்புநிலை தக்கவைப்பு அமைப்பைப் பயன்படுத்தி). பின்னணியில் எதுவும் இயங்காது — பயன்பாடு இயங்கும்போது மட்டுமே டைமர் தயார்நிலையில் இருக்கும். அமைப்புகள் → நாட்காட்டியின் கீழ் நிகழ்வுகளை இறக்குமதி செய்யவும்."
|
||||
"settings.recording.auto_hint": "WhispAssist திறந்திருக்கும்போது மட்டும். இறக்குமதி செய்யப்பட்ட நாட்காட்டி நிகழ்வின் தொடக்க நேரம் வரும்போது, ஒரு பதிவு தானாகத் தொடங்கும் (மேலே உள்ள உங்கள் இயல்புநிலை தக்கவைப்பு அமைப்பைப் பயன்படுத்தி). பின்னணியில் எதுவும் இயங்காது — பயன்பாடு இயங்கும்போது மட்டுமே டைமர் தயார்நிலையில் இருக்கும். அமைப்புகள் → நாட்காட்டியின் கீழ் நிகழ்வுகளை இறக்குமதி செய்யவும்.",
|
||||
"settings.recording.autostart_label": "உள்நுழைவின்போது WhispAssist ஐத் தொடங்கு",
|
||||
"settings.recording.autostart_hint": "Windows-இல் உள்நுழையும்போது WhispAssist தானாகத் தொடங்கும். இயல்பாக முடக்கப்பட்டுள்ளது; பயனர் அளவிலான தொடக்கப் பதிவை நிறுவுகிறது (நிர்வாகி தேவையில்லை), தானாகப் பதிவு செய்யத் தொடங்காது.",
|
||||
"settings.recording.close_tray_label": "சிஸ்டம் ட்ரேக்கு மூடு",
|
||||
"settings.recording.close_tray_hint": "சாளரத்தை மூடினால் WhispAssist வெளியேறாமல் பின்னணியில் இயங்கிக் கொண்டிருக்கும். ட்ரே ஐகானில் இருந்து மீண்டும் திறக்கவும்; முழுமையாக வெளியேற ட்ரேயின் 'வெளியேறு' ஐப் பயன்படுத்தவும். இயல்பாக இயக்கத்தில் உள்ளது."
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "زبان منتخب کرنے کے لیے اوپر کسی کثیر لسانی ماڈل پر سوئچ کریں۔",
|
||||
|
||||
"settings.hardware.title": "ہارڈ ویئر",
|
||||
"settings.hardware.refresh": "تازہ کریں",
|
||||
"settings.hardware.test_output": "آزمائیں",
|
||||
"settings.hardware.test_mic": "آزمائیں",
|
||||
"settings.hardware.testing": "سن رہا ہے…",
|
||||
"settings.hardware.play_tone": "ٹون چلائیں",
|
||||
"settings.hardware.stress_title": "فوری اسٹریس ٹیسٹ",
|
||||
"settings.hardware.stress_hint": "انسٹال شدہ ماڈلز کو ہر دستیاب بیک اینڈ پر جانچتا ہے اور سب سے درست ماڈل تجویز کرتا ہے جو براہِ راست گفتگو کی رفتار برقرار رکھ سکے۔ اس میں کچھ وقت لگتا ہے۔",
|
||||
"settings.hardware.stress_run": "اسٹریس ٹیسٹ چلائیں",
|
||||
"settings.hardware.stress_running": "جاری ہے…",
|
||||
"settings.hardware.stress_progress": "{pair} کی جانچ جاری ہے…",
|
||||
"settings.hardware.stress_recommend": "تجویز کردہ: {backend} + {model}",
|
||||
"settings.hardware.stress_apply": "لاگو کریں",
|
||||
"settings.hardware.stress_none": "اس ہارڈویئر پر کوئی بھی انسٹال شدہ ماڈل براہِ راست گفتگو کی رفتار برقرار نہیں رکھ پاتا — کوئی چھوٹا ماڈل آزمائیں۔",
|
||||
"settings.hardware.stress_backend": "بیک اینڈ",
|
||||
"settings.hardware.stress_model": "ماڈل",
|
||||
"settings.hardware.stress_rtf": "رفتار (×اصل وقت)",
|
||||
"settings.hardware.stress_realtime": "براہِ راست؟",
|
||||
"settings.hardware.stress_yes": "ہاں",
|
||||
"settings.hardware.stress_no": "نہیں",
|
||||
"settings.hardware.active_backend": "فعال بیک اینڈ",
|
||||
"settings.hardware.model_meta": "· ماڈل {size}",
|
||||
"settings.hardware.preferred_backend": "ترجیحی بیک اینڈ",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "{name} ڈاؤن لوڈ ہو رہا ہے",
|
||||
"settings.hardware.active_badge": "فعال",
|
||||
"settings.hardware.installed": "انسٹال شدہ",
|
||||
"settings.hardware.quantization_for": "{name} کے لیے کوانٹائزیشن",
|
||||
"settings.hardware.quant_recommended": "{quant} · تجویز کردہ",
|
||||
"settings.hardware.quant_higher": "{quant} · بہتر معیار، بڑا",
|
||||
"settings.hardware.use": "استعمال کریں",
|
||||
"settings.hardware.remove": "ہٹائیں",
|
||||
"settings.hardware.cant_remove_active": "فعال ماڈل کو نہیں ہٹایا جا سکتا",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "کیلنڈر اور Outlook .pst",
|
||||
"settings.calendar.intro_1": "ایک مقامی Outlook بیک اپ سے ایونٹس اور شرکاء درآمد کریں",
|
||||
"settings.calendar.intro_2": "— صرف پڑھنے کے لیے، فائل میں کچھ بھی واپس نہیں لکھا جاتا۔ اس آلے سے کچھ بھی باہر نہیں جاتا۔",
|
||||
"settings.calendar.requires": "آپ کے PATH میں یہ درکار ہے (ساتھ شامل نہیں):",
|
||||
"settings.calendar.pst_file": ".pst فائل",
|
||||
"settings.calendar.no_file": "کوئی فائل منتخب نہیں",
|
||||
"settings.calendar.browse": "براؤز کریں…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "مقفل",
|
||||
"settings.privacy.vault_locked_2": " ہے۔ خفیہ شدہ میٹنگز پڑھنے کے لیے ان لاک کریں۔",
|
||||
"settings.privacy.password": "پاس ورڈ",
|
||||
"settings.privacy.show_password": "پاس ورڈ دکھائیں",
|
||||
"settings.privacy.unlock": "ان لاک کریں",
|
||||
"settings.privacy.vault_unlocked_1": "والٹ ",
|
||||
"settings.privacy.unlocked_word": "ان لاک",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "اختیاری",
|
||||
"import.title_placeholder": "بطور ڈیفالٹ فائل کا نام",
|
||||
"import.filter_av": "آڈیو / ویڈیو",
|
||||
"import.requires_1": "درکار ہے",
|
||||
"import.requires_2": "انسٹال شدہ اور آپ کے PATH میں (اور",
|
||||
"import.requires_3": "URLs کے لیے)۔ WhispAssist انہیں شامل نہیں کرتا۔",
|
||||
"import.model_label": "نقل نویسی ماڈل",
|
||||
"import.model_hint": "میٹنگ کے ساتھ محفوظ ہوتا ہے تاکہ معلوم ہو کہ نقل نویسی کس سے ہوئی۔",
|
||||
"import.requires": "آپ کے PATH میں یہ درکار ہیں (ساتھ شامل نہیں):",
|
||||
"import.background_note": "پس منظر میں چلتا ہے — فہرست میں پیش رفت دیکھیں۔",
|
||||
"import.importing": "درآمد ہو رہا ہے… اس میں کچھ وقت لگ سکتا ہے",
|
||||
"import.import": "درآمد کریں",
|
||||
"import.cancel": "منسوخ کریں",
|
||||
"import.tracker.label": "درآمد کی پیش رفت",
|
||||
"import.tracker.running": "درآمد جاری ہے…",
|
||||
"import.tracker.done": "درآمد مکمل",
|
||||
"import.tracker.failed": "درآمد ناکام",
|
||||
"import.tracker.working": "کام جاری…",
|
||||
"import.tracker.stopped": "روک دیا گیا",
|
||||
"import.phase.prepare": "ٹرانس کوڈ",
|
||||
"import.phase.transcribe": "نقل نویسی",
|
||||
"import.phase.diarize": "مقررین کی شناخت",
|
||||
"import.phase.finalize": "تکمیل",
|
||||
|
||||
"tagchip.filter": "«{tag}» ٹیگ والی میٹنگز فلٹر کریں",
|
||||
"tagchip.remove": "ٹیگ {tag} ہٹائیں",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "منسوخ کریں",
|
||||
"app.cancel_title": "اس ریکارڈنگ کو رد کریں اور حذف کریں",
|
||||
"app.recording": "ریکارڈ ہو رہا ہے…",
|
||||
"app.mute": "مائیکروفون خاموش کریں",
|
||||
"app.unmute": "مائیکروفون بحال کریں",
|
||||
"app.mute_title": "مائیکروفون خاموش کریں (M)",
|
||||
"app.unmute_title": "مائیکروفون بحال کریں (M)",
|
||||
"app.backend_title": "فعال نقل بیک اینڈ",
|
||||
"app.retention_title": "اس میٹنگ کے لیے آڈیو کو .wav کے طور پر محفوظ کریں",
|
||||
"app.saving": "محفوظ ہو رہا ہے",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "خرابی",
|
||||
"meetings.resume": "نقل دوبارہ شروع کریں",
|
||||
"meetings.delete_aria": "میٹنگ حذف کریں",
|
||||
"meetings.not_encrypted": "غیر خفیہ کردہ",
|
||||
"meetings.encrypt": "خفیہ کریں",
|
||||
"meetings.encrypting": "خفیہ کیا جا رہا ہے…",
|
||||
"meetings.encrypt_title": "اس میٹنگ کی فائلیں والٹ سے خفیہ کریں",
|
||||
"meetings.delete_confirm": "اس میٹنگ کو حذف کریں؟ یہ اس کی ریکارڈنگ، نقل اور نوٹس ہٹا دیتا ہے۔",
|
||||
|
||||
"transcript.heading": "نقل",
|
||||
"transcript.title_aria": "میٹنگ عنوان",
|
||||
"transcript.transcribed_with": "نقل نویسی بذریعہ",
|
||||
"transcript.lang_title": "نقل کی زبان",
|
||||
"transcript.lang_auto": "شناخت ہو رہی ہے…",
|
||||
"transcript.show": "نقل دکھائیں",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "ترچھا",
|
||||
"notes.h1": "سرخی 1",
|
||||
"notes.h2": "سرخی 2",
|
||||
"notes.h3": "سرخی 3",
|
||||
"notes.bullet": "بلٹ فہرست",
|
||||
"notes.numbered": "نمبر شدہ فہرست",
|
||||
"notes.quote": "اقتباس",
|
||||
"notes.divider": "جداکار",
|
||||
"notes.checkbox_title": "چیک باکس",
|
||||
"notes.checkbox_aria": "چیک باکس فہرست آئٹم",
|
||||
"notes.enhance": "بہتر بنائیں",
|
||||
"notes.enhancing": "بہتر بنایا جا رہا ہے…",
|
||||
"notes.enhance_title": "ٹرانسکرپٹ کی مدد سے ان نوٹس کو منظم Markdown میں پھیلائیں (AI)",
|
||||
"notes.enhanced_note": "ٹرانسکرپٹ کی بنیاد پر نوٹس بہتر بنائے گئے۔",
|
||||
"notes.undo_enhance": "کالعدم کریں",
|
||||
"notes.edit_raw": "خام markdown میں ترمیم کریں",
|
||||
"notes.render": "markdown رینڈر کریں",
|
||||
"notes.editor": "ایڈیٹر",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "تسلیم شدہ",
|
||||
"settings.recording.consent_not": "ابھی تسلیم نہیں",
|
||||
"settings.recording.auto_label": "جب کوئی کیلنڈر ایونٹ شروع ہو تو خودکار طور پر ریکارڈنگ شروع کریں",
|
||||
"settings.recording.auto_hint": "صرف جب WhispAssist کھلا ہو۔ جب کسی درآمد شدہ کیلنڈر ایونٹ کا آغاز وقت آتا ہے، تو ریکارڈنگ خودکار طور پر شروع ہو جاتی ہے (اوپر والی آپ کی ڈیفالٹ برقراری ترتیب استعمال کرتے ہوئے)۔ پس منظر میں کچھ نہیں چلتا — ٹائمر صرف ایپ چلنے کے دوران مسلح ہوتا ہے۔ ترتیبات → کیلنڈر کے تحت ایونٹس درآمد کریں۔"
|
||||
"settings.recording.auto_hint": "صرف جب WhispAssist کھلا ہو۔ جب کسی درآمد شدہ کیلنڈر ایونٹ کا آغاز وقت آتا ہے، تو ریکارڈنگ خودکار طور پر شروع ہو جاتی ہے (اوپر والی آپ کی ڈیفالٹ برقراری ترتیب استعمال کرتے ہوئے)۔ پس منظر میں کچھ نہیں چلتا — ٹائمر صرف ایپ چلنے کے دوران مسلح ہوتا ہے۔ ترتیبات → کیلنڈر کے تحت ایونٹس درآمد کریں۔",
|
||||
"settings.recording.autostart_label": "لاگ اِن پر WhispAssist شروع کریں",
|
||||
"settings.recording.autostart_hint": "جب آپ Windows میں سائن اِن کرتے ہیں تو WhispAssist خود بخود شروع ہو جاتا ہے۔ طے شدہ طور پر بند؛ فی صارف اسٹارٹ اپ اندراج نصب کرتا ہے (ایڈمن درکار نہیں) اور خود سے ریکارڈنگ شروع نہیں کرتا۔",
|
||||
"settings.recording.close_tray_label": "سسٹم ٹرے میں بند کریں",
|
||||
"settings.recording.close_tray_hint": "ونڈو بند کرنے پر WhispAssist بند ہونے کے بجائے پس منظر میں چلتا رہتا ہے۔ ٹرے آئیکن سے دوبارہ کھولیں؛ مکمل بند کرنے کے لیے ٹرے کا 'اخراج' استعمال کریں۔ طے شدہ طور پر فعال۔"
|
||||
}
|
||||
|
||||
+61
-4
@@ -24,6 +24,25 @@
|
||||
"settings.transcription.switch_multilingual": "切换到上方的多语言模型即可选择语言。",
|
||||
|
||||
"settings.hardware.title": "硬件",
|
||||
"settings.hardware.refresh": "刷新",
|
||||
"settings.hardware.test_output": "测试",
|
||||
"settings.hardware.test_mic": "测试",
|
||||
"settings.hardware.testing": "正在监听…",
|
||||
"settings.hardware.play_tone": "播放提示音",
|
||||
"settings.hardware.stress_title": "快速压力测试",
|
||||
"settings.hardware.stress_hint": "在每个可用后端上对已安装的模型进行基准测试,并推荐既能跟上实时语音又最准确的模型。需要一点时间。",
|
||||
"settings.hardware.stress_run": "运行压力测试",
|
||||
"settings.hardware.stress_running": "运行中…",
|
||||
"settings.hardware.stress_progress": "正在测试 {pair}…",
|
||||
"settings.hardware.stress_recommend": "推荐:{backend} + {model}",
|
||||
"settings.hardware.stress_apply": "应用",
|
||||
"settings.hardware.stress_none": "在此硬件上没有已安装的模型能跟上实时语音——请尝试更小的模型。",
|
||||
"settings.hardware.stress_backend": "后端",
|
||||
"settings.hardware.stress_model": "模型",
|
||||
"settings.hardware.stress_rtf": "速度(×实时)",
|
||||
"settings.hardware.stress_realtime": "实时?",
|
||||
"settings.hardware.stress_yes": "是",
|
||||
"settings.hardware.stress_no": "否",
|
||||
"settings.hardware.active_backend": "活动后端",
|
||||
"settings.hardware.model_meta": "· 模型 {size}",
|
||||
"settings.hardware.preferred_backend": "首选后端",
|
||||
@@ -56,6 +75,9 @@
|
||||
"settings.hardware.downloading_model": "正在下载 {name}",
|
||||
"settings.hardware.active_badge": "活动",
|
||||
"settings.hardware.installed": "已安装",
|
||||
"settings.hardware.quantization_for": "{name} 的量化",
|
||||
"settings.hardware.quant_recommended": "{quant} · 推荐",
|
||||
"settings.hardware.quant_higher": "{quant} · 质量更高,体积更大",
|
||||
"settings.hardware.use": "使用",
|
||||
"settings.hardware.remove": "移除",
|
||||
"settings.hardware.cant_remove_active": "无法移除活动模型",
|
||||
@@ -88,6 +110,7 @@
|
||||
"settings.calendar.title": "日历与 Outlook .pst",
|
||||
"settings.calendar.intro_1": "从本地 Outlook 备份导入事件和参与者",
|
||||
"settings.calendar.intro_2": "——只读,不会向文件写回任何内容。没有任何内容离开此设备。",
|
||||
"settings.calendar.requires": "需要以下程序位于 PATH 中(未随附):",
|
||||
"settings.calendar.pst_file": ".pst 文件",
|
||||
"settings.calendar.no_file": "未选择文件",
|
||||
"settings.calendar.browse": "浏览…",
|
||||
@@ -278,6 +301,7 @@
|
||||
"settings.privacy.locked_word": "锁定",
|
||||
"settings.privacy.vault_locked_2": "。解锁以读取加密的会议。",
|
||||
"settings.privacy.password": "密码",
|
||||
"settings.privacy.show_password": "显示密码",
|
||||
"settings.privacy.unlock": "解锁",
|
||||
"settings.privacy.vault_unlocked_1": "保险库已",
|
||||
"settings.privacy.unlocked_word": "解锁",
|
||||
@@ -320,12 +344,23 @@
|
||||
"import.optional": "可选",
|
||||
"import.title_placeholder": "默认为文件名",
|
||||
"import.filter_av": "音频 / 视频",
|
||||
"import.requires_1": "需要",
|
||||
"import.requires_2": "已安装并在你的 PATH 中(以及",
|
||||
"import.requires_3": "用于 URL)。WhispAssist 不捆绑它们。",
|
||||
"import.model_label": "转录模型",
|
||||
"import.model_hint": "与会议一同记录,方便查看当时使用的转录模型。",
|
||||
"import.requires": "需要以下程序位于 PATH 中(未随附):",
|
||||
"import.background_note": "在后台运行——可在列表中跟踪进度。",
|
||||
"import.importing": "正在导入…这可能需要一段时间",
|
||||
"import.import": "导入",
|
||||
"import.cancel": "取消",
|
||||
"import.tracker.label": "导入进度",
|
||||
"import.tracker.running": "正在导入…",
|
||||
"import.tracker.done": "导入完成",
|
||||
"import.tracker.failed": "导入失败",
|
||||
"import.tracker.working": "处理中…",
|
||||
"import.tracker.stopped": "已停止",
|
||||
"import.phase.prepare": "转码",
|
||||
"import.phase.transcribe": "转录",
|
||||
"import.phase.diarize": "识别说话人",
|
||||
"import.phase.finalize": "完成",
|
||||
|
||||
"tagchip.filter": "筛选带有标签“{tag}”的会议",
|
||||
"tagchip.remove": "移除标签 {tag}",
|
||||
@@ -345,6 +380,10 @@
|
||||
"app.cancel": "取消",
|
||||
"app.cancel_title": "放弃此录音并删除它",
|
||||
"app.recording": "正在录音…",
|
||||
"app.mute": "麦克风静音",
|
||||
"app.unmute": "取消麦克风静音",
|
||||
"app.mute_title": "麦克风静音 (M)",
|
||||
"app.unmute_title": "取消麦克风静音 (M)",
|
||||
"app.backend_title": "活动转录后端",
|
||||
"app.retention_title": "为此会议将音频保存为 .wav",
|
||||
"app.saving": "正在保存",
|
||||
@@ -393,10 +432,15 @@
|
||||
"meetings.status.error": "错误",
|
||||
"meetings.resume": "恢复转录",
|
||||
"meetings.delete_aria": "删除会议",
|
||||
"meetings.not_encrypted": "未加密",
|
||||
"meetings.encrypt": "加密",
|
||||
"meetings.encrypting": "正在加密…",
|
||||
"meetings.encrypt_title": "使用保险库加密此会议的文件",
|
||||
"meetings.delete_confirm": "删除此会议?这将移除它的录音、转录和笔记。",
|
||||
|
||||
"transcript.heading": "转录",
|
||||
"transcript.title_aria": "会议标题",
|
||||
"transcript.transcribed_with": "转录所用模型:",
|
||||
"transcript.lang_title": "转录语言",
|
||||
"transcript.lang_auto": "正在自动检测…",
|
||||
"transcript.show": "显示转录",
|
||||
@@ -428,9 +472,18 @@
|
||||
"notes.italic": "斜体",
|
||||
"notes.h1": "标题 1",
|
||||
"notes.h2": "标题 2",
|
||||
"notes.h3": "三级标题",
|
||||
"notes.bullet": "项目符号列表",
|
||||
"notes.numbered": "有序列表",
|
||||
"notes.quote": "引用",
|
||||
"notes.divider": "分隔线",
|
||||
"notes.checkbox_title": "复选框",
|
||||
"notes.checkbox_aria": "复选框列表项",
|
||||
"notes.enhance": "增强",
|
||||
"notes.enhancing": "正在增强…",
|
||||
"notes.enhance_title": "根据转录将这些笔记扩展为结构化 Markdown(AI)",
|
||||
"notes.enhanced_note": "笔记已根据转录增强。",
|
||||
"notes.undo_enhance": "撤销",
|
||||
"notes.edit_raw": "编辑原始 markdown",
|
||||
"notes.render": "渲染 markdown",
|
||||
"notes.editor": "编辑器",
|
||||
@@ -527,5 +580,9 @@
|
||||
"settings.recording.consent_ack": "已确认",
|
||||
"settings.recording.consent_not": "尚未确认",
|
||||
"settings.recording.auto_label": "当日历事件开始时自动开始录音",
|
||||
"settings.recording.auto_hint": "仅在 WhispAssist 打开时。当导入的日历事件的开始时间到达时,会自动开始录音(使用上方的默认保留设置)。没有任何内容在后台运行——计时器仅在应用运行时启用。请在设置 → 日历下导入事件。"
|
||||
"settings.recording.auto_hint": "仅在 WhispAssist 打开时。当导入的日历事件的开始时间到达时,会自动开始录音(使用上方的默认保留设置)。没有任何内容在后台运行——计时器仅在应用运行时启用。请在设置 → 日历下导入事件。",
|
||||
"settings.recording.autostart_label": "登录时启动 WhispAssist",
|
||||
"settings.recording.autostart_hint": "登录 Windows 时自动启动 WhispAssist。默认关闭;仅为当前用户安装启动项(无需管理员权限),并且不会自行开始录音。",
|
||||
"settings.recording.close_tray_label": "关闭到系统托盘",
|
||||
"settings.recording.close_tray_hint": "关闭窗口后 WhispAssist 会继续在后台运行而不是退出。可通过托盘图标重新打开;用托盘菜单中的“退出”彻底退出。默认开启。"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -55,6 +55,13 @@ class MeetingsStore {
|
||||
syncJobs = $state<SyncJob[]>([]);
|
||||
uploading = $state(false);
|
||||
|
||||
// At-rest encryption padlocks (FR-SEC-3): meetings that still have plaintext
|
||||
// artifacts on disk (empty when the vault isn't set up), plus whether the
|
||||
// vault is unlocked — which gates the per-meeting Encrypt action.
|
||||
unencryptedIds = $state<MeetingId[]>([]);
|
||||
vaultUnlocked = $state(false);
|
||||
encryptingId = $state<MeetingId | null>(null);
|
||||
|
||||
async init() {
|
||||
await this.loadTags();
|
||||
await this.load();
|
||||
@@ -152,6 +159,31 @@ class MeetingsStore {
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
void this.refreshEncryption();
|
||||
}
|
||||
|
||||
/** Refresh the unencrypted-meeting set + vault unlock state (FR-SEC-3).
|
||||
* Also called from Settings after vault enable/unlock/lock so the padlocks
|
||||
* react without a list reload. */
|
||||
async refreshEncryption() {
|
||||
try {
|
||||
const [ids, vault] = await Promise.all([api.listUnencryptedMeetings(), api.vaultStatus()]);
|
||||
this.unencryptedIds = ids;
|
||||
this.vaultUnlocked = vault.unlocked;
|
||||
} catch {
|
||||
// backend not reachable; leave as-is.
|
||||
}
|
||||
}
|
||||
|
||||
/** Seal an unencrypted meeting's artifacts in place (vault must be unlocked). */
|
||||
async encrypt(id: MeetingId) {
|
||||
this.encryptingId = id;
|
||||
try {
|
||||
await api.encryptMeeting(id);
|
||||
} finally {
|
||||
this.encryptingId = null;
|
||||
}
|
||||
await this.refreshEncryption();
|
||||
}
|
||||
|
||||
async loadTags() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Recording state store (Svelte 5 runes-friendly via a small class).
|
||||
// Subscribes to recording/transcript events and exposes reactive state.
|
||||
|
||||
import { api, events, type TranscriptSegment, type MeetingId } from "../api";
|
||||
import { api, events, type TranscriptSegment, type SpeakerInfo, type MeetingId } from "../api";
|
||||
import { settings } from "./settings.svelte";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
|
||||
@@ -10,6 +10,9 @@ class RecordingStore {
|
||||
state = $state<"idle" | "recording" | "paused">("idle");
|
||||
elapsedMs = $state(0);
|
||||
segments = $state<TranscriptSegment[]>([]);
|
||||
/** Live speaker list from provisional diarization (FR-SPK): resolves labels
|
||||
* to display names ("You" for the mic speaker) for the live transcript. */
|
||||
speakers = $state<SpeakerInfo[]>([]);
|
||||
/** Whether audio is being retained as .wav for the in-flight meeting (ADR-0009). */
|
||||
retention = $state(false);
|
||||
/** Live system/loopback level for the waveform/meter (FR-CAP-5); 0 when not recording. */
|
||||
@@ -19,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. */
|
||||
@@ -47,11 +53,16 @@ class RecordingStore {
|
||||
this.retention = p.record;
|
||||
});
|
||||
await events.onSegment(({ segment }) => {
|
||||
// Replace an interim segment with the same id, else append.
|
||||
// Replace an interim segment with the same id, else append. Live
|
||||
// diarization also re-emits committed segments here with a refined
|
||||
// speaker (FR-SPK) — same replace-by-id path applies.
|
||||
const i = this.segments.findIndex((s) => s.id === segment.id);
|
||||
if (i >= 0) this.segments[i] = segment;
|
||||
else this.segments.push(segment);
|
||||
});
|
||||
await events.onDiarizationUpdated(({ speakers }) => {
|
||||
this.speakers = speakers;
|
||||
});
|
||||
await events.onLevel(({ rms, peak, mic }) => {
|
||||
if (mic) {
|
||||
this.levelRmsMic = rms;
|
||||
@@ -64,12 +75,30 @@ 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) {
|
||||
this.segments = [];
|
||||
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
|
||||
@@ -89,6 +118,7 @@ class RecordingStore {
|
||||
this.levelPeak = 0;
|
||||
this.levelRmsMic = 0;
|
||||
this.levelPeakMic = 0;
|
||||
this.micMuted = false;
|
||||
this.deviceNotice = null;
|
||||
}
|
||||
|
||||
@@ -101,11 +131,13 @@ class RecordingStore {
|
||||
if (this.meetingId) await api.cancelRecording(this.meetingId);
|
||||
this.meetingId = null;
|
||||
this.segments = [];
|
||||
this.speakers = [];
|
||||
this.state = "idle";
|
||||
this.levelRms = 0;
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { recording } from "../stores/recording.svelte";
|
||||
import { api, type MeetingListItem, type SearchHit } from "../api";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Search, Trash2, Download, RotateCcw } from "@lucide/svelte";
|
||||
import { Search, Trash2, Download, RotateCcw, Lock, LockOpen } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
|
||||
// Meeting status ("recording"/"transcribing"/"recovering"/"error") → its
|
||||
@@ -110,6 +110,15 @@
|
||||
e.stopPropagation();
|
||||
await meetings.resume(id);
|
||||
}
|
||||
|
||||
// At-rest encryption padlocks (FR-SEC-3). A meeting with plaintext
|
||||
// artifacts shows an open padlock (only when a vault exists — without one
|
||||
// the distinction is noise); with the vault unlocked, an Encrypt action
|
||||
// seals it in place.
|
||||
async function encryptMeeting(e: Event, id: string) {
|
||||
e.stopPropagation();
|
||||
await meetings.encrypt(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
@@ -191,7 +200,19 @@
|
||||
</span>
|
||||
<span class="row muted small">
|
||||
<span>{formatDate(m.started_at)}</span>
|
||||
{#if m.duration_secs}<span>{formatDuration(m.duration_secs)}</span>{/if}
|
||||
<span class="row meta-end">
|
||||
{#if meetings.unencryptedIds.includes(m.id)}
|
||||
<span
|
||||
class="unencrypted"
|
||||
role="img"
|
||||
aria-label={t("meetings.not_encrypted")}
|
||||
title={t("meetings.not_encrypted")}
|
||||
>
|
||||
<LockOpen size={12} aria-hidden="true" />
|
||||
</span>
|
||||
{/if}
|
||||
{#if m.duration_secs}<span>{formatDuration(m.duration_secs)}</span>{/if}
|
||||
</span>
|
||||
</span>
|
||||
{#if snippetOf(m)}
|
||||
<span class="snippet">{snippetOf(m)}</span>
|
||||
@@ -205,6 +226,17 @@
|
||||
{/if}
|
||||
</button>
|
||||
<div class="row actions">
|
||||
{#if meetings.unencryptedIds.includes(m.id) && meetings.vaultUnlocked && m.status !== "recording"}
|
||||
<button
|
||||
class="link"
|
||||
onclick={(e) => encryptMeeting(e, m.id)}
|
||||
disabled={meetings.encryptingId !== null}
|
||||
title={t("meetings.encrypt_title")}
|
||||
>
|
||||
<Lock size={12} aria-hidden="true" />
|
||||
{meetings.encryptingId === m.id ? t("meetings.encrypting") : t("meetings.encrypt")}
|
||||
</button>
|
||||
{/if}
|
||||
{#if m.status === "recovering"}
|
||||
<button class="link" onclick={(e) => resumeMeeting(e, m.id)}>
|
||||
<RotateCcw size={12} aria-hidden="true" />
|
||||
@@ -390,7 +422,22 @@
|
||||
font-size: 0.75rem;
|
||||
padding: 0;
|
||||
}
|
||||
.link:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.link.danger:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
/* Open-padlock marker for meetings with plaintext artifacts (FR-SEC-3) —
|
||||
warning-tinted so it reads as "attention", not merely decorative. */
|
||||
.meta-end {
|
||||
justify-content: flex-end;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.unencrypted {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--warning);
|
||||
}
|
||||
</style>
|
||||
|
||||
+533
-69
@@ -9,9 +9,17 @@
|
||||
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,
|
||||
ModelInfo,
|
||||
StressTestResult,
|
||||
SyncKind,
|
||||
SyncTargetConfig,
|
||||
SyncTargetInfo,
|
||||
} from "../api";
|
||||
import { trapFocus } from "../actions/trapFocus";
|
||||
import {
|
||||
X,
|
||||
@@ -23,7 +31,11 @@
|
||||
CalendarDays,
|
||||
UploadCloud,
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
LockOpen,
|
||||
Sparkles,
|
||||
Volume2,
|
||||
Zap,
|
||||
RefreshCw,
|
||||
ChevronRight,
|
||||
RotateCcw,
|
||||
@@ -275,6 +287,11 @@
|
||||
}
|
||||
|
||||
// ---- Calendar / .pst import (T6.1/T6.2/T6.3, FR-CAL-1/2) ----
|
||||
// .pst import shells out to readpst (libpst, ADR-0008) — external like
|
||||
// ffmpeg/yt-dlp, so link the same kind of download button as the Import
|
||||
// dialog's. ezwinports is the maintained Windows build of libpst.
|
||||
const LIBPST_URL =
|
||||
"https://sourceforge.net/projects/ezwinports/files/libpst-0.6.63-w32-bin.zip/download";
|
||||
let pstPath = $state("");
|
||||
let pstPassword = $state("");
|
||||
let eventTitleFilter = $state("");
|
||||
@@ -378,58 +395,176 @@
|
||||
if (!p || !p.total) return null;
|
||||
return Math.round((p.received / p.total) * 100);
|
||||
}
|
||||
// Whisper models grouped one row per family (id minus the `-q*` suffix)
|
||||
// with a quantization dropdown, instead of a separate row per quantization.
|
||||
// The catalog guarantees each family lists its recommended q5 variant first.
|
||||
const familyOf = (id: string) => id.replace(/-q\d_\d$/, "");
|
||||
const quantOf = (id: string) => (id.match(/-(q\d_\d)$/)?.[1] ?? "").toUpperCase();
|
||||
let modelFamilies = $derived.by(() => {
|
||||
const fams: { key: string; variants: ModelInfo[] }[] = [];
|
||||
for (const m of settings.models) {
|
||||
const k = familyOf(m.id);
|
||||
const f = fams.find((x) => x.key === k);
|
||||
if (f) f.variants.push(m);
|
||||
else fams.push({ key: k, variants: [m] });
|
||||
}
|
||||
return fams;
|
||||
});
|
||||
// Per-family dropdown choice; unset falls back to active > installed > recommended.
|
||||
let quantChoice = $state<Record<string, string>>({});
|
||||
function shownVariant(key: string, variants: ModelInfo[]): ModelInfo {
|
||||
return (
|
||||
variants.find((v) => v.id === quantChoice[key]) ??
|
||||
variants.find((v) => v.active) ??
|
||||
variants.find((v) => v.installed) ??
|
||||
variants[0]
|
||||
);
|
||||
}
|
||||
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();
|
||||
} catch {
|
||||
vault = null;
|
||||
}
|
||||
// Keep the meetings list's padlocks in step with vault state (FR-SEC-3).
|
||||
void meetings.refreshEncryption();
|
||||
}
|
||||
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 +889,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>
|
||||
<h3>{t("settings.hardware.title")}</h3>
|
||||
<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 +966,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 +1001,77 @@
|
||||
</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}
|
||||
@@ -910,7 +1157,8 @@
|
||||
|
||||
<h4>{t("settings.hardware.models")}</h4>
|
||||
<ul class="models">
|
||||
{#each settings.models as m (m.id)}
|
||||
{#each modelFamilies as f (f.key)}
|
||||
{@const m = shownVariant(f.key, f.variants)}
|
||||
{@const downloading = m.id in settings.downloadProgress}
|
||||
{@const pct = progressPct(m.id)}
|
||||
<li class="model" class:active={m.active}>
|
||||
@@ -948,6 +1196,23 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="model-actions">
|
||||
{#if f.variants.length > 1}
|
||||
<select
|
||||
class="quant-select"
|
||||
aria-label={t("settings.hardware.quantization_for", { name: m.label })}
|
||||
value={m.id}
|
||||
disabled={downloading}
|
||||
onchange={(e) => (quantChoice[f.key] = (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{#each f.variants as v, i (v.id)}
|
||||
<option value={v.id}>
|
||||
{i === 0
|
||||
? t("settings.hardware.quant_recommended", { quant: quantOf(v.id) })
|
||||
: t("settings.hardware.quant_higher", { quant: quantOf(v.id) })}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
{#if downloading}
|
||||
<!-- no actions while a download is in flight -->
|
||||
{:else if m.installed}
|
||||
@@ -1132,6 +1397,12 @@
|
||||
{t("settings.calendar.intro_1")} <code>.pst</code>
|
||||
{t("settings.calendar.intro_2")}
|
||||
</p>
|
||||
<div class="tools">
|
||||
<span class="muted small">{t("settings.calendar.requires")}</span>
|
||||
<button class="tool" type="button" onclick={() => api.openUrl(LIBPST_URL)}>
|
||||
<Download size={12} aria-hidden="true" /> libpst (readpst)
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label class="wide"
|
||||
>{t("settings.calendar.pst_file")}
|
||||
@@ -1918,67 +2189,118 @@
|
||||
{/if}
|
||||
|
||||
{#if vault}
|
||||
<h4>{t("settings.privacy.vault_title")}</h4>
|
||||
{#if !vault.enabled}
|
||||
<p class="muted">{t("settings.privacy.vault_intro")}</p>
|
||||
<div class="grid">
|
||||
<label class="wide"
|
||||
>{t("settings.privacy.vault_password")}<input
|
||||
type="password"
|
||||
bind:value={vaultPw}
|
||||
/></label
|
||||
>
|
||||
<div
|
||||
class="vault-card"
|
||||
class:locked={vault.enabled && !vault.unlocked}
|
||||
class:unlocked={vault.enabled && vault.unlocked}
|
||||
>
|
||||
<div class="vault-head">
|
||||
{#if !vault.enabled}
|
||||
<ShieldCheck size={18} aria-hidden="true" />
|
||||
{:else if !vault.unlocked}
|
||||
<Lock size={18} aria-hidden="true" />
|
||||
{:else}
|
||||
<LockOpen size={18} aria-hidden="true" />
|
||||
{/if}
|
||||
<h4>{t("settings.privacy.vault_title")}</h4>
|
||||
{#if vault.enabled}
|
||||
<span class="badge" class:busy={!vault.unlocked}>
|
||||
{vault.unlocked
|
||||
? t("settings.privacy.unlocked_word")
|
||||
: t("settings.privacy.locked_word")}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="primary" onclick={enableVault} disabled={vaultPw.length < 8}
|
||||
>{t("settings.privacy.enable_vault")}</button
|
||||
>
|
||||
<p class="muted">{t("settings.privacy.vault_pw_hint")}</p>
|
||||
{:else if !vault.unlocked}
|
||||
<p class="muted">
|
||||
{t("settings.privacy.vault_locked_1")}<strong
|
||||
>{t("settings.privacy.locked_word")}</strong
|
||||
>{t("settings.privacy.vault_locked_2")}
|
||||
</p>
|
||||
<div class="grid">
|
||||
<label class="wide"
|
||||
>{t("settings.privacy.password")}<input
|
||||
type="password"
|
||||
|
||||
{#if !vault.enabled}
|
||||
<p class="muted">{t("settings.privacy.vault_intro")}</p>
|
||||
<div class="pw-row">
|
||||
<input
|
||||
type={showVaultPw ? "text" : "password"}
|
||||
bind:value={vaultPw}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<button class="primary" onclick={unlockVault} disabled={!vaultPw}
|
||||
>{t("settings.privacy.unlock")}</button
|
||||
>
|
||||
{:else}
|
||||
<p class="muted">
|
||||
{t("settings.privacy.vault_unlocked_1")}<strong
|
||||
>{t("settings.privacy.unlocked_word")}</strong
|
||||
>{t("settings.privacy.vault_unlocked_2")}
|
||||
</p>
|
||||
<button onclick={lockVault}>{t("settings.privacy.lock_now")}</button>
|
||||
<details>
|
||||
<summary>{t("settings.privacy.change_password")}</summary>
|
||||
<div class="grid">
|
||||
<label class="wide"
|
||||
>{t("settings.privacy.current_password")}<input
|
||||
type="password"
|
||||
bind:value={vaultPw}
|
||||
/></label
|
||||
>
|
||||
<label class="wide"
|
||||
>{t("settings.privacy.new_password")}<input
|
||||
type="password"
|
||||
bind:value={vaultPw2}
|
||||
/></label
|
||||
placeholder={t("settings.privacy.vault_password")}
|
||||
aria-label={t("settings.privacy.vault_password")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon pw-toggle"
|
||||
onclick={() => (showVaultPw = !showVaultPw)}
|
||||
aria-label={t("settings.privacy.show_password")}
|
||||
title={t("settings.privacy.show_password")}
|
||||
>
|
||||
{#if showVaultPw}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>{/if}
|
||||
</button>
|
||||
</div>
|
||||
<button onclick={changeVaultPassword} disabled={!vaultPw || vaultPw2.length < 8}
|
||||
>{t("settings.privacy.change_password")}</button
|
||||
<button class="primary" onclick={enableVault} disabled={vaultPw.length < 8}
|
||||
>{t("settings.privacy.enable_vault")}</button
|
||||
>
|
||||
</details>
|
||||
{/if}
|
||||
{#if vaultMsg}<p class="muted">{vaultMsg}</p>{/if}
|
||||
<p class="muted hint">{t("settings.privacy.vault_pw_hint")}</p>
|
||||
{:else if !vault.unlocked}
|
||||
<p class="muted">
|
||||
{t("settings.privacy.vault_locked_1")}<strong
|
||||
>{t("settings.privacy.locked_word")}</strong
|
||||
>{t("settings.privacy.vault_locked_2")}
|
||||
</p>
|
||||
<div class="pw-row">
|
||||
<input
|
||||
type={showVaultPw ? "text" : "password"}
|
||||
bind:value={vaultPw}
|
||||
placeholder={t("settings.privacy.password")}
|
||||
aria-label={t("settings.privacy.password")}
|
||||
onkeydown={(e) => e.key === "Enter" && vaultPw && unlockVault()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon pw-toggle"
|
||||
onclick={() => (showVaultPw = !showVaultPw)}
|
||||
aria-label={t("settings.privacy.show_password")}
|
||||
title={t("settings.privacy.show_password")}
|
||||
>
|
||||
{#if showVaultPw}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>{/if}
|
||||
</button>
|
||||
</div>
|
||||
<button class="primary" onclick={unlockVault} disabled={!vaultPw}
|
||||
>{t("settings.privacy.unlock")}</button
|
||||
>
|
||||
{:else}
|
||||
<p class="muted">
|
||||
{t("settings.privacy.vault_unlocked_1")}<strong
|
||||
>{t("settings.privacy.unlocked_word")}</strong
|
||||
>{t("settings.privacy.vault_unlocked_2")}
|
||||
</p>
|
||||
<button onclick={lockVault}>
|
||||
<Lock size={14} aria-hidden="true" />
|
||||
{t("settings.privacy.lock_now")}
|
||||
</button>
|
||||
<details>
|
||||
<summary>{t("settings.privacy.change_password")}</summary>
|
||||
<div class="grid">
|
||||
<label class="wide"
|
||||
>{t("settings.privacy.current_password")}<input
|
||||
type="password"
|
||||
bind:value={vaultPw}
|
||||
/></label
|
||||
>
|
||||
<label class="wide"
|
||||
>{t("settings.privacy.new_password")}<input
|
||||
type="password"
|
||||
bind:value={vaultPw2}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<button onclick={changeVaultPassword} disabled={!vaultPw || vaultPw2.length < 8}
|
||||
>{t("settings.privacy.change_password")}</button
|
||||
>
|
||||
</details>
|
||||
{/if}
|
||||
{#if vaultMsg}<p class="vault-msg" class:error={vaultMsgError}>{vaultMsg}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if section === "language"}
|
||||
@@ -2121,6 +2443,33 @@
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* External-tool download chip — identical to the Import dialog's
|
||||
ffmpeg/yt-dlp buttons (ImportMeeting.svelte). */
|
||||
.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);
|
||||
}
|
||||
.tools .small {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.footnote {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
@@ -2329,6 +2678,12 @@
|
||||
gap: 0.4rem;
|
||||
flex: none;
|
||||
}
|
||||
/* Quantization picker (FR-MODEL-1) — a native select sized to the compact
|
||||
model row; the row's buttons/badges act on whichever variant is chosen. */
|
||||
.quant-select {
|
||||
font-size: 0.8rem;
|
||||
max-width: 13rem;
|
||||
}
|
||||
.icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2429,6 +2784,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,47 +770,54 @@
|
||||
{#if editableItems.length === 0}
|
||||
<p class="muted">{t("summary.ai_empty")}</p>
|
||||
{:else}
|
||||
<!-- Two-row card per item: the action text owns the full first row (it
|
||||
was unreadable when six controls shared one row in this narrow
|
||||
pane); owner/due/reminder are a secondary meta row beneath it. -->
|
||||
<ul class="action-items">
|
||||
{#each editableItems as item, i (i)}
|
||||
<li>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={item.confirmed}
|
||||
aria-label={t("summary.confirmed")}
|
||||
title={t("summary.confirmed")}
|
||||
/>
|
||||
<input
|
||||
class="ai-text"
|
||||
bind:value={item.text}
|
||||
placeholder={t("summary.ai_text_placeholder")}
|
||||
aria-label={t("summary.ai_text_aria")}
|
||||
/>
|
||||
<input
|
||||
class="ai-owner"
|
||||
value={item.owner ?? ""}
|
||||
oninput={(e) => (item.owner = (e.target as HTMLInputElement).value || null)}
|
||||
placeholder={t("summary.owner")}
|
||||
aria-label={t("summary.owner")}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
class="due-date"
|
||||
aria-label={t("summary.due_date")}
|
||||
value={dueDateInput(item.due_at)}
|
||||
onchange={(e) => onDueDateChange(item, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<label class="remind" title={t("summary.reminder_title")}>
|
||||
<input type="checkbox" bind:checked={item.reminder_set} disabled={!item.due_at} />
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</label>
|
||||
<button
|
||||
class="ai-del"
|
||||
onclick={() => removeActionItem(i)}
|
||||
title={t("summary.ai_delete_title")}
|
||||
aria-label={t("summary.ai_delete_aria")}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<div class="ai-main">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={item.confirmed}
|
||||
aria-label={t("summary.confirmed")}
|
||||
title={t("summary.confirmed")}
|
||||
/>
|
||||
<input
|
||||
class="ai-text"
|
||||
bind:value={item.text}
|
||||
placeholder={t("summary.ai_text_placeholder")}
|
||||
aria-label={t("summary.ai_text_aria")}
|
||||
/>
|
||||
<button
|
||||
class="ai-del"
|
||||
onclick={() => removeActionItem(i)}
|
||||
title={t("summary.ai_delete_title")}
|
||||
aria-label={t("summary.ai_delete_aria")}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="ai-meta">
|
||||
<input
|
||||
class="ai-owner"
|
||||
value={item.owner ?? ""}
|
||||
oninput={(e) => (item.owner = (e.target as HTMLInputElement).value || null)}
|
||||
placeholder={t("summary.owner")}
|
||||
aria-label={t("summary.owner")}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
class="due-date"
|
||||
aria-label={t("summary.due_date")}
|
||||
value={dueDateInput(item.due_at)}
|
||||
onchange={(e) => onDueDateChange(item, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<label class="remind" title={t("summary.reminder_title")}>
|
||||
<input type="checkbox" bind:checked={item.reminder_set} disabled={!item.due_at} />
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -1080,9 +1087,9 @@
|
||||
}
|
||||
ul.action-items li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0;
|
||||
padding: 0.45rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
ul.action-items label {
|
||||
@@ -1090,14 +1097,28 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.ai-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.ai-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
/* Meta row indented under the text (past the confirm checkbox), wrapping
|
||||
rather than crushing its inputs when the pane is narrow. */
|
||||
.ai-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding-left: 1.4rem;
|
||||
}
|
||||
.ai-owner {
|
||||
flex: none;
|
||||
width: 5rem;
|
||||
flex: 1;
|
||||
min-width: 5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ai-del {
|
||||
|
||||
@@ -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}
|
||||
@@ -524,7 +676,7 @@
|
||||
onclick={() => (selectedSegmentMs = open ? null : s.start_ms)}
|
||||
>
|
||||
<span class="ts">{fmtTs(s.start_ms)}</span>
|
||||
<strong>{speakerName(s.speaker)}:</strong>
|
||||
<strong>{speakerName(s.speaker, recording.speakers)}:</strong>
|
||||
{s.text}
|
||||
{#if hasNote}
|
||||
<span class="note-badge" title={t("transcript.has_note")}>📝</span>
|
||||
@@ -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