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 | ||
|
|
4f3af98bfa | ||
|
|
77f9a8b518 | ||
|
|
2a7e6229f4 | ||
|
|
64e7baad66 | ||
|
|
804d451bfa | ||
|
|
a29e60b47d | ||
|
|
969ed6022e | ||
|
|
226593b49d | ||
|
|
dc1d352e09 | ||
|
|
05e003ab19 | ||
|
|
d636eec848 | ||
|
|
1aeaae3550 | ||
|
|
84774c75cb | ||
|
|
bca790571c | ||
|
|
bfe7005368 | ||
|
|
e01d61ae0c | ||
|
|
8947eb2f39 | ||
|
|
d2ef12269b | ||
|
|
bab1eb57b1 | ||
|
|
76bb38cbe6 | ||
|
|
7570d810e4 | ||
|
|
a090cc4acd | ||
|
|
7f0a718210 | ||
|
|
96e8723cb9 | ||
|
|
fce3a6b96c | ||
|
|
0f8288f90f | ||
|
|
8ffc015e29 | ||
|
|
b8c58d6ab8 | ||
|
|
51a03b0ec4 | ||
|
|
96851157dd | ||
|
|
f05cec879f | ||
|
|
bf67be8764 | ||
|
|
c1de97938e | ||
|
|
5f300ee177 | ||
|
|
52d3fcbf3b | ||
|
|
2181aa5b0e | ||
|
|
4e87cab3ad | ||
|
|
84ab88b565 | ||
|
|
ae13944563 | ||
|
|
8fc5d72576 | ||
|
|
681933c8cf | ||
|
|
cfe73e7d80 | ||
|
|
505387ba49 | ||
|
|
fa8ce62f63 | ||
|
|
7527da92c5 | ||
|
|
ef933c3786 | ||
|
|
f6f336d79f | ||
|
|
1f25833e74 | ||
|
|
0d8bcf3b17 | ||
|
|
27a1f98051 | ||
|
|
0ecd6db12e | ||
|
|
d2f35c64b1 | ||
|
|
5967fd6997 | ||
|
|
a0ad1564a8 | ||
|
|
377a91d1b8 | ||
|
|
7796c5b62d | ||
|
|
698fb26b0a | ||
|
|
802e30e9c6 | ||
|
|
f1683af01c | ||
|
|
12388b8bd9 | ||
|
|
b295e1ee83 | ||
|
|
c98c14bbc7 | ||
|
|
c0b7587298 | ||
|
|
d5f0d028ac | ||
|
|
2f925de9a5 | ||
|
|
486767587e | ||
|
|
88969019b3 | ||
|
|
2b13de4ef2 | ||
|
|
a112b81d6e | ||
|
|
1da127a61c | ||
|
|
57d3aca386 | ||
|
|
aac88bfbc5 | ||
|
|
e5fdb2baea | ||
|
|
fc89f76ac8 | ||
|
|
7b8a651730 | ||
|
|
e1e2808d9c | ||
|
|
77234e697d | ||
|
|
f5c521df0d | ||
|
|
3f04ec6167 | ||
|
|
030b06329a | ||
|
|
a7af623753 | ||
|
|
b98c258c13 | ||
|
|
8ca6cf4f89 | ||
|
|
dbe845e923 | ||
|
|
d5aef2966f | ||
|
|
2389fb05a5 | ||
|
|
96d02cc220 | ||
|
|
c3da6cf0f3 | ||
|
|
5564c9c9a6 | ||
|
|
d25c297257 | ||
|
|
23cfca7ea2 | ||
|
|
6129866ac5 | ||
|
|
2e57ccfab5 | ||
|
|
4dff7a58b5 | ||
|
|
25204c0235 | ||
|
|
2b2e120f12 | ||
|
|
70eb182eb4 | ||
|
|
836547596b | ||
|
|
58215076cb | ||
|
|
862ab86860 | ||
|
|
a0faaa94b4 | ||
|
|
9c74f84c5b | ||
|
|
57cd46be46 | ||
|
|
490a960676 | ||
|
|
379bdf532f | ||
|
|
95f07e1dba | ||
|
|
931a2b78b4 | ||
|
|
89254bc070 | ||
|
|
22a37c5f4e | ||
|
|
cecfe20eec | ||
|
|
6fbf967fdb | ||
|
|
9f3c1b65d0 | ||
|
|
a4b2b9a292 | ||
|
|
12e23143f6 | ||
|
|
fd556c57a1 | ||
|
|
1ea8011dc8 | ||
|
|
002f8caa2b | ||
|
|
d72509df5f | ||
|
|
7ef45bb315 | ||
|
|
bd251b991a | ||
|
|
dfaeb7c873 | ||
|
|
6b164cd02a | ||
|
|
21101054a4 | ||
|
|
16200a130e | ||
|
|
86f115642c | ||
|
|
1760d4ae24 | ||
|
|
2681a5d8c8 | ||
|
|
233d641041 | ||
|
|
29589663b2 | ||
|
|
28058e205d | ||
|
|
235129d0d6 | ||
|
|
59c2d70643 | ||
|
|
e082f99f32 | ||
|
|
5260a4240b | ||
|
|
bf644ea0be | ||
|
|
13fb9984da | ||
|
|
99d50583cf | ||
|
|
0027becd10 | ||
|
|
e3c80b565c | ||
|
|
2a637e78ad | ||
|
|
80ad2a5f76 | ||
|
|
6622ae81ff | ||
|
|
90b2d51d08 | ||
|
|
7081040475 | ||
|
|
7dfcbdf247 | ||
|
|
864cd33a5f | ||
|
|
94eafd4fae | ||
|
|
45ddc2bd3e | ||
|
|
bb226dcb5e | ||
|
|
98b183d2b0 | ||
|
|
8828fca805 | ||
|
|
9057b25e92 | ||
|
|
41b5553836 | ||
|
|
538db4372f | ||
|
|
8906f8c8ed | ||
|
|
2f62e4b13a | ||
|
|
3214bd2740 | ||
|
|
e7b951cb90 | ||
|
|
a341afc52f | ||
|
|
83babc99cd | ||
|
|
4015b5132e | ||
|
|
b5f5ef172c | ||
|
|
69eedc9354 | ||
|
|
c9b5b91473 | ||
|
|
dca797aaa3 | ||
|
|
b532acbb3c | ||
|
|
469df7524f | ||
|
|
ec864c24f0 | ||
|
|
9f1fc671da | ||
|
|
3372dd7490 | ||
|
|
a32acba157 | ||
|
|
d9f4fd0960 | ||
|
|
ff74e91f69 | ||
|
|
e557d7599f | ||
|
|
678f8bee0f | ||
|
|
1cf0be1259 | ||
|
|
b14cba2a49 | ||
|
|
7df76dff00 | ||
|
|
a48550da1e | ||
|
|
ce4c54c93b | ||
|
|
aa5ae127b3 | ||
|
|
3d90265e8f | ||
|
|
d81b4726f9 | ||
|
|
ef6bb28f3e | ||
|
|
d407be8bb0 | ||
|
|
8a80580851 | ||
|
|
83cb30ef0e | ||
|
|
6991c1d1bc | ||
|
|
40709b257d | ||
|
|
4e33e7d84c | ||
|
|
e7cc433aff | ||
|
|
2cc48c9d4d | ||
|
|
be025bbd6c | ||
|
|
7ebd34ae08 | ||
|
|
353d582e96 | ||
|
|
7bff39271c | ||
|
|
e6e995efe0 | ||
|
|
2cdae9b20d | ||
|
|
dc969df792 | ||
|
|
489271964f | ||
|
|
4c00496c09 | ||
|
|
7490ff5db4 | ||
|
|
34fff3f7de | ||
|
|
7675e0467e | ||
|
|
75ca3df0a0 | ||
|
|
66d9eff2f9 | ||
|
|
1fb1da0946 | ||
|
|
496a8aa29b | ||
|
|
7f88101a79 | ||
|
|
37a6dd172d | ||
|
|
6bc0949e7e | ||
|
|
e966181ab8 | ||
|
|
56e739c6fe | ||
|
|
a68407f059 | ||
|
|
67fffb0203 | ||
|
|
a211f88ad4 | ||
|
|
b986c570f7 | ||
|
|
0a597f86f0 | ||
|
|
754f0f0b6a | ||
|
|
3cdb0abb2d | ||
|
|
11b175dafa | ||
|
|
d73af49742 | ||
|
|
e7925fc3ad | ||
|
|
c3e8bae27f | ||
|
|
2582d8947f | ||
|
|
ff3ced6874 | ||
|
|
57e757f01f | ||
|
|
f76cd46660 | ||
|
|
10edf053a9 | ||
|
|
23388f9305 | ||
|
|
42463d41f6 | ||
|
|
f5651c1133 | ||
|
|
0839d9c0b8 | ||
|
|
71824d44cc | ||
|
|
e424ecd8d3 | ||
|
|
1ade08a60e | ||
|
|
02fb7de6a8 | ||
|
|
70b4952366 | ||
|
|
ae0ef563e9 | ||
|
|
ec8b12f636 | ||
|
|
cd6997265f | ||
|
|
63bfc293cb | ||
|
|
4fc3df504a | ||
|
|
55b520d373 | ||
|
|
bd97032550 | ||
|
|
59742e4fdf | ||
|
|
8caeb818a7 | ||
|
|
71c4c6ecf0 | ||
|
|
386be75658 | ||
|
|
36a28dc096 | ||
|
|
07072551c0 | ||
|
|
e0112ec8c4 | ||
|
|
45e34284e9 | ||
|
|
0ecc72f18f | ||
|
|
f99845ef1b | ||
|
|
f85b78c9eb | ||
|
|
949bade137 | ||
|
|
eb2aa2a0d6 | ||
|
|
49cc1e6cd3 | ||
|
|
bd9b5ba3cc | ||
|
|
58d565907b | ||
|
|
5f690f4b74 | ||
|
|
851e89720d | ||
|
|
9eed88e5bd | ||
|
|
c48c67760d | ||
|
|
14868d4e5c | ||
|
|
fc93f8e14f | ||
|
|
c73245f293 | ||
|
|
e0668e48df | ||
|
|
999084ab3f | ||
|
|
c88c04233c | ||
|
|
7e1b7882eb | ||
|
|
18f46ea6c3 | ||
|
|
a7fda95843 | ||
|
|
d14a6766e5 | ||
|
|
3038b9d05d | ||
|
|
b40a7e2fbc | ||
|
|
106ffed786 | ||
|
|
23db4b6dac | ||
|
|
4cf5eb89b4 |
@@ -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/
|
||||
|
||||
@@ -21,6 +21,13 @@ Every memory operation in this session goes through MEMANTO. There is no excepti
|
||||
|
||||
These are not suggestions. Follow each one on every turn.
|
||||
|
||||
0. **Activate the `whispassist` agent at the start of every session, before any memory op.** Run
|
||||
`memanto agent activate whispassist` first thing. This machine hosts multiple projects and the
|
||||
session-start sync may activate a *different* project's agent (e.g. `whispassist`), so the
|
||||
auto-synced `MEMORY.md` can belong to the wrong project — do not trust it as LastERP context
|
||||
until you've activated `whispassist` and re-synced. Confirm with `memanto agent list` (the
|
||||
active one is marked). All `recall`/`remember`/`answer` calls read and write the *active*
|
||||
agent's store, so getting this wrong silently pollutes or mis-reads another project's memory.
|
||||
1. **Read `MEMORY.md` before doing anything.** It is auto-synced at session start and holds
|
||||
the user's preferences, facts, goals, instructions, decisions, and commitments from every
|
||||
prior session. You MUST honor what is written there. If you act against it, you are
|
||||
@@ -179,6 +186,56 @@ A task is done when: code compiles with no clippy warnings; the relevant require
|
||||
acceptance criteria are demonstrably met; tests named in `docs/06-test-strategy.md` for that
|
||||
phase pass; and docs are updated if behavior or contracts changed.
|
||||
|
||||
## Build & release (the unified installer)
|
||||
|
||||
Release builds produce **one universal installer** (as **two bundles**: MSI + NSIS) that
|
||||
covers every backend from a single command. The Vulkan whisper DLLs are compiled in and
|
||||
`vulkan-1.dll` is bundled as a resource; the NPU (OpenVINO) and DirectML runtimes are
|
||||
**downloaded on demand** at first use, so they don't need to be built or bundled. There is no
|
||||
separate "DirectML build" to merge — the one Vulkan-feature build *is* the universal installer.
|
||||
|
||||
**Version bump (4 files, keep them in lockstep):** `package.json`, `src-tauri/Cargo.toml`,
|
||||
`src-tauri/tauri.conf.json`, and the `whispassist` entry in `src-tauri/Cargo.lock`. Do **not**
|
||||
bump `package-lock.json` — its app version is intentionally `0.0.0` (its only version-looking
|
||||
`0.x` string is an unrelated dependency).
|
||||
|
||||
**Build command** — run from the repo root with the MSVC environment loaded, targeting the
|
||||
dedicated Vulkan build dir so the compile stays incremental (a clean build is much slower):
|
||||
|
||||
```sh
|
||||
call "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat"
|
||||
set CARGO_TARGET_DIR=C:\wt
|
||||
npm run tauri -- build --features vulkan --config src-tauri/tauri.vulkan.conf.json
|
||||
```
|
||||
|
||||
- `CARGO_TARGET_DIR=C:\wt` is a **persistent Vulkan-build cache**, kept separate from
|
||||
`src-tauri/target` so toggling the `vulkan` feature doesn't force a full rebuild. Reuse it;
|
||||
don't `cargo clean` unless you actually need a from-scratch build.
|
||||
- `tauri.vulkan.conf.json` only adds `vulkan-1.dll` to the bundle resources (that DLL lives at
|
||||
`src-tauri/vulkan-1.dll`); everything else comes from the base `tauri.conf.json`.
|
||||
- `vcvars64.bat` is required for the native deps (see the build-toolchain memory).
|
||||
|
||||
**Output** (under the `C:\wt` target dir), two bundles:
|
||||
|
||||
```
|
||||
C:\wt\release\bundle\msi\WhispAssist_<ver>_x64_en-US.msi (~44 MB)
|
||||
C:\wt\release\bundle\nsis\WhispAssist_<ver>_x64-setup.exe (~9 MB)
|
||||
```
|
||||
|
||||
**Checksums:** generate and verify SHA-256 sums for both bundles, and embed them in
|
||||
`RELEASE_NOTES_<ver>.md` (see the 0.4.0/0.5.0 notes for the format):
|
||||
|
||||
```sh
|
||||
cd C:\wt\release\bundle
|
||||
sha256sum msi/WhispAssist_<ver>_x64_en-US.msi nsis/WhispAssist_<ver>_x64-setup.exe | sed 's| \*| |' > SHA256SUMS_<ver>.txt
|
||||
sha256sum -c SHA256SUMS_<ver>.txt # must print OK for both
|
||||
```
|
||||
|
||||
The bundles and `SHA256SUMS` live in the `C:\wt` build dir, **outside the repo** — only
|
||||
`RELEASE_NOTES_<ver>.md` is committed. Bundles are currently **unsigned** (no signing cert in
|
||||
`tauri.conf.json`); the release notes carry the SmartScreen "More info → Run anyway" guidance.
|
||||
Tagging/pushing a release is outward-facing — do it only when explicitly asked.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not add telemetry, analytics, crash reporting, or auto-update phone-home without an ADR
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="bonus/clippy-says-hej.png" alt="WhispAssist logo with friendly Clippy saying hi there showing open source, private, note taker, and meeting advisor">
|
||||
</p>
|
||||
|
||||
# WhispAssist (WA)
|
||||
|
||||
**A privacy-first, Windows-native meeting assistant that runs entirely on-device.**
|
||||
@@ -7,18 +11,22 @@ on-device acceleration (**NPU → GPU → CPU**), labels speakers, structures th
|
||||
Markdown notes, and optionally augments them with a locally hosted LLM (Ollama). Audio and
|
||||
transcripts **never leave the machine** unless you explicitly configure a destination.
|
||||
|
||||
> **Status: working application (v0.2.0).** Capture (system audio **+ your microphone**),
|
||||
> transcription (CPU / Intel NPU / Vulkan GPU), speaker diarization, storage + crash recovery,
|
||||
> local-LLM summaries, AI tags, opt-in recording with **in-app playback**, at-rest encryption,
|
||||
> and self-hosted sync are implemented and ship as a single signed **MSI + NSIS** universal
|
||||
> installer. Outlook `.pst`/calendar context (recurring-event import + filtering) is landing;
|
||||
> the coding-agent (MCP) handoff is in progress. Build order and remaining tasks are in
|
||||
> [`docs/05-roadmap.md`](docs/05-roadmap.md).
|
||||
> **Status: working application (v0.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**, **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 |
|
||||
@@ -26,18 +34,23 @@ transcripts **never leave the machine** unless you explicitly configure a destin
|
||||
| **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` — in progress |
|
||||
| **Calendar / Outlook `.pst` context** | ✅ cloud calendar | ❌ | ✅ local `.pst` import + **auto-record on events** |
|
||||
| **Import an existing recording (file/URL)** | ❌ live capture only | ❌ | ✅ any file or URL (ffmpeg + yt-dlp) |
|
||||
| **Opt-in recording + consent notice** | ⚠️ partial | ❌ | ✅ off by default, one-time consent |
|
||||
| **At-rest encryption** | ☁️ server-side | ❌ | ✅ vault: Argon2id + XChaCha20-Poly1305 |
|
||||
| **Self-hosted sync w/ client-side encryption** | ❌ | ⚠️ | ✅ WebDAV + OAuth, **encrypt-before-upload** |
|
||||
| **Coding-agent (MCP) handoff** | ❌ | ❌ | ⚙️ local MCP server — in progress |
|
||||
| **Coding-agent (MCP) server** | ✅ **cloud** MCP (notes via their servers) | ❌ | ✅ **local, loopback-only**, token-gated, adds no egress |
|
||||
| **License** | Proprietary | Open source (MIT) | Open source (MIT / Apache-2.0) |
|
||||
| **Cost** | Subscription | Free | Free |
|
||||
|
||||
<sub>Comparison reflects each project's public positioning as of mid-2026. Granola and Meetily
|
||||
are independent products and their capabilities evolve — verify current details before relying
|
||||
on any row.</sub>
|
||||
<sub>Comparison reflects each project's public positioning as of mid-2026. Granola now ships an
|
||||
MCP server too, but it is **cloud-hosted** — an agent reaching it pulls your notes through
|
||||
Granola's servers; WhispAssist's MCP server is **loopback-only and adds no egress of its own**
|
||||
(data leaves only via the connected agent's own provider, which WA discloses). Granola and
|
||||
Meetily are independent products and their capabilities evolve — verify current details before
|
||||
relying on any row.</sub>
|
||||
|
||||
**The short version:** Granola is the polished cloud option (your audio and notes are processed
|
||||
on their servers). Meetily is the closest peer — open-source and self-hosted — but is
|
||||
@@ -46,19 +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.2.0)
|
||||
## What's built (v0.7.3)
|
||||
|
||||
- **Bot-free capture — now both sides** — WASAPI loopback records the system mix (all
|
||||
participants), and an optional **microphone** path captures your own voice, mixed into both the
|
||||
live transcript and the saved recording. Pick a specific output/input or turn the mic off in
|
||||
**Settings ▸ Hardware**. No meeting bot, no per-app plumbing.
|
||||
- **Local transcription with a hardware ladder** — whisper.cpp via `whisper-rs` on CPU; the
|
||||
**Intel NPU** via ONNX Runtime + OpenVINO; **GPU via Vulkan** (a single binary that runs on
|
||||
NVIDIA, AMD, and Intel). WA detects the hardware, picks the best backend
|
||||
(**NPU → NVIDIA → AMD → Intel → CPU**), streams partial transcripts live, and shows the active
|
||||
backend in the UI.
|
||||
- **Speaker diarization** — `sherpa-onnx` (pyannote segmentation + speaker-embedding
|
||||
clustering), fully offline.
|
||||
- **Bot-free capture — now both sides, with a live dual meter 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. 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.).
|
||||
@@ -70,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,
|
||||
@@ -80,12 +112,33 @@ coding-agent handoff.
|
||||
destination holds only ciphertext. Credentials live only in the OS credential store.
|
||||
- **Optional hosted AI** — Anthropic and OpenAI-compatible providers behind the same
|
||||
`LlmProvider` interface, off by default (third-party egress, keys in the OS credential store).
|
||||
- **One universal installer** — a single signed MSI and NSIS `-setup.exe` that covers every
|
||||
machine: Vulkan for all GPUs, the Intel NPU path, and CPU fallback. The Vulkan loader is bundled
|
||||
so it launches even on machines without a GPU driver.
|
||||
- **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 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
|
||||
own provider, which WA discloses.
|
||||
- **20 interface languages, with RTL** — the whole UI (shell, meetings list, transcript/notes,
|
||||
summary, and Settings) is available in **20 languages**, chosen under **Settings ▸ Language**:
|
||||
Arabic (العربية), Bengali (বাংলা), Simplified Chinese (中文简体), English, Finnish (Suomi),
|
||||
French — France & Canada (Français), German (Deutsch), Hindi (हिन्दी), Korean (한국어),
|
||||
Norwegian Bokmål (Norsk bokmål), Portuguese — Brazil & Portugal (Português), Russian (Русский),
|
||||
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 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:** Outlook `.pst` + calendar context, the local **MCP server** that hands meeting
|
||||
context to your coding agents (Claude, Codex, Copilot, OpenCode), and MS Graph calendar.
|
||||
**In progress:** Microsoft Graph calendar (cloud calendar via OAuth) and an optional CUDA
|
||||
(NVIDIA-only) build variant.
|
||||
|
||||
## Quick start (install)
|
||||
|
||||
@@ -96,22 +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. 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) — 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
|
||||
@@ -121,7 +191,7 @@ and its alternatives are recorded in [`docs/adr/`](docs/adr/) (ADR-0001–0011).
|
||||
- **Storage:** SQLite + on-disk audio/transcript files
|
||||
- **Local LLM:** Ollama HTTP API (localhost or a private LAN endpoint)
|
||||
- **Encryption:** Argon2id + XChaCha20-Poly1305 envelope vault; secrets in the OS credential store
|
||||
- **Calendar / Outlook:** `outlook-pst` for `.pst`, OS notifications for reminders *(in progress)*
|
||||
- **Calendar / Outlook:** `readpst` for `.pst` import, OS scheduled toasts for action-item reminders
|
||||
- **Sync:** WebDAV primary set + OneDrive/Dropbox/Box via OAuth — off by default (ADR-0010)
|
||||
- **External AI / agents:** hosted providers behind `LlmProvider`; a loopback-only **MCP server**
|
||||
for coding-agent handoff — off by default (ADR-0011)
|
||||
@@ -132,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}
|
||||
|
||||
@@ -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."
|
||||
|
Binary file not shown.
|
After Width: | Height: | Size: 728 KiB |
@@ -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
|
||||
|
||||
+93
-9
@@ -14,19 +14,61 @@ 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)
|
||||
├── notes.md # user-editable Markdown notes
|
||||
├── manual_notes.json # raw user-authored notes captured live during recording
|
||||
├── notes.md # the final notes document: manual notes + transcript, merged at finalize
|
||||
├── summary.json # LLM summary, decisions, action items (if generated)
|
||||
└── briefs/ # feature briefs distilled from this meeting (ADR-0011), if any
|
||||
└── <brief_id>.json # agent-ready spec served via the MCP `get_feature_brief` tool
|
||||
```
|
||||
|
||||
A **bundle export** (`export_meeting` / `bulk_export_meetings` with `format: "bundle"`, FR-STORE-4)
|
||||
copies a meeting's `audio.wav`, `transcript.json`, `notes.md`, and `summary.json` (all decrypted)
|
||||
into a destination folder plus a `meeting.json` manifest (the `MeetingBundle`: title, timestamps,
|
||||
duration, language/backend/model, tags, speakers, and confirmed action items). `import_meeting_bundle`
|
||||
reconstructs each such folder under a fresh meeting id — the portable format for moving recordings
|
||||
between computers.
|
||||
|
||||
Rule: while a meeting is in progress a working WAV is the source of truth for crash recovery. On
|
||||
finalize, it is **kept** as `audio.wav` if "Record this meeting" was on, or **deleted** if not
|
||||
(FR-REC-1/4) — deletion happens only after `transcript.json` is finalized. `transcript.json`,
|
||||
`notes.md`, and `summary.json` are **derived** and regenerable (regenerable only while the audio
|
||||
still exists — i.e. for recorded meetings).
|
||||
(FR-REC-1/4) — deletion happens only after `transcript.json` is finalized. `transcript.json` and
|
||||
`summary.json` are **derived** and regenerable (regenerable only while the audio still exists —
|
||||
i.e. for recorded meetings).
|
||||
|
||||
`notes.md` is **generated once, at finalize**, by merging `manual_notes.json` (freeform notes
|
||||
typed live during the recording, plus any per-moment annotations — see below) with the rendered,
|
||||
speaker-tagged transcript (`notes::MarkdownNotes::merge`). After that it is the user's own
|
||||
document, freely editable via `update_notes` exactly like before this changed — nothing
|
||||
re-renders or overwrites it afterward. In particular, renaming or merging a speaker after finalize
|
||||
updates the `speakers` table and the live UI display, but does **not** retroactively rewrite text
|
||||
already baked into `notes.md` (same as any other manual edit isn't retroactively touched either —
|
||||
this was a pre-existing clobber bug this redesign also fixes: renaming a speaker used to silently
|
||||
overwrite the whole file). A crash-recovery finalize (T2.8) and a post-finalize batch
|
||||
re-transcription (T3.8) both re-render `notes.md` from scratch and so both re-read
|
||||
`manual_notes.json` from disk to fold the same manual notes back in.
|
||||
|
||||
### `manual_notes.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema": 1,
|
||||
"freeform_md": "string — the user's running notes, typed live in the Notes pane while recording",
|
||||
"segment_notes": [
|
||||
{ "anchor_ms": 12345, "text": "string", "created_at": 1735000000, "updated_at": 1735000010 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`segment_notes[].anchor_ms` is a timestamp into the recording (a clicked transcript segment's
|
||||
`start_ms`), not a segment id — a later batch re-transcription can renumber/regenerate segment
|
||||
ids, but never moves the moment in time a note was attached to. At merge time, each note is placed
|
||||
right after whichever transcript paragraph's time span contains its `anchor_ms`; a note whose
|
||||
anchor doesn't land inside any paragraph surfaces under an "Other notes" section instead of being
|
||||
silently dropped. Written to disk on every edit via `update_live_notes`/`set_segment_note`
|
||||
(`04-api-contracts.md`) — live-session only, same write-through-for-crash-safety spirit as
|
||||
`transcript.json` accumulating during recording.
|
||||
|
||||
## SQLite schema (`wa.db`)
|
||||
|
||||
@@ -45,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
|
||||
@@ -267,13 +310,22 @@ label so re-diarization or renaming never requires rewriting every segment (FR-S
|
||||
## `briefs/<brief_id>.json` (feature brief — ADR-0011)
|
||||
|
||||
Agent-ready spec the MCP `get_feature_brief` tool returns. Designed to drop straight into a coding
|
||||
agent's context.
|
||||
agent's context. Written by `create_feature_brief` (M1); one file per brief under the meeting's
|
||||
`briefs/` folder, **sealed at rest with the vault** when unlocked (T8.8), exactly like
|
||||
`summary.json`. The `feature_briefs` table indexes it (id, meeting_id, title, target_repo, path,
|
||||
exposed) for `list_feature_briefs` and the MCP scope check — the file is the source of truth; the
|
||||
row is the index. The IPC `FeatureBrief` type (`04-api-contracts.md`) is the subset returned to the
|
||||
UI / MCP: everything below **except** the `schema`/provenance envelope (`generated_at`, `provider`,
|
||||
`model`, `source`).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema": 1,
|
||||
"id": "b7a1…",
|
||||
"meeting_id": "f1c2…",
|
||||
"generated_at": 1751299200, // envelope: which model distilled this, and when
|
||||
"provider": "ollama",
|
||||
"model": "llama3",
|
||||
"title": "Bulk CSV export for the reporting view",
|
||||
"problem": "Customer can't get their data out for offline analysis.",
|
||||
"desired_outcome": "One-click CSV export of the current filtered report.",
|
||||
@@ -282,15 +334,21 @@ agent's context.
|
||||
"Respects active filters and column order",
|
||||
"Streams large exports without blocking the UI",
|
||||
],
|
||||
"target_repo": "acme/reporting-web", // optional hint for the agent
|
||||
"target_repo": "acme/reporting-web", // optional hint the user supplies at create time
|
||||
"context_excerpts": [
|
||||
// minimal transcript quotes that ground the request
|
||||
// verbatim transcript quotes that ground the request — NOT model paraphrase;
|
||||
// the builder selects them from the real transcript (speaker = resolved display name)
|
||||
{ "speaker": "Customer", "text": "We really need to pull this into our own spreadsheets." },
|
||||
],
|
||||
"source": { "meeting_title": "Acme quarterly sync", "at": 1751299200 },
|
||||
}
|
||||
```
|
||||
|
||||
Field presence: `title`, `problem`, `desired_outcome` are always strings (the builder falls back to
|
||||
the meeting title / `""` on a sparse model reply); `acceptance_criteria` and `context_excerpts` may
|
||||
be empty arrays. Every `context_excerpts[].text` is a verbatim substring of a real transcript
|
||||
segment (the M1 grounding invariant, asserted by the golden-transcript test).
|
||||
|
||||
## `settings.json`
|
||||
|
||||
```jsonc
|
||||
@@ -305,11 +363,12 @@ agent's context.
|
||||
"consent_acknowledged": false, // set true after the one-time consent notice (FR-REC-2)
|
||||
},
|
||||
"llm": {
|
||||
"provider": "ollama", // ollama|custom|anthropic|openai|off (ADR-0007/0011)
|
||||
"provider": "ollama", // ollama|custom|anthropic|openai|off (ADR-0007/0011; "openai" not yet wired)
|
||||
"endpoint": "http://localhost:11434",
|
||||
"model": "llama3",
|
||||
"stream": true,
|
||||
// API keys for hosted providers (anthropic|openai) live in the OS credential store, not here.
|
||||
"hosted_ai_acknowledged": false, // one-time "data leaves your device" notice ack (T10.3, ADR-0011)
|
||||
},
|
||||
// Sync target rows live in wa.db (sync_targets); secrets live in the OS credential store.
|
||||
// settings.json only holds the global default. No credentials here (FR-SYNC-6).
|
||||
@@ -323,9 +382,34 @@ agent's context.
|
||||
"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).
|
||||
"calendar": { "graph_enabled": false, "graph_credential_ref": null },
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
@@ -15,23 +15,51 @@ each command returns `Result<T, WaError>` where `WaError` carries a `kind` (mach
|
||||
// `record` (default false) controls audio RETENTION (ADR-0009). When false, working audio is
|
||||
// deleted on finalize and only the transcript/notes persist. It can be toggled mid-meeting.
|
||||
// templateId (Phase 8, T8.1, FR-NOTE-5) picks a NoteTemplate — see list_note_templates below.
|
||||
start_recording(input: { meetingTitle?: string; calendarEventId?: string; record?: boolean; templateId?: string }): MeetingId
|
||||
// language (T8.7, FR-TRX-4, M4.2): omitted/"auto" requests auto-detection; an ISO-639-1 code
|
||||
// (e.g. "es") forces that language. Falls back to Settings.whisper_language when omitted.
|
||||
// Only takes effect with a multilingual model loaded (ModelInfo.multilingual) — an English-only
|
||||
// model forces "en" regardless (see resolve_language in src-tauri/src/transcription/mod.rs).
|
||||
start_recording(input: { meetingTitle?: string; calendarEventId?: string; record?: boolean; templateId?: string; language?: string }): MeetingId
|
||||
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)
|
||||
|
||||
// ---- Live notes (Granola-style redesign, `03-data-model.md`'s manual_notes.json) ----
|
||||
// Both are live-session only (err "no matching active recording" once finalized — post-finalize,
|
||||
// notes.md is the single editable document and `update_notes` is the command for it).
|
||||
update_live_notes(input: { meetingId: MeetingId; markdown: string }): void // freeform notes typed while recording
|
||||
// anchorMs: the clicked transcript segment's start_ms, not its id (survives re-transcription).
|
||||
// text: "" clears that moment's note. Merged into notes.md right after the transcript paragraph
|
||||
// covering anchorMs when the meeting finalizes (notes::MarkdownNotes::merge).
|
||||
set_segment_note(input: { meetingId: MeetingId; anchorMs: number; text: string }): void
|
||||
|
||||
// ---- Hardware ----
|
||||
hardware_status(): { backends: BackendInfo[]; active: BackendId; modelSize: string; estRtf: number }
|
||||
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 ----
|
||||
reprocess_transcript(input: { meetingId: MeetingId; model: string }): void // batch mode (FR-TRX-3)
|
||||
// language (T8.7, M4.2): omitted reuses the meeting's current language rather than resetting it.
|
||||
reprocess_transcript(input: { meetingId: MeetingId; model: string; language?: string }): void // batch mode (FR-TRX-3)
|
||||
// ModelInfo gained `multilingual: boolean` (T8.7, FR-TRX-4, M4.2) — false for `.en` (English-only)
|
||||
// ggml variants, true for the multilingual ones; gates the Settings language picker.
|
||||
list_models(): ModelInfo[]
|
||||
list_diarization_models(): ModelInfo[] // fixed seg+emb pair (T4.7, FR-MODEL-1)
|
||||
download_model(input: { kind: "whisper" | "diar-seg" | "diar-emb"; id: string }): void // emits progress events
|
||||
remove_model(input: { id: string }): void // disambiguated by id, not kind — ids never collide across catalogs
|
||||
// Static catalog of whisper.cpp-recognized ISO-639-1 codes for the Settings language dropdown
|
||||
// (T8.7, FR-TRX-4, M4.2); "Auto-detect" is a frontend-only addition, not in this list.
|
||||
list_whisper_languages(): { code: string; label: string }[]
|
||||
|
||||
// ---- Speakers ----
|
||||
rename_speaker(input: { meetingId: MeetingId; label: string; name: string }): void
|
||||
@@ -44,10 +72,24 @@ map_speaker_to_participant(input: { meetingId: MeetingId; label: string; partici
|
||||
// yet at local-desktop meeting counts) and gained from/to date filters, per
|
||||
// FR-SEARCH-2's "filter by date, tag, or participant".
|
||||
list_meetings(input: { query?: string; tag?: string; participantId?: string; from?: number; to?: number }): MeetingListItem[]
|
||||
// Meeting also carries `action_items: ActionItem[]` — table-backed (the confirmed/edited source
|
||||
// of truth), falling back to summary.json drafts until any are saved (FR-LLM-3). TranscriptSegment
|
||||
// keeps start_ms/end_ms; the UI renders these as per-line timestamps.
|
||||
get_meeting(input: { meetingId: MeetingId }): Meeting // includes transcript + speakers + summary (null until generated)
|
||||
delete_meeting(input: { meetingId: MeetingId }): void
|
||||
export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" }): string
|
||||
// format "bundle" writes a portable folder: audio.wav + transcript.json + notes.md + summary.json
|
||||
// (all decrypted) + meeting.json (the MeetingBundle manifest), re-importable on another machine.
|
||||
// format "obsidian" writes one self-contained vault note (dest is a .md file path, no audio):
|
||||
// YAML frontmatter (title/date/duration/participants/tags/source) + notes + summary + decisions
|
||||
// + action items + timestamped transcript. For dropping a meeting into an Obsidian vault.
|
||||
export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" | "obsidian" }): string
|
||||
// Edit commands that change an uploaded artifact — update_notes, set_tags, generate_summary,
|
||||
// reprocess_transcript, confirm_action_items — auto-resync when sync is enabled (FR-SYNC-5):
|
||||
// they enqueue for finalize-trigger targets and pump in the background. SHA-256 dedup means an
|
||||
// edit that didn't alter a file uploads nothing.
|
||||
update_notes(input: { meetingId: MeetingId; markdown: string }): void
|
||||
// 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
|
||||
@@ -58,6 +100,17 @@ list_note_templates(): NoteTemplate[]
|
||||
// Bulk export (T8.5, FR-STORE-4): every meeting matching tag/from/to, one file (or bundle
|
||||
// folder) per meeting under destDir. Returns the count actually exported.
|
||||
bulk_export_meetings(input: { destDir: string; format: "md" | "pdf" | "docx" | "bundle"; tag?: string; from?: number; to?: number }): number
|
||||
// Import bundle(s) (FR-STORE-4): `dir` is a single bundle folder (has meeting.json) or a parent
|
||||
// folder of them (from a bulk export). Each is reconstructed under a fresh meeting id (original
|
||||
// title/date/duration/speakers/tags/action items preserved). Returns the count imported.
|
||||
import_meeting_bundle(input: { dir: string }): number
|
||||
// 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
|
||||
@@ -73,10 +126,27 @@ llm_setup_suggestions(): { ollamaInstalled: boolean; installUrl: string; suggest
|
||||
pull_ollama_model(input: { model: string }): void // guided download via Ollama's own /api/pull; emits model://progress (T5.7)
|
||||
|
||||
// ---- Calendar / .pst ----
|
||||
import_pst(input: { path: string; password?: string }): number // eventsImported; emits pst://progress (FR-CAL-1)
|
||||
// rangeDays: only import events starting within the last N days; omitted imports the full mailbox
|
||||
// history. Bug fix: a long-lived .pst has no natural upper bound on history (every recurring
|
||||
// series expands to its cap, T4.1's RECURRENCE_MAX_OCCURRENCES, plus every one-off entry the file
|
||||
// ever held, e.g. a decade of Outlook's auto-generated yearly holidays) -- unbounded import could
|
||||
// produce tens of thousands of rows. Settings.pst_import_range_days persists the last choice and
|
||||
// applies it to pst_auto_sync's startup re-import too.
|
||||
import_pst(input: { path: string; password?: string; rangeDays?: number }): number // eventsImported; emits pst://progress (FR-CAL-1)
|
||||
list_calendar_events(input: { from?: number; to?: number }): CalendarEvent[]
|
||||
get_calendar_event(input: { eventId: string }): { event: CalendarEvent; participants: Participant[] } // pre-meeting panel + naming dropdown (FR-CAL-3, FR-SPK-4)
|
||||
// olderThanDays omitted deletes every unlinked event ("Delete all"); Some(n) only those starting
|
||||
// more than n days ago. An event attached to a recorded meeting (meetings.calendar_event_id) is
|
||||
// always kept regardless of the choice -- protected reports how many were skipped for that reason.
|
||||
cleanup_calendar_events(input: { olderThanDays?: number }): { deleted: number; protected: number }
|
||||
attach_meeting_to_event(input: { meetingId: MeetingId; eventId: string }): void
|
||||
// Optional MS Graph calendar source (M4.4, T8.9, FR-CAL-6): opt-in, explicit consent (OAuth PKCE
|
||||
// + Microsoft's own consent screen), metadata-only (subject/organizer/start/end/attendees, never
|
||||
// the event body). Not a SyncTarget — begin_graph_calendar_link stores its token separately from
|
||||
// sync_targets and never appears in list_sync_targets.
|
||||
begin_graph_calendar_link(): { authUrl: string } // opens in browser; emits calendar://linked when done
|
||||
import_graph_calendar(input: { from?: number; to?: number }): number // eventsImported; emits calendar://progress
|
||||
disconnect_graph_calendar(): void // best-effort credential cleanup + settings reset
|
||||
|
||||
// ---- Sync / upload (ADR-0010) ---- secrets are passed to add/update but stored only in the OS
|
||||
// credential store; they are NEVER returned by list_sync_targets.
|
||||
@@ -106,6 +176,9 @@ run_agent(input: { briefId: string; tool: "claude" | "codex" | "opencode" | "cop
|
||||
create_issue_from_brief(input: { briefId: string; tracker: "github"; assignCopilot?: boolean }): { url: string } // FR-AGENT-2
|
||||
|
||||
// ---- Settings ----
|
||||
// Settings gained `whisper_language: string | null` (T8.7, FR-TRX-4, M4.2) — the default
|
||||
// transcription language applied at the next start_recording; null = auto-detect. Mirrors
|
||||
// this doc's settings.json `transcription.language` (03-data-model.md).
|
||||
get_settings(): Settings
|
||||
update_settings(input: Partial<Settings>): Settings
|
||||
// Reports the full egress allowlist so the UI can prove exactly what may leave the device (FR-SEC-2).
|
||||
@@ -128,14 +201,20 @@ 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 }
|
||||
"pst://progress" { processed, total }
|
||||
"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 }
|
||||
@@ -205,9 +284,12 @@ pub trait Diarizer: Send + Sync {
|
||||
}
|
||||
|
||||
// calendar/mod.rs
|
||||
// `attendees()` (a second, separate trait method in the original design) was dropped — every
|
||||
// source (PstSource, GraphSource) lists attendees inline per-appointment, so import() returns
|
||||
// them together (see ADR-0008's update). CalImport gained `from`/`to` (M4.4) for a source that
|
||||
// fetches by date range (Graph's calendarView); PstSource ignores them.
|
||||
pub trait CalendarSource: Send + Sync {
|
||||
fn import(&self, input: CalImport) -> Result<Vec<CalendarEvent>, CalError>; // pst|graph|ics
|
||||
fn attendees(&self, event_id: &str) -> Result<Vec<Participant>, CalError>;
|
||||
fn import(&self, input: CalImport) -> Result<Vec<ImportedEvent>, CalError>; // pst|graph|ics
|
||||
}
|
||||
|
||||
// notes/mod.rs
|
||||
@@ -215,6 +297,9 @@ pub trait NotesRenderer: Send + Sync {
|
||||
fn to_markdown(&self, t: &Transcript, speakers: &[SpeakerInfo], s: Option<&Summary>) -> String;
|
||||
fn export(&self, md: &str, dest: &Path, fmt: ExportFormat) -> Result<PathBuf, NotesError>;
|
||||
}
|
||||
// MarkdownNotes::merge(segments, speakers, manual: &ManualNotes, summary, template) -> String is an
|
||||
// inherent method (not part of the trait — only one renderer needs it): what `stop_recording` calls
|
||||
// instead of `to_markdown` to fold manual_notes.json into the generated notes.md (see 03-data-model.md).
|
||||
|
||||
// sync/mod.rs
|
||||
// One impl per provider; `WebDavTarget` covers Nextcloud/ownCloud/Cloudreve/Seafile/Synology.
|
||||
|
||||
@@ -235,3 +235,152 @@ P10 needs P5; 10b (MCP) is the priority; 10c (push/issue) is later and optional.
|
||||
## Suggested first milestone (thin vertical slice)
|
||||
T1.1 → T1.2 → T1.5 → T1.6 → T2.1 → T2.2 → T2.4 gives a usable "record → live transcript → saved
|
||||
Markdown notes" loop — the smallest thing worth dogfooding.
|
||||
|
||||
---
|
||||
|
||||
## Remaining work — post-v0.2.0 execution plan
|
||||
|
||||
As of **v0.2.0**, Phases 1–9 ship (capture incl. microphone, CPU/NPU/Vulkan transcription,
|
||||
diarization, storage/recovery, notes, local-LLM summaries, PST calendar, UX/a11y,
|
||||
templates/search/tags/export/reminders/encryption, WebDAV + OneDrive sync) in a single universal
|
||||
installer. What remains is **Phase 10 (external AI & agent handoff, ADR-0011)** plus a few
|
||||
breadth/reliability items. This section sequences that work by leverage, dependency, and the
|
||||
privacy invariant. Task IDs reference the Phase 10 list above; finer sub-tasks add a letter suffix.
|
||||
Traits/contracts for all of this already exist in `04-api-contracts.md` (`FeatureBriefBuilder`,
|
||||
`McpServer`, hosted `LlmProvider` impls, `AgentRunner`, `IssueTracker`); tests live in
|
||||
`06-test-strategy.md` (P10 + the cross-cutting egress gate).
|
||||
|
||||
**Status snapshot (what's actually in the tree):**
|
||||
- **Built:** Phases 1–9; `OpenAiCompatProvider` (used as the Phase 5 "custom" endpoint); chunked/
|
||||
resumable upload (M4.1); multi-language transcription (M4.2); `DropboxTarget`/`BoxTarget` (M4.3);
|
||||
MS Graph `CalendarSource` (`GraphSource`, M4.4).
|
||||
- **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
|
||||
"isn't built yet").
|
||||
|
||||
**Sequence:** M1 → M2 (briefs are the MCP payload); **M3 can run in parallel** with M1/M2; M4 is
|
||||
opportunistic; **M5 is last and optional**.
|
||||
|
||||
### M1 — Feature briefs (do first; standalone value) → FR-MCP-4 (T10.6)
|
||||
Needs only the already-built LLM, delivers value before any MCP transport (view/copy a brief), and
|
||||
is the exact payload M2 serves. **No new egress** (uses the configured `LlmProvider`).
|
||||
|
||||
**Already scaffolded — do NOT re-create:** IPC types `FeatureBrief`/`FeatureBriefInfo`/
|
||||
`ContextExcerpt` (`models.rs`); `api.ts` bindings `createFeatureBrief`/`listFeatureBriefs`/
|
||||
`getFeatureBrief`/`setBriefExposed`; the four commands registered in `lib.rs`; DB tables
|
||||
`feature_briefs` + `mcp_access_log` (`migrations/0003_ai_mcp.sql`); the `briefs/<id>.json` schema
|
||||
(`03-data-model.md`) and the `FeatureBriefBuilder` trait (`04-api-contracts.md`). The four command
|
||||
bodies today return `Err(not_implemented(...))` — M1 fills them in.
|
||||
|
||||
- `[M1.1]` **Storage methods** (`Store` trait + `SqliteStore`, `storage/mod.rs`) over
|
||||
`feature_briefs`; row struct `FeatureBriefRow { id, meeting_id, title, target_repo: Option<String>,
|
||||
path, exposed: bool, created_at }`. Deletion cascades via the meeting FK (existing `delete_meeting`
|
||||
already drops the row + folder). **S**
|
||||
- `async fn insert_feature_brief(&self, row: FeatureBriefRow) -> Result<(), StoreError>;`
|
||||
- `async fn list_feature_briefs(&self, meeting_id: Option<&MeetingId>) -> Result<Vec<FeatureBriefInfo>, StoreError>;` (newest first)
|
||||
- `async fn get_feature_brief_row(&self, id: &str) -> Result<FeatureBriefRow, StoreError>;` (resolves `path`)
|
||||
- `async fn set_brief_exposed(&self, id: &str, exposed: bool) -> Result<(), StoreError>;`
|
||||
- `[M1.2]` **LLM completion primitive** (`llm/mod.rs`) — one non-streaming method on `LlmProvider`
|
||||
(mirrors `suggest_tags`), implemented for `OllamaProvider` + `OpenAiCompatProvider` now (Anthropic
|
||||
lands in M3): `async fn complete(&self, system: &str, user: &str) -> Result<String, LlmError>;`. **S**
|
||||
- `[M1.3]` **`FeatureBriefBuilder`** (new `briefs` module) — the distiller. **M**
|
||||
- *Prompt contract* (system, reuse the `RESPONSE_FORMAT_INSTRUCTIONS` pattern): "Distill this
|
||||
transcript into an implementation brief for a coding agent. Respond in Markdown with exactly, in
|
||||
order: `## Title` (one line), `## Problem`, `## Desired Outcome`, `## Acceptance Criteria` (a
|
||||
`- ` bullet list, one testable criterion per line). Be concrete and terse; invent nothing not in
|
||||
the transcript; no other sections." *User*: `build_prompt`-style metadata + optional `target_repo`
|
||||
hint + `"Transcript:\n" + transcript`.
|
||||
- *Parser* `parse_brief(md) -> BriefFields { title, problem, desired_outcome, acceptance_criteria }`
|
||||
— mirror `parse_summary`/`bullet_text`; missing section → `""`/`[]`; title fallback = meeting title.
|
||||
- *`context_excerpts` (grounding, verbatim)*: tokenize `problem + acceptance_criteria`, score each
|
||||
transcript segment by keyword overlap, take the top ≤5 of length ≥ ~40 chars (fallback: first 3
|
||||
substantive segments); `speaker` = resolved display name. `// ponytail: keyword-overlap select;
|
||||
upgrade to embedding similarity if excerpts feel off`.
|
||||
- `[M1.4]` **Command bodies** (`commands.rs`, replace the four stubs; keep names/args so `api.ts` is
|
||||
unchanged — add `state: State<'_, AppState>`, which Tauri injects). **M**
|
||||
- `create_feature_brief(state, meeting_id: MeetingId, target_repo: Option<String>) -> WaResult<FeatureBrief>`
|
||||
— reject the currently-recording meeting; `load_settings()` + `llm_provider_from_settings()`
|
||||
(error if none); load transcript via `state.store`; run the builder; assemble `BriefFile { schema:
|
||||
1, id, meeting_id, generated_at, provider, model, …fields, source }`; `vault::seal` → write
|
||||
`briefs/<id>.json` under `meeting_dir`; `store.insert_feature_brief(row)` (`exposed=false`);
|
||||
return the IPC `FeatureBrief`. **Write the file + row only after a successful distill** (no partial
|
||||
artifacts on LLM failure).
|
||||
- `list_feature_briefs(state, meeting_id: Option<MeetingId>) -> WaResult<Vec<FeatureBriefInfo>>`
|
||||
- `get_feature_brief(state, id: String) -> WaResult<FeatureBrief>` (row → `vault::open(path)` → subset)
|
||||
- `set_brief_exposed(state, id: String, exposed: bool) -> WaResult<()>`
|
||||
- `[M1.5]` **UI** (`SummaryPanel.svelte` or a new "Feature briefs" block; existing bindings, no new
|
||||
client code): "Create feature brief" button + optional target-repo input; list via
|
||||
`listFeatureBriefs(meetingId)`; viewer with **copy-as-Markdown** and **copy-as-JSON**; `exposed`
|
||||
toggle wired to `setBriefExposed` but shown as "available when the MCP server is on" until M2. **M**
|
||||
- `[M1.6]` **Tests — golden transcript** (see `06-test-strategy.md` P10): `parse_brief` unit
|
||||
(golden reply → fields; empty/malformed → empty, no panic); builder over a golden transcript with a
|
||||
`MockLlmProvider` (no network) asserting the fields, non-empty `acceptance_criteria`, and the
|
||||
**grounding invariant** (every excerpt text is a verbatim substring of a transcript segment);
|
||||
command-level: LLM off/unreachable → `Err`, nothing written. **S**
|
||||
|
||||
- **Acceptance (M1 done):** on a finished meeting with an LLM configured, "Create feature brief"
|
||||
produces a schema-valid, vault-sealed `briefs/<id>.json` + a `feature_briefs` row that lists and
|
||||
re-opens; excerpts are verbatim from the transcript; with the LLM off/unreachable the command errors
|
||||
cleanly and writes nothing partial; the golden-transcript unit tests pass. No new egress; `exposed`
|
||||
defaults off.
|
||||
|
||||
### M2 — MCP server (the differentiator) → FR-MCP-1/2/3/5/6/7 (T10.4/10.5/10.7/10.8/10.9)
|
||||
The unique capability (meeting → coding-agent handoff) and a privacy *reinforcement*: inbound on
|
||||
loopback, **zero added egress**.
|
||||
- `[M2.1]` `mcp` module on `rmcp`: loopback bind + token gate; Streamable HTTP (`/mcp`) + stdio
|
||||
adapter; implement `McpServer::start/stop` (replace `todo!()`). **L** → FR-MCP-1/6, NFR-SEC-5
|
||||
- `[M2.2]` Tools-first surface: `list_recent_meetings`, `get_transcript`, `get_action_items`,
|
||||
`get_feature_brief`. **M** → FR-MCP-2
|
||||
- `[M2.3]` Scope control `set_mcp_scope(none|selected|all)`; recordings never served unless
|
||||
explicitly allowed; enforce in every tool handler. **M** → FR-MCP-3
|
||||
- `[M2.4]` Lifecycle: `mcp_status`/`set_mcp_enabled` (replace stubs); persist enabled/scope in
|
||||
settings, **token in the OS credential store** (never `settings.json`). **M** → FR-MCP-1
|
||||
- `[M2.5]` Disclosure UI ("connected agents may forward data") + `mcp_access_log` rows /
|
||||
`mcp://access` events on every tool read. **M** → FR-MCP-5
|
||||
- `[M2.6]` Privacy panel shows MCP state and confirms it adds no egress; extend
|
||||
`privacy_self_check`. **S** → FR-MCP-7, FR-SEC-2
|
||||
- **Acceptance:** an in-process MCP client gets a schema-valid brief; server binds loopback only
|
||||
(assert a non-loopback bind is refused) and requires a token; **the egress test is unchanged with
|
||||
MCP on** (merge blocker, FR-MCP-7); recordings not served unless allowed; every read logged.
|
||||
- **Depends on:** M1 (a served tool). Guardrail: the cross-cutting egress gate must stay green.
|
||||
|
||||
### M3 — Hosted AI providers (finish 10a) → FR-AI-1/2/3 (T10.1/10.2/10.3)
|
||||
Commodity but low-effort (OpenAI-compat already exists); gives a cloud-summary choice and pairs with
|
||||
M1 (a brief can be distilled by a hosted model). Adds allowlisted egress **by design**.
|
||||
- `[M3.1]` Implement `AnthropicProvider` (`/v1/messages`, `x-api-key`, streaming) behind
|
||||
`LlmProvider`; `is_local() == false`. **M** → FR-AI-1
|
||||
- `[M3.2]` `set_llm_provider`: store the API key in the OS credential store via `credential_ref`
|
||||
(never settings/DB); add the host to the settings-derived egress allowlist. **M** → FR-AI-2, NFR-SEC-4
|
||||
- `[M3.3]` Third-party "data leaves your device" banner + one-time acknowledgment; per-use provider
|
||||
selection + active-provider display wherever a summary is generated. **S** → FR-AI-2/3
|
||||
- **Acceptance:** summaries route to mocked OpenAI-compat *and* Anthropic endpoints with correct
|
||||
shapes; key read only from the credential store; host on the allowlist only when configured; banner
|
||||
shown before first hosted use.
|
||||
|
||||
### M4 — Reliability & breadth (opportunistic, by value)
|
||||
- `[M4.1]` **Chunked/resumable upload** (T9.2 refinement) — *top reliability item*: recordings are
|
||||
now native-quality (~50–100 MB) and `put()` buffers the whole file in memory; OneDrive/Graph caps a
|
||||
single PUT at 250 MB. Stream from disk in chunks (Nextcloud chunked upload + Graph upload session).
|
||||
**M** → FR-SYNC-2/5
|
||||
- `[M4.2]` Multi-language transcription (T8.7): multilingual model option; whisper language param
|
||||
(select/auto); persist per-meeting `language`; UI localization scaffold. **M** → FR-TRX-4
|
||||
- `[M4.3]` Dropbox/Box upload targets (T9.10): implement `DropboxTarget`/`BoxTarget` `SyncTarget`
|
||||
impls (OAuth is already wired). **M** → FR-SYNC-9
|
||||
- `[M4.4]` MS Graph calendar source (T8.9) behind `CalendarSource` (`GraphSource`), consented
|
||||
(OAuth PKCE), metadata-only. **L** → FR-CAL-6 (Could) — **shipped**.
|
||||
|
||||
### M5 — Push handoff (Layer 3, last & optional) → FR-AGENT-1/2 (Could) (T10.10/10.11)
|
||||
Only on demand — the MCP handoff (M2) already covers the agent use case (agents *pull* context).
|
||||
- `[M5.1]` `AgentRunner`: spawn `claude -p` / `codex exec` / `opencode run` / `copilot` headless
|
||||
against a chosen repo from a brief; stream output (`run_agent`). **L** → FR-AGENT-1
|
||||
- `[M5.2]` `IssueTracker`: create a GitHub issue from a confirmed action item / brief; optional
|
||||
Copilot-cloud assign (`create_issue_from_brief`). Third-party egress: off/labeled/allowlisted. **M**
|
||||
→ FR-AGENT-2
|
||||
|
||||
**Privacy checkpoints (gate every milestone):** the egress self-test stays green (FR-SEC-1); enabling
|
||||
MCP adds **no** allowlist host (FR-MCP-7); hosted AI/agent handoff adds only the explicitly configured
|
||||
host; all secrets (AI keys, MCP token, OAuth tokens) live only in the OS credential store
|
||||
(NFR-SEC-4). Plus the standing gates: `cargo fmt`/`clippy -D warnings`, Prettier/ESLint/`tsc`, and a
|
||||
docs update whenever a command/event contract changes (CLAUDE.md).
|
||||
|
||||
@@ -114,7 +114,14 @@ is green and the cross-cutting gates pass.
|
||||
requires a token, and **opens no outbound socket** — the egress test is unchanged with MCP on (FR-MCP-7,
|
||||
NFR-SEC-5). Recordings are not served unless `expose_recordings` is true (FR-MCP-3).
|
||||
- Audit: every tool call appends an `mcp_access_log` row / `mcp://access` event (FR-MCP-5).
|
||||
- Unit: `FeatureBriefBuilder` produces problem/outcome/acceptance-criteria from a golden transcript.
|
||||
- Unit (M1 — feature briefs, no network): (1) `parse_brief` splits a golden
|
||||
`## Title/## Problem/## Desired Outcome/## Acceptance Criteria` reply into the right fields, and an
|
||||
empty/malformed reply yields empty fields without panicking. (2) `FeatureBriefBuilder` over a golden
|
||||
transcript, driven by a `MockLlmProvider` that returns a fixed sectioned reply, produces a
|
||||
schema-valid `FeatureBrief` with non-empty `acceptance_criteria` and satisfies the **grounding
|
||||
invariant**: every `context_excerpts[].text` is a verbatim substring of some transcript segment
|
||||
(never model paraphrase). (3) Command-level: `create_feature_brief` with the LLM off/unreachable
|
||||
returns `Err` and writes no `briefs/*.json` and no `feature_briefs` row (no partial artifacts).
|
||||
- (10c, when built) push: `AgentRunner` invokes a stub CLI with the brief; `IssueTracker` creates a
|
||||
mocked GitHub issue and (optional) Copilot assignment (FR-AGENT-1/2).
|
||||
|
||||
|
||||
@@ -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,142 @@
|
||||
# i18n migration tracking
|
||||
|
||||
Working doc for the incremental UI-string translation effort. The i18n **mechanism**
|
||||
is done; this tracks moving the app's remaining hardcoded strings into the translation
|
||||
files, one batch at a time. Tick boxes as views are converted.
|
||||
|
||||
> **Status (2026-07-12): migration complete.** All views and components (Batches A–F) are
|
||||
> converted; `en.json` holds ~506 keys. Every user-facing English string flows through
|
||||
> `t()`. What remains is intentionally-untranslated data (see the "Not translated" section)
|
||||
> — plus the actual work of adding a second language, which is now just translating
|
||||
> `en.json` into a new `<code>.json`.
|
||||
|
||||
- **Engine:** hand-rolled, zero-dependency. `src/lib/i18n/index.svelte.ts`
|
||||
- **Baseline dictionary:** `src/lib/i18n/en.json` (English is the source-of-truth key set
|
||||
**and** the fallback for any missing key)
|
||||
- **Selector:** Settings → Language (`section === "language"`)
|
||||
- **Scope decision:** display language is separate from the transcription `whisper_language`
|
||||
setting — don't conflate them.
|
||||
|
||||
## What we translate (and what we never do)
|
||||
|
||||
**Translate: UI chrome only** — fixed labels, buttons, headings, placeholders,
|
||||
empty/loading/status states, tooltips, and app-generated default *labels*.
|
||||
|
||||
**Never translate: user-authored content.** Meeting titles, tag names, notes, transcript
|
||||
text, search snippets, dates — these are bound straight from data (`item.title`, tag
|
||||
values, `s.text`, …) and stay exactly as the user wrote them. If a string comes from the
|
||||
user or the recording, it does not get a key.
|
||||
|
||||
So "convert view X" always means "key its fixed labels", never "touch its content". Most
|
||||
views are mostly content with a thin shell of labels — MeetingsList, for example, is ~10
|
||||
fixed labels (search placeholder, empty states, filter/bulk-export labels) wrapped around
|
||||
a list whose rows are pure user data.
|
||||
|
||||
> Caveat — the `"Untitled meeting"` **default title is generated in the Rust backend**, not
|
||||
> the frontend, so it never reaches `t()`. Localizing app-generated defaults is a separate
|
||||
> backend decision, out of scope for this frontend effort.
|
||||
|
||||
## How to convert a string (the pattern)
|
||||
|
||||
1. Add a key to `en.json`. Naming: `<area>.<subarea>.<name>`, dotted, grouped by view —
|
||||
e.g. `nav.recording`, `settings.transcription.title`, `meetings.empty`.
|
||||
2. Replace the literal in markup with `{t("key")}` (import `t` from `../i18n/index.svelte`).
|
||||
3. Dynamic bits use placeholders: `t("meetings.count", { n })` against
|
||||
`"meetings.count": "{n} meetings"`. Handle plurals with a caller-side ternary for now
|
||||
(`n === 1 ? t("...one") : t("...many")`) — the engine is intentionally simple.
|
||||
4. Attributes translate the same way: `title={t("...")}`, `aria-label={t("...")}`,
|
||||
`placeholder={t("...")}`.
|
||||
5. `npm run check` must stay at 0 errors/warnings.
|
||||
|
||||
> When adding a **new language** (not covered by this doc's batches): copy `en.json` to
|
||||
> `<code>.json`, translate it, and register it in `DICTS` + `LOCALES` in
|
||||
> `index.svelte.ts`. That one file is the whole job.
|
||||
|
||||
## Done
|
||||
|
||||
- [x] i18n engine + `en.json` baseline + Settings language selector (branch
|
||||
`feature_chore_bug_007`)
|
||||
- [x] `Settings.svelte` — nav labels (`nav.*`), the Language section
|
||||
(`settings.language.*`), and the Transcription Language section
|
||||
(`settings.transcription.*`)
|
||||
|
||||
- [x] `src/App.svelte` — app shell chrome (`app.*`): header (tagline, template
|
||||
picker, record/stop/cancel/add-meeting, recording status, retention, backend,
|
||||
device-notice), Settings button, consent + vault-unlock dialogs, pane toggles,
|
||||
splitter labels, and JS strings (discard confirm, SR announcements, vault error).
|
||||
- [x] `src/lib/views/MeetingsList.svelte` — labels only (`meetings.*`): search/filter/
|
||||
bulk-export controls, empty/loading/status states, status badges (via
|
||||
`statusLabel`), resume + delete, delete-confirm, and the pluralized export result.
|
||||
List rows (titles/tags/dates/snippets) left as user data.
|
||||
- [x] `src/lib/views/TranscriptNotes.svelte` — transcript + notes chrome (`transcript.*`,
|
||||
`notes.*`): pane headings/toggles, reprocess controls, notes toolbar (bold/heading/
|
||||
list/preview), export button tooltips + dialog filter names, empty states, segment
|
||||
tooltips + note placeholders. Transcript/notes/speaker text left as user data.
|
||||
- [x] `src/lib/views/SummaryPanel.svelte` — all panel chrome (`summary.*`): section
|
||||
headings (Recording/Sync/Tags/Summary/Briefs/Action items/Calendar/Participants/
|
||||
Speakers), buttons, placeholders, empty states, provider labels (via `providerLabel`;
|
||||
brand names kept literal), action-item + brief + speaker controls. Summary text,
|
||||
tags, brief content, participant/speaker names left as user data.
|
||||
- [x] `src/lib/views/Settings.svelte` — **all sections** done (`settings.*`): the modal
|
||||
shell (dialog title, Close), plus recording, hardware (incl. models/diarization),
|
||||
storage (+ export/import status), calendar (.pst import/cleanup/events), sync
|
||||
(targets + WebDAV/OAuth forms), AI (provider + Ollama advanced params), MCP
|
||||
(transport/scope/token/access log), privacy (egress self-check + vault), about.
|
||||
OLLAMA_OPTIONS param catalog labels/help left as data (config catalog, like model
|
||||
ids); example URLs/model-id placeholders left literal.
|
||||
|
||||
- [x] Components (Batch F): `ConsentNotice` + `HostedAiBanner` (legal/consent copy,
|
||||
`consent.*` / `hosted.*`), `ThemeToggle` (`theme.*`), `ImportMeeting` (`import.*`),
|
||||
`TagChip` (`tagchip.*`), `LevelMeter` (`levelmeter.*`). `Splitter`'s `label` is
|
||||
caller-supplied and already translated by the parent; no strings of its own.
|
||||
|
||||
## Outstanding — suggested batches
|
||||
|
||||
_None — all batches complete._ The section below is kept as a record of the plan.
|
||||
|
||||
Ordered roughly by user-visibility ÷ effort. Sizes are rough (line count / labeled
|
||||
attributes) to help portion the work, not exact string counts. A file isn't "done" until
|
||||
its visible text **and** its `title`/`aria-label`/`placeholder` attributes are keyed.
|
||||
|
||||
### ~~Batch A — app shell~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch B — meetings list~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch C — transcript & notes~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch D — summary panel~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch E — Settings, all sections~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
All nine sections + the modal shell converted, one commit per section. Moved to the Done
|
||||
list above.
|
||||
|
||||
### ~~Batch F — components~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
## Not translated (intentional)
|
||||
|
||||
- **User-authored content** — meeting titles, tag names, notes, transcript text, search
|
||||
snippets. Bound from data; stays as the user wrote it (see "What we translate" above).
|
||||
- App-generated defaults created in the backend (e.g. `"Untitled meeting"`) — a separate
|
||||
backend concern; the frontend `t()` never sees them.
|
||||
- Backend / Rust error messages surfaced via `WaError` — out of scope for the frontend
|
||||
`t()`; revisit only if we localize command errors.
|
||||
- Provider/proper names (Ollama, Anthropic, OpenAI, Obsidian, WebDAV, WhispAssist),
|
||||
model ids, ISO language codes.
|
||||
- Console/`tracing` logs.
|
||||
|
||||
## Batch log
|
||||
|
||||
Record each landed batch here (date / branch / commit) so progress is auditable.
|
||||
|
||||
| Date | Batch | Branch / commit | Notes |
|
||||
|------|-------|-----------------|-------|
|
||||
| 2026-07-12 | Infra + Settings nav/language/transcription | `feature_chore_bug_007` | engine + en.json + selector |
|
||||
| 2026-07-12 | Batch A (App shell) + Batch B (MeetingsList) | `feature_chore_bug_007` | +~60 keys; `app.*`, `meetings.*` |
|
||||
| 2026-07-12 | Batch C (TranscriptNotes) + Batch D (SummaryPanel) | `feature_chore_bug_007` | +~120 keys; `transcript.*`, `notes.*`, `summary.*`; en.json now 198 keys |
|
||||
| 2026-07-12 | Batch E (Settings, all 9 sections + shell) | `feature_chore_bug_007` | +~270 keys; `settings.*`; en.json now 470 keys; one commit per section |
|
||||
| 2026-07-12 | Batch F (components) | `feature_chore_bug_007` | +~36 keys; `consent.*`/`hosted.*`/`theme.*`/`import.*`/`tagchip.*`/`levelmeter.*`; en.json now 506 keys — migration complete |
|
||||
@@ -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.2.0",
|
||||
"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
+138
-8
@@ -71,7 +71,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
@@ -475,7 +486,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -485,7 +507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"chacha20 0.9.1",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
@@ -626,6 +648,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
@@ -828,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"
|
||||
@@ -846,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"
|
||||
@@ -1023,7 +1074,7 @@ dependencies = [
|
||||
"rustc_version",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
"winreg 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1492,6 +1543,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1795,6 +1847,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
@@ -1808,6 +1866,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec 1.15.2",
|
||||
@@ -3140,7 +3199,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
@@ -3439,6 +3498,17 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20 0.10.1",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
@@ -3477,6 +3547,12 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3699,18 +3775,28 @@ checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rand 0.10.2",
|
||||
"reqwest 0.13.4",
|
||||
"rmcp-macros",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sse-stream",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4158,7 +4244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -4169,7 +4255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -4545,6 +4631,19 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-stream"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -4867,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"
|
||||
@@ -5975,16 +6088,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "whispassist"
|
||||
version = "0.2.0"
|
||||
version = "0.7.5"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"docx-rs",
|
||||
"futures-util",
|
||||
"getrandom 0.2.17",
|
||||
"hound",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"keyring",
|
||||
"ort",
|
||||
"printpdf",
|
||||
@@ -6000,9 +6118,12 @@ dependencies = [
|
||||
"sqlx",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
"tauri-plugin-dialog",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
@@ -6692,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"
|
||||
|
||||
+33
-4
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "whispassist"
|
||||
version = "0.2.0"
|
||||
version = "0.7.5"
|
||||
description = "Privacy-first, fully local Windows meeting assistant"
|
||||
authors = ["WhispAssist contributors"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -23,7 +23,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "1"
|
||||
async-trait = "0.1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "fs"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util", "net"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
@@ -47,7 +47,26 @@ chacha20poly1305 = "0.10"
|
||||
getrandom = "0.2"
|
||||
zeroize = "1" # wipe key material from memory on lock
|
||||
keyring = { version = "3", optional = true, features = ["windows-native"] } # OS credential store (sync + AI creds); windows-native = real Credential Manager (else keyring 3.x uses a no-op mock store)
|
||||
rmcp = { version = "0.16", optional = true, features = ["server"] } # MCP server (ADR-0011)
|
||||
# MCP server (ADR-0011). `client`/`transport-streamable-http-client-reqwest`
|
||||
# are only ever constructed by this crate's own in-process tests (an actual
|
||||
# MCP client talking to our loopback server) -- WA never opens an outbound
|
||||
# MCP connection at runtime, so this adds no egress (FR-MCP-7).
|
||||
rmcp = { version = "0.16", optional = true, features = [
|
||||
"server", "transport-streamable-http-server", "transport-io",
|
||||
"client", "transport-streamable-http-client-reqwest",
|
||||
] }
|
||||
# Low-level HTTP glue for the Streamable HTTP transport: `rmcp`'s
|
||||
# `StreamableHttpService` is a bare `tower_service::Service`, so something has
|
||||
# to actually accept TCP connections and run HTTP/1 on top of it. All four
|
||||
# versions are already in Cargo.lock transitively (via reqwest/tauri), so this
|
||||
# just promotes them to direct deps -- no new crates.
|
||||
hyper = { version = "1", optional = true, features = ["server", "http1"] }
|
||||
hyper-util = { version = "0.1", optional = true, features = ["tokio"] }
|
||||
http-body-util = { version = "0.1", optional = true }
|
||||
http = { version = "1", optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
tower-service = { version = "0.3", optional = true }
|
||||
tokio-util = { version = "0.7", optional = true }
|
||||
|
||||
# audio / transcription / diarization / calendar are integrated per-phase and are
|
||||
# feature-gated so the CPU-only build always compiles (NFR-MNT-4).
|
||||
@@ -62,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.
|
||||
@@ -80,6 +100,7 @@ windows = { version = "0.58", features = [
|
||||
"Win32_Devices_DeviceAndDriverInstallation", # SetupAPI: NPU detection (Phase 3, T3.4)
|
||||
"Win32_System_Com",
|
||||
"Win32_UI_Shell", # SetCurrentProcessExplicitAppUserModelID (Phase 8, T8.6)
|
||||
"Win32_UI_WindowsAndMessaging", # MessageBoxW: fatal-startup error dialog (see run())
|
||||
"UI_Notifications", # scheduled toast reminders (Phase 8, T8.6, FR-CAL-5) — the
|
||||
"Data_Xml_Dom", # OS delivers these itself at the due time, no polling timer
|
||||
"Foundation",
|
||||
@@ -105,8 +126,16 @@ 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"] # WhispAssist as an MCP server + hosted-AI creds
|
||||
mcp = [
|
||||
"dep:rmcp", "dep:keyring", "dep:hyper", "dep:hyper-util",
|
||||
"dep:http-body-util", "dep:http", "dep:bytes", "dep:tower-service",
|
||||
"dep:tokio-util",
|
||||
] # WhispAssist as an MCP server + hosted-AI creds
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z" # optimize for size — keep the binary small (NFR-RES-1)
|
||||
|
||||
@@ -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,17 @@
|
||||
-- Bug fix: search (FR-SEARCH-1) never covered summary or tags -- meeting_fts
|
||||
-- only ever had title/transcript_text/notes_text columns. `meeting_fts` is a
|
||||
-- derived index (rebuilt from meetings/transcript.json/notes.md/summary.json/
|
||||
-- tags, never a source of truth), so dropping and recreating it is safe: no
|
||||
-- data loss, and `backfill_fts` repopulates every meeting on next startup
|
||||
-- since a freshly created table has no rows for anything yet.
|
||||
DROP TABLE meeting_fts;
|
||||
|
||||
CREATE VIRTUAL TABLE meeting_fts USING fts5(
|
||||
meeting_id UNINDEXED,
|
||||
title,
|
||||
transcript_text,
|
||||
notes_text,
|
||||
summary_text,
|
||||
tags_text,
|
||||
tokenize = 'porter unicode61'
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
-- Bug fix: prior imports could accumulate many duplicate rows for the same
|
||||
-- real calendar event on every re-import -- either because the (source,
|
||||
-- raw_uid) unique index (added in 0005) never retroactively deduped rows
|
||||
-- that existed before it, or because an event with no UID in its source data
|
||||
-- got `raw_uid = NULL`, which that index's `WHERE raw_uid IS NOT NULL` clause
|
||||
-- explicitly exempts from uniqueness, so it duplicated on every single
|
||||
-- re-import forever. This collapses whatever's already in the table, then
|
||||
-- backfills a stable content-based key for anything still missing a UID so
|
||||
-- future re-imports resolve to the same row instead of minting a new one
|
||||
-- (see calendar::content_uid in src/calendar/mod.rs, which produces the
|
||||
-- identical 'content:subject|organizer|starts_at|ends_at' format used here).
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- Drop the index first: the backfill below can momentarily produce rows that
|
||||
-- share a (source, raw_uid) pair before they're deduped a few statements
|
||||
-- later, which the index would reject mid-UPDATE.
|
||||
DROP INDEX IF EXISTS idx_calendar_events_source_uid;
|
||||
|
||||
UPDATE calendar_events
|
||||
SET raw_uid = 'content:' || COALESCE(subject, '') || '|' || COALESCE(organizer, '')
|
||||
|| '|' || COALESCE(starts_at, 0) || '|' || COALESCE(ends_at, 0)
|
||||
WHERE raw_uid IS NULL;
|
||||
|
||||
-- One survivor per (source, raw_uid) group: whichever row a meeting is
|
||||
-- already attached to (so `attach_meeting_to_event` links don't break), else
|
||||
-- the lexicographically-first id (arbitrary but deterministic).
|
||||
CREATE TEMP TABLE calendar_event_survivors AS
|
||||
SELECT source, raw_uid, MIN(id) AS keep_id
|
||||
FROM calendar_events
|
||||
GROUP BY source, raw_uid;
|
||||
|
||||
UPDATE calendar_event_survivors
|
||||
SET keep_id = (
|
||||
SELECT m.calendar_event_id FROM meetings m
|
||||
JOIN calendar_events ce ON ce.id = m.calendar_event_id
|
||||
WHERE ce.source = calendar_event_survivors.source
|
||||
AND ce.raw_uid = calendar_event_survivors.raw_uid
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM meetings m
|
||||
JOIN calendar_events ce ON ce.id = m.calendar_event_id
|
||||
WHERE ce.source = calendar_event_survivors.source
|
||||
AND ce.raw_uid = calendar_event_survivors.raw_uid
|
||||
);
|
||||
|
||||
-- Repoint any meeting attached to a duplicate that's about to be deleted
|
||||
-- onto the group's survivor instead.
|
||||
UPDATE meetings
|
||||
SET calendar_event_id = (
|
||||
SELECT s.keep_id FROM calendar_event_survivors s
|
||||
JOIN calendar_events ce ON ce.source = s.source AND ce.raw_uid = s.raw_uid
|
||||
WHERE ce.id = meetings.calendar_event_id
|
||||
)
|
||||
WHERE calendar_event_id IN (
|
||||
SELECT ce.id FROM calendar_events ce
|
||||
JOIN calendar_event_survivors s ON ce.source = s.source AND ce.raw_uid = s.raw_uid
|
||||
WHERE ce.id != s.keep_id
|
||||
);
|
||||
|
||||
-- Drop the duplicates (cascades to calendar_event_participants).
|
||||
DELETE FROM calendar_events
|
||||
WHERE id NOT IN (SELECT keep_id FROM calendar_event_survivors);
|
||||
|
||||
DROP TABLE calendar_event_survivors;
|
||||
|
||||
CREATE UNIQUE INDEX idx_calendar_events_source_uid ON calendar_events(source, raw_uid)
|
||||
WHERE raw_uid IS NOT NULL;
|
||||
@@ -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;
|
||||
+445
-33
@@ -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>>;
|
||||
@@ -58,6 +75,9 @@ pub type FrameSink = SyncSender<Vec<f32>>;
|
||||
pub struct AudioLevel {
|
||||
pub rms: f32,
|
||||
pub peak: f32,
|
||||
/// `true` for the microphone stream, `false` for system/loopback audio —
|
||||
/// lets the UI overlay the two meters in different colours (FR-CAP-5/7).
|
||||
pub mic: bool,
|
||||
}
|
||||
|
||||
/// Out-of-band capture notices, lower-volume than `FrameSink`/level updates.
|
||||
@@ -198,12 +218,89 @@ 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 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>>,
|
||||
}
|
||||
|
||||
impl VoiceSample {
|
||||
pub fn new(cap_samples: usize) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
cap: cap_samples,
|
||||
buf: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn push(&self, samples: &[f32]) {
|
||||
// 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);
|
||||
buf.truncate(self.cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The samples captured so far (may be shorter than `cap` on a short
|
||||
/// recording, or empty if the mic never produced usable audio).
|
||||
pub fn samples(&self) -> Vec<f32> {
|
||||
self.buf.lock().map(|b| b.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let bytes_per_frame = (format.get_bitspersample() as usize / 8) * format.get_nchannels() as usize;
|
||||
let bytes_per_frame =
|
||||
(format.get_bitspersample() as usize / 8) * format.get_nchannels() as usize;
|
||||
if bytes_per_frame == 0 {
|
||||
0
|
||||
} else {
|
||||
@@ -231,11 +328,16 @@ impl WasapiCapture {
|
||||
frame_sink: FrameSink,
|
||||
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);
|
||||
|
||||
@@ -250,7 +352,11 @@ impl WasapiCapture {
|
||||
&event_sink,
|
||||
&running_th,
|
||||
&paused_th,
|
||||
&muted_th,
|
||||
bridge.as_ref(),
|
||||
voice_sample.as_ref(),
|
||||
split,
|
||||
emit_level,
|
||||
)
|
||||
})
|
||||
.map_err(|e| AudioError::Capture(format!("spawn failed: {e}")))?;
|
||||
@@ -258,6 +364,7 @@ impl WasapiCapture {
|
||||
Ok(CaptureHandle {
|
||||
running,
|
||||
paused,
|
||||
muted,
|
||||
thread,
|
||||
})
|
||||
}
|
||||
@@ -281,17 +388,23 @@ impl WasapiCapture {
|
||||
frame_sink,
|
||||
event_sink,
|
||||
Some(bridge),
|
||||
None,
|
||||
true, // split: mic-enabled loopback records mic-left / loopback-right
|
||||
true, // emit_level
|
||||
)
|
||||
}
|
||||
|
||||
/// Microphone capture that both feeds the live-transcript mixer (`frame_sink`)
|
||||
/// and pushes its audio into `bridge` for the loopback thread to record.
|
||||
/// `voice_sample`, when given, also collects the first few seconds of raw
|
||||
/// mic audio for a post-recording voiceprint match (mic-speaker labeling).
|
||||
pub fn start_microphone_recording(
|
||||
&self,
|
||||
device_id: Option<&str>,
|
||||
frame_sink: FrameSink,
|
||||
event_sink: EventSink,
|
||||
bridge: Arc<MicBridge>,
|
||||
voice_sample: Option<Arc<VoiceSample>>,
|
||||
) -> Result<CaptureHandle, AudioError> {
|
||||
self.start_capture(
|
||||
"wa-mic-capture",
|
||||
@@ -301,6 +414,9 @@ impl WasapiCapture {
|
||||
frame_sink,
|
||||
event_sink,
|
||||
Some(bridge),
|
||||
voice_sample,
|
||||
false, // split: mic stream has no writer
|
||||
true, // emit_level
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -322,6 +438,9 @@ impl AudioCapture for WasapiCapture {
|
||||
frame_sink,
|
||||
event_sink,
|
||||
None,
|
||||
None,
|
||||
false, // split: loopback-only / mic stream — no split writer
|
||||
true, // emit_level
|
||||
)
|
||||
}
|
||||
|
||||
@@ -339,6 +458,9 @@ impl AudioCapture for WasapiCapture {
|
||||
frame_sink,
|
||||
event_sink,
|
||||
None,
|
||||
None,
|
||||
false, // split: loopback-only / mic stream — no split writer
|
||||
true, // emit_level
|
||||
)
|
||||
}
|
||||
|
||||
@@ -376,7 +498,10 @@ struct CaptureSession {
|
||||
/// was picked) — same "degrade gracefully rather than fail the recording" spirit
|
||||
/// as the device-recovery reconnect below.
|
||||
#[cfg(feature = "audio")]
|
||||
fn find_device(direction: &Direction, device_id: Option<&str>) -> Result<wasapi::Device, AudioError> {
|
||||
fn find_device(
|
||||
direction: &Direction,
|
||||
device_id: Option<&str>,
|
||||
) -> Result<wasapi::Device, AudioError> {
|
||||
let Some(id) = device_id else {
|
||||
return wasapi::get_default_device(direction)
|
||||
.map_err(|e| AudioError::Device(format!("no default audio device: {e}")));
|
||||
@@ -458,12 +583,14 @@ fn format_compatible(a: &WaveFormat, b: &WaveFormat) -> bool {
|
||||
}
|
||||
|
||||
/// Amplitude of one chunk of mono samples, for the live level meter (FR-CAP-5).
|
||||
/// `mic` tags which stream this level came from so the UI can overlay both.
|
||||
#[cfg(feature = "audio")]
|
||||
fn audio_level(mono: &[f32]) -> AudioLevel {
|
||||
fn audio_level(mono: &[f32], mic: bool) -> AudioLevel {
|
||||
if mono.is_empty() {
|
||||
return AudioLevel {
|
||||
rms: 0.0,
|
||||
peak: 0.0,
|
||||
mic,
|
||||
};
|
||||
}
|
||||
let sum_sq: f32 = mono.iter().map(|s| s * s).sum();
|
||||
@@ -471,6 +598,7 @@ fn audio_level(mono: &[f32]) -> AudioLevel {
|
||||
AudioLevel {
|
||||
rms: (sum_sq / mono.len() as f32).sqrt(),
|
||||
peak,
|
||||
mic,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,6 +606,18 @@ fn audio_level(mono: &[f32]) -> 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.
|
||||
@@ -491,26 +631,38 @@ 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()
|
||||
.ok()
|
||||
.map_err(|e| AudioError::Device(format!("COM init failed: {e}")))?;
|
||||
|
||||
let is_loopback = matches!(direction, Direction::Render);
|
||||
// Only the loopback stream emits level updates (FR-CAP-5) and writes a WAV;
|
||||
// the mic stream just contributes frames to the transcript mixer (FR-CAP-7).
|
||||
let emit_level = wav_path.is_some();
|
||||
// Both streams emit level updates (FR-CAP-5/7) — tagged by `mic` below so the
|
||||
// UI can overlay them — but only the loopback stream writes a WAV.
|
||||
let mut session = open_capture_session(&direction, device_id)?;
|
||||
// Loopback publishes its rate so the mic knows what to resample to before
|
||||
// pushing into the shared bridge (mic-into-recording, FR-CAP-7).
|
||||
// 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()))
|
||||
@@ -530,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.
|
||||
@@ -573,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
|
||||
@@ -590,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.
|
||||
@@ -612,12 +812,20 @@ fn capture_loop(
|
||||
}
|
||||
|
||||
if emit_level && last_level_emit.elapsed() >= LEVEL_EMIT_INTERVAL {
|
||||
let _ = event_sink.try_send(CaptureEvent::Level(audio_level(&mono)));
|
||||
let _ = event_sink.try_send(CaptureEvent::Level(audio_level(&mono, !is_loopback)));
|
||||
last_level_emit = Instant::now();
|
||||
}
|
||||
|
||||
let resampled = resampler.process(&mono);
|
||||
if !resampled.is_empty() {
|
||||
// Mic-only, best-effort: a few seconds of raw mic audio for the
|
||||
// post-recording voiceprint match (see `VoiceSample`). No-ops
|
||||
// itself once its cap is reached.
|
||||
if !is_loopback {
|
||||
if let Some(vs) = voice_sample {
|
||||
vs.push(&resampled);
|
||||
}
|
||||
}
|
||||
let _ = frame_sink.try_send(resampled); // drop on backpressure; disk write is unaffected
|
||||
}
|
||||
}
|
||||
@@ -688,7 +896,8 @@ fn write_wav_bytes(
|
||||
for frame in bytes.chunks_exact(2 * channels) {
|
||||
let add = mic.get(frames as usize).copied().unwrap_or(0.0);
|
||||
for c in frame.chunks_exact(2) {
|
||||
let v = i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32 + add;
|
||||
let v =
|
||||
i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32 + add;
|
||||
writer
|
||||
.write_sample(f32_to_i16(v))
|
||||
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
|
||||
@@ -711,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> {
|
||||
@@ -825,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 =
|
||||
@@ -834,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)
|
||||
@@ -846,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) => {
|
||||
@@ -874,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.
|
||||
@@ -979,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();
|
||||
@@ -1013,6 +1387,16 @@ mod tests {
|
||||
assert!(m.loopback.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_sample_caps_and_stops_accepting_once_full() {
|
||||
let vs = VoiceSample::new(4);
|
||||
vs.push(&[1.0, 2.0, 3.0]);
|
||||
vs.push(&[4.0, 5.0]); // would overflow the cap of 4
|
||||
assert_eq!(vs.samples(), vec![1.0, 2.0, 3.0, 4.0]);
|
||||
vs.push(&[9.0]); // already full — ignored
|
||||
assert_eq!(vs.samples(), vec![1.0, 2.0, 3.0, 4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_writer_writes_16bit_from_a_float_mix_and_keeps_duration() {
|
||||
// A 32-bit-float mix is captured but written as 16-bit PCM at the same
|
||||
@@ -1045,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);
|
||||
@@ -1063,10 +1474,7 @@ mod tests {
|
||||
let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
||||
let mut writer = WavWriter::create(&path, wav_spec_for(&format).unwrap()).unwrap();
|
||||
// One stereo frame of silence (L=0.0, R=0.0).
|
||||
let bytes: Vec<u8> = [0.0f32, 0.0]
|
||||
.iter()
|
||||
.flat_map(|s| s.to_le_bytes())
|
||||
.collect();
|
||||
let bytes: Vec<u8> = [0.0f32, 0.0].iter().flat_map(|s| s.to_le_bytes()).collect();
|
||||
let frames = write_wav_bytes(&mut writer, &bytes, &format, &[0.5]).unwrap();
|
||||
writer.finalize().unwrap();
|
||||
assert_eq!(frames, 1);
|
||||
@@ -1328,9 +1736,13 @@ mod tests {
|
||||
let (ev_tx, _ev) = std::sync::mpsc::sync_channel::<CaptureEvent>(8);
|
||||
let (ev_tx2, _ev2) = std::sync::mpsc::sync_channel::<CaptureEvent>(8);
|
||||
|
||||
let loop_h =
|
||||
match WasapiCapture.start_loopback_recording(&path, None, loop_tx, ev_tx, bridge.clone())
|
||||
{
|
||||
let loop_h = match WasapiCapture.start_loopback_recording(
|
||||
&path,
|
||||
None,
|
||||
loop_tx,
|
||||
ev_tx,
|
||||
bridge.clone(),
|
||||
) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
eprintln!("[mixrec] no loopback device: {e} (skipping)");
|
||||
@@ -1338,7 +1750,7 @@ mod tests {
|
||||
}
|
||||
};
|
||||
let mic_h = WasapiCapture
|
||||
.start_microphone_recording(None, mic_tx, ev_tx2, bridge)
|
||||
.start_microphone_recording(None, mic_tx, ev_tx2, bridge, None)
|
||||
.ok();
|
||||
|
||||
let _ = std::process::Command::new("powershell")
|
||||
@@ -1401,7 +1813,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn audio_level_of_empty_chunk_is_silence() {
|
||||
let level = audio_level(&[]);
|
||||
let level = audio_level(&[], false);
|
||||
assert_eq!(level.rms, 0.0);
|
||||
assert_eq!(level.peak, 0.0);
|
||||
}
|
||||
@@ -1409,14 +1821,14 @@ mod tests {
|
||||
#[test]
|
||||
fn audio_level_computes_rms_and_peak() {
|
||||
// Two samples of equal magnitude: RMS equals that magnitude, peak too.
|
||||
let level = audio_level(&[0.5, -0.5]);
|
||||
let level = audio_level(&[0.5, -0.5], false);
|
||||
assert!((level.rms - 0.5).abs() < 1e-6);
|
||||
assert!((level.peak - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_level_peak_tracks_the_largest_magnitude_sample() {
|
||||
let level = audio_level(&[0.1, -0.9, 0.3]);
|
||||
let level = audio_level(&[0.1, -0.9, 0.3], false);
|
||||
assert!((level.peak - 0.9).abs() < 1e-6);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
//! Feature-brief distiller (Phase 10 M1, ADR-0011, FR-MCP-4/T10.6). Turns a
|
||||
//! finished meeting's transcript into an agent-ready spec via the configured
|
||||
//! `LlmProvider` — no new egress, no new dependency: it's the same provider
|
||||
//! `generate_summary` already talks to.
|
||||
//!
|
||||
//! The distillation itself (`distill`) is a plain function over an
|
||||
//! `LlmProvider` + transcript data, independent of `Store` — that's what lets
|
||||
//! the golden-transcript test exercise it with a `MockLlmProvider` and no
|
||||
//! database at all. `LlmFeatureBriefBuilder` is the thin `Store`-aware
|
||||
//! adapter the `FeatureBriefBuilder` trait (`docs/04-api-contracts.md`)
|
||||
//! describes, used by `commands::create_feature_brief`.
|
||||
|
||||
use crate::llm::{bullet_text, LlmError, LlmProvider};
|
||||
use crate::models::{ContextExcerpt, FeatureBrief, MeetingId, SpeakerInfo, TranscriptSegment};
|
||||
use crate::storage::{Store, StoreError};
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BriefError {
|
||||
#[error("storage error: {0}")]
|
||||
Store(#[from] StoreError),
|
||||
#[error("llm error: {0}")]
|
||||
Llm(#[from] LlmError),
|
||||
}
|
||||
|
||||
/// Builds the agent-ready spec from a transcript via the configured
|
||||
/// `LlmProvider` (`docs/04-api-contracts.md`).
|
||||
#[async_trait]
|
||||
pub trait FeatureBriefBuilder: Send + Sync {
|
||||
async fn build(
|
||||
&self,
|
||||
meeting_id: &MeetingId,
|
||||
target_repo: Option<&str>,
|
||||
) -> Result<FeatureBrief, BriefError>;
|
||||
}
|
||||
|
||||
/// System prompt contract (mirrors `llm::RESPONSE_FORMAT_INSTRUCTIONS`):
|
||||
/// exactly four sections, in order, nothing invented beyond the transcript.
|
||||
const BRIEF_INSTRUCTIONS: &str = "Distill this transcript into an implementation brief for a \
|
||||
coding agent. Respond in Markdown with exactly, in order: \"## Title\" (one line), \
|
||||
\"## Problem\", \"## Desired Outcome\", \"## Acceptance Criteria\" (a \"- \" bullet list, one \
|
||||
testable criterion per line). Be concrete and terse; invent nothing not in the transcript; no \
|
||||
other sections.";
|
||||
|
||||
/// Parsed reply, before assembly into the IPC/file shapes.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct BriefFields {
|
||||
pub title: String,
|
||||
pub problem: String,
|
||||
pub desired_outcome: String,
|
||||
pub acceptance_criteria: Vec<String>,
|
||||
}
|
||||
|
||||
/// Assembles the (system, user) prompt pair — metadata + optional
|
||||
/// `target_repo` hint + the transcript, mirroring `commands::build_prompt`'s
|
||||
/// shape for `summarize`.
|
||||
fn build_brief_messages(
|
||||
meeting_title: &str,
|
||||
participants: &[String],
|
||||
target_repo: Option<&str>,
|
||||
transcript: &str,
|
||||
) -> (String, String) {
|
||||
let mut user = format!("Meeting: {meeting_title}\n");
|
||||
if !participants.is_empty() {
|
||||
user.push_str(&format!("Participants: {}\n", participants.join(", ")));
|
||||
}
|
||||
if let Some(repo) = target_repo {
|
||||
user.push_str(&format!("Target repo: {repo}\n"));
|
||||
}
|
||||
user.push_str("\nTranscript:\n");
|
||||
user.push_str(transcript);
|
||||
(BRIEF_INSTRUCTIONS.to_string(), user)
|
||||
}
|
||||
|
||||
/// Splits a "## Title / ## Problem / ## Desired Outcome / ## Acceptance
|
||||
/// Criteria" Markdown reply (see `BRIEF_INSTRUCTIONS`) into `BriefFields` —
|
||||
/// mirrors `llm::parse_summary`. A missing/malformed section never panics:
|
||||
/// `title`/`problem`/`desired_outcome` fall back to `""` (title falls back
|
||||
/// further, to `meeting_title`, since a brief with no title at all is
|
||||
/// unusable), and `acceptance_criteria` falls back to `[]`.
|
||||
pub fn parse_brief(md: &str, meeting_title: &str) -> BriefFields {
|
||||
let mut title = String::new();
|
||||
let mut problem = String::new();
|
||||
let mut desired_outcome = String::new();
|
||||
let mut acceptance_criteria = Vec::new();
|
||||
let mut section = -1i8; // 0 title, 1 problem, 2 desired outcome, 3 acceptance criteria, -1 other/unknown
|
||||
|
||||
for line in md.lines() {
|
||||
let lower = line.trim().to_ascii_lowercase();
|
||||
if lower.starts_with("## title") {
|
||||
section = 0;
|
||||
continue;
|
||||
}
|
||||
if lower.starts_with("## problem") {
|
||||
section = 1;
|
||||
continue;
|
||||
}
|
||||
if lower.starts_with("## desired outcome") {
|
||||
section = 2;
|
||||
continue;
|
||||
}
|
||||
if lower.starts_with("## acceptance criteria") {
|
||||
section = 3;
|
||||
continue;
|
||||
}
|
||||
if line.trim_start().starts_with('#') {
|
||||
section = -1;
|
||||
continue;
|
||||
}
|
||||
match section {
|
||||
0 => {
|
||||
let text = line.trim();
|
||||
if !text.is_empty() {
|
||||
if !title.is_empty() {
|
||||
title.push(' ');
|
||||
}
|
||||
title.push_str(text);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
problem.push_str(line);
|
||||
problem.push('\n');
|
||||
}
|
||||
2 => {
|
||||
desired_outcome.push_str(line);
|
||||
desired_outcome.push('\n');
|
||||
}
|
||||
3 => {
|
||||
if let Some(item) = bullet_text(line) {
|
||||
acceptance_criteria.push(item);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let title = title.trim().to_string();
|
||||
BriefFields {
|
||||
title: if title.is_empty() {
|
||||
meeting_title.to_string()
|
||||
} else {
|
||||
title
|
||||
},
|
||||
problem: problem.trim().to_string(),
|
||||
desired_outcome: desired_outcome.trim().to_string(),
|
||||
acceptance_criteria,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercased alphanumeric words of length >= 3 — short enough to skip
|
||||
/// common stopwords ("the", "to", "we") without a stopword list, long enough
|
||||
/// to still catch meaningful terms.
|
||||
fn keywords(text: &str) -> HashSet<String> {
|
||||
text.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| w.len() >= 3)
|
||||
.map(|w| w.to_ascii_lowercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn display_name(label: &str, speakers: &[SpeakerInfo]) -> String {
|
||||
speakers
|
||||
.iter()
|
||||
.find(|s| s.label == label)
|
||||
.and_then(|s| s.display_name.clone())
|
||||
.unwrap_or_else(|| label.to_string())
|
||||
}
|
||||
|
||||
const MIN_EXCERPT_LEN: usize = 40;
|
||||
const MAX_EXCERPTS: usize = 5;
|
||||
const FALLBACK_EXCERPTS: usize = 3;
|
||||
|
||||
/// Selects grounding excerpts for a brief (the M1 grounding invariant: every
|
||||
/// returned `text` is copied verbatim from a transcript segment — never
|
||||
/// model paraphrase). Scores each substantive segment (>= ~40 chars) by
|
||||
/// keyword overlap with `problem` + `acceptance_criteria`, taking the top
|
||||
/// <= 5; falls back to the first 3 substantive segments if nothing scores
|
||||
/// (e.g. a terse reply with too few keywords, or a transcript that just
|
||||
/// doesn't share vocabulary with the drafted brief).
|
||||
// ponytail: keyword-overlap select; upgrade to embedding similarity if excerpts feel off
|
||||
fn context_excerpts(
|
||||
fields: &BriefFields,
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
) -> Vec<ContextExcerpt> {
|
||||
let mut query = keywords(&fields.problem);
|
||||
query.extend(keywords(&fields.acceptance_criteria.join(" ")));
|
||||
|
||||
let substantive: Vec<&TranscriptSegment> = segments
|
||||
.iter()
|
||||
.filter(|s| s.text.trim().len() >= MIN_EXCERPT_LEN)
|
||||
.collect();
|
||||
|
||||
if !query.is_empty() {
|
||||
let mut scored: Vec<(usize, &TranscriptSegment)> = substantive
|
||||
.iter()
|
||||
.map(|&s| (keywords(&s.text).intersection(&query).count(), s))
|
||||
.filter(|(overlap, _)| *overlap > 0)
|
||||
.collect();
|
||||
if !scored.is_empty() {
|
||||
// Stable sort keeps original (chronological) order among ties.
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
return scored
|
||||
.into_iter()
|
||||
.take(MAX_EXCERPTS)
|
||||
.map(|(_, s)| ContextExcerpt {
|
||||
speaker: display_name(&s.speaker, speakers),
|
||||
text: s.text.clone(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
substantive
|
||||
.into_iter()
|
||||
.take(FALLBACK_EXCERPTS)
|
||||
.map(|s| ContextExcerpt {
|
||||
speaker: display_name(&s.speaker, speakers),
|
||||
text: s.text.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Transcript-derived inputs `distill` needs — bundled into one struct so the
|
||||
/// function stays under clippy's argument-count lint rather than taking each
|
||||
/// field positionally.
|
||||
struct MeetingContext<'a> {
|
||||
title: &'a str,
|
||||
participants: &'a [String],
|
||||
transcript_md: &'a str,
|
||||
segments: &'a [TranscriptSegment],
|
||||
speakers: &'a [SpeakerInfo],
|
||||
}
|
||||
|
||||
/// Core distillation: prompt -> LLM round trip -> parse -> ground. Takes
|
||||
/// transcript data directly rather than a `MeetingId`, so it needs no
|
||||
/// `Store` — `LlmFeatureBriefBuilder::build` below is the `Store`-aware
|
||||
/// wrapper that looks the meeting up first.
|
||||
async fn distill(
|
||||
llm: &dyn LlmProvider,
|
||||
meeting_id: &MeetingId,
|
||||
target_repo: Option<&str>,
|
||||
ctx: &MeetingContext<'_>,
|
||||
) -> Result<FeatureBrief, BriefError> {
|
||||
let (system, user) =
|
||||
build_brief_messages(ctx.title, ctx.participants, target_repo, ctx.transcript_md);
|
||||
let reply = llm.complete(&system, &user).await?;
|
||||
let fields = parse_brief(&reply, ctx.title);
|
||||
let context_excerpts = context_excerpts(&fields, ctx.segments, ctx.speakers);
|
||||
Ok(FeatureBrief {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
meeting_id: meeting_id.clone(),
|
||||
title: fields.title,
|
||||
problem: fields.problem,
|
||||
desired_outcome: fields.desired_outcome,
|
||||
acceptance_criteria: fields.acceptance_criteria,
|
||||
target_repo: target_repo.map(str::to_string),
|
||||
context_excerpts,
|
||||
})
|
||||
}
|
||||
|
||||
/// `FeatureBriefBuilder` impl used by `commands::create_feature_brief`:
|
||||
/// loads the meeting via `store`, then distills it with `llm`.
|
||||
pub struct LlmFeatureBriefBuilder {
|
||||
pub store: Arc<dyn Store>,
|
||||
pub llm: Box<dyn LlmProvider>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FeatureBriefBuilder for LlmFeatureBriefBuilder {
|
||||
async fn build(
|
||||
&self,
|
||||
meeting_id: &MeetingId,
|
||||
target_repo: Option<&str>,
|
||||
) -> Result<FeatureBrief, BriefError> {
|
||||
let meeting = self.store.get_meeting(meeting_id).await?;
|
||||
let participants: Vec<String> = meeting
|
||||
.speakers
|
||||
.iter()
|
||||
.map(|s| s.display_name.clone().unwrap_or_else(|| s.label.clone()))
|
||||
.collect();
|
||||
let ctx = MeetingContext {
|
||||
title: &meeting.title,
|
||||
participants: &participants,
|
||||
transcript_md: &meeting.notes_markdown,
|
||||
segments: &meeting.segments,
|
||||
speakers: &meeting.speakers,
|
||||
};
|
||||
distill(self.llm.as_ref(), meeting_id, target_repo, &ctx).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::{LlmStatus, Prompt, Summary, TokenSink};
|
||||
|
||||
/// No-network stand-in for a real provider (T10.6 test — the golden-
|
||||
/// transcript builder test must never touch a socket). Only `complete`
|
||||
/// is exercised by `distill`; the rest are unused stubs.
|
||||
struct MockLlmProvider {
|
||||
reply: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for MockLlmProvider {
|
||||
async fn status(&self) -> LlmStatus {
|
||||
LlmStatus {
|
||||
provider: "mock".to_string(),
|
||||
reachable: true,
|
||||
is_local: true,
|
||||
models: Vec::new(),
|
||||
}
|
||||
}
|
||||
async fn summarize(&self, _prompt: Prompt, _out: TokenSink) -> Result<Summary, LlmError> {
|
||||
unimplemented!("not exercised by the brief-builder test")
|
||||
}
|
||||
async fn suggest_tags(&self, _transcript: &str) -> Result<Vec<String>, LlmError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn complete(&self, _system: &str, _user: &str) -> Result<String, LlmError> {
|
||||
Ok(self.reply.clone())
|
||||
}
|
||||
fn is_local(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
const GOLDEN_REPLY: &str = "## Title\n\
|
||||
Bulk CSV export for the reporting view\n\n\
|
||||
## Problem\n\
|
||||
Customers can't get their filtered report data out for offline analysis.\n\n\
|
||||
## Desired Outcome\n\
|
||||
One-click CSV export of the current filtered report.\n\n\
|
||||
## Acceptance Criteria\n\
|
||||
- Export button on the report toolbar\n\
|
||||
- Respects active filters and column order\n\
|
||||
- Streams large exports without blocking the UI\n";
|
||||
|
||||
fn seg(id: u64, speaker: &str, text: &str) -> TranscriptSegment {
|
||||
TranscriptSegment {
|
||||
id,
|
||||
start_ms: id * 1000,
|
||||
end_ms: id * 1000 + 900,
|
||||
speaker: speaker.to_string(),
|
||||
text: text.to_string(),
|
||||
confidence: Some(0.9),
|
||||
interim: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn golden_segments() -> Vec<TranscriptSegment> {
|
||||
vec![
|
||||
seg(0, "S1", "Let's start with the reporting view."),
|
||||
seg(
|
||||
1,
|
||||
"S2",
|
||||
"We really need to pull this filtered report into our own spreadsheets for offline analysis.",
|
||||
),
|
||||
seg(2, "S1", "Makes sense — what would the export button need to respect?"),
|
||||
seg(
|
||||
3,
|
||||
"S2",
|
||||
"It has to respect the active filters and the column order we've already set up.",
|
||||
),
|
||||
seg(4, "S1", "And it can't block the UI while a large export streams out."),
|
||||
seg(5, "S2", "Right, exactly."),
|
||||
]
|
||||
}
|
||||
|
||||
fn golden_speakers() -> Vec<SpeakerInfo> {
|
||||
vec![
|
||||
SpeakerInfo {
|
||||
label: "S1".to_string(),
|
||||
display_name: Some("Alex".to_string()),
|
||||
participant_id: None,
|
||||
},
|
||||
SpeakerInfo {
|
||||
label: "S2".to_string(),
|
||||
display_name: Some("Customer".to_string()),
|
||||
participant_id: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ---- parse_brief ----
|
||||
|
||||
#[test]
|
||||
fn parse_brief_splits_the_four_requested_sections() {
|
||||
let fields = parse_brief(GOLDEN_REPLY, "fallback title");
|
||||
assert_eq!(fields.title, "Bulk CSV export for the reporting view");
|
||||
assert_eq!(
|
||||
fields.problem,
|
||||
"Customers can't get their filtered report data out for offline analysis."
|
||||
);
|
||||
assert_eq!(
|
||||
fields.desired_outcome,
|
||||
"One-click CSV export of the current filtered report."
|
||||
);
|
||||
assert_eq!(
|
||||
fields.acceptance_criteria,
|
||||
vec![
|
||||
"Export button on the report toolbar",
|
||||
"Respects active filters and column order",
|
||||
"Streams large exports without blocking the UI",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_brief_falls_back_to_the_meeting_title_when_no_title_section() {
|
||||
let text = "## Problem\nSomething broke.\n";
|
||||
let fields = parse_brief(text, "Sprint planning");
|
||||
assert_eq!(fields.title, "Sprint planning");
|
||||
assert_eq!(fields.problem, "Something broke.");
|
||||
assert_eq!(fields.desired_outcome, "");
|
||||
assert!(fields.acceptance_criteria.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_brief_of_empty_or_malformed_input_is_empty_and_does_not_panic() {
|
||||
let fields = parse_brief("", "Meeting title");
|
||||
assert_eq!(fields.title, "Meeting title");
|
||||
assert_eq!(fields.problem, "");
|
||||
assert_eq!(fields.desired_outcome, "");
|
||||
assert!(fields.acceptance_criteria.is_empty());
|
||||
|
||||
// No recognized headings at all — everything before the first `#`
|
||||
// (there is none) is just unattributed prose, so nothing is captured.
|
||||
let fields = parse_brief("just some prose with no headings", "Meeting title");
|
||||
assert_eq!(fields.title, "Meeting title");
|
||||
assert!(fields.problem.is_empty());
|
||||
assert!(fields.acceptance_criteria.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_brief_ignores_unrecognized_extra_sections() {
|
||||
let text = "## Title\nFix the thing\n\n## Notes\nirrelevant chatter\n\n\
|
||||
## Acceptance Criteria\n- It works\n";
|
||||
let fields = parse_brief(text, "fallback");
|
||||
assert_eq!(fields.title, "Fix the thing");
|
||||
assert_eq!(fields.acceptance_criteria, vec!["It works"]);
|
||||
}
|
||||
|
||||
// ---- context_excerpts / grounding invariant ----
|
||||
|
||||
#[test]
|
||||
fn context_excerpts_are_verbatim_substrings_of_a_transcript_segment() {
|
||||
let fields = parse_brief(GOLDEN_REPLY, "fallback");
|
||||
let segments = golden_segments();
|
||||
let speakers = golden_speakers();
|
||||
let excerpts = context_excerpts(&fields, &segments, &speakers);
|
||||
assert!(!excerpts.is_empty());
|
||||
for excerpt in &excerpts {
|
||||
assert!(
|
||||
segments.iter().any(|s| s.text.contains(&excerpt.text)),
|
||||
"excerpt {:?} is not a verbatim substring of any transcript segment",
|
||||
excerpt.text
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_excerpts_resolve_speaker_display_names() {
|
||||
let fields = parse_brief(GOLDEN_REPLY, "fallback");
|
||||
let segments = golden_segments();
|
||||
let speakers = golden_speakers();
|
||||
let excerpts = context_excerpts(&fields, &segments, &speakers);
|
||||
assert!(excerpts.iter().any(|e| e.speaker == "Customer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_excerpts_falls_back_to_first_substantive_segments_with_no_keyword_overlap() {
|
||||
let fields = BriefFields {
|
||||
title: "t".to_string(),
|
||||
problem: "zzzzz qqqqq".to_string(), // shares no vocabulary with the transcript
|
||||
desired_outcome: String::new(),
|
||||
acceptance_criteria: vec!["wwwww".to_string()],
|
||||
};
|
||||
let segments = golden_segments();
|
||||
let excerpts = context_excerpts(&fields, &segments, &golden_speakers());
|
||||
assert_eq!(excerpts.len(), FALLBACK_EXCERPTS);
|
||||
for excerpt in &excerpts {
|
||||
assert!(segments.iter().any(|s| s.text.contains(&excerpt.text)));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- distill (the FeatureBriefBuilder golden-transcript test) ----
|
||||
|
||||
#[tokio::test]
|
||||
async fn distill_over_a_golden_transcript_yields_grounded_non_empty_criteria() {
|
||||
let llm = MockLlmProvider {
|
||||
reply: GOLDEN_REPLY.to_string(),
|
||||
};
|
||||
let segments = golden_segments();
|
||||
let speakers = golden_speakers();
|
||||
let participants: Vec<String> = speakers
|
||||
.iter()
|
||||
.map(|s| s.display_name.clone().unwrap())
|
||||
.collect();
|
||||
let transcript_md = segments
|
||||
.iter()
|
||||
.map(|s| format!("**{}:** {}", s.speaker, s.text))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let ctx = MeetingContext {
|
||||
title: "Reporting sync",
|
||||
participants: &participants,
|
||||
transcript_md: &transcript_md,
|
||||
segments: &segments,
|
||||
speakers: &speakers,
|
||||
};
|
||||
let brief = distill(&llm, &"m1".to_string(), Some("acme/reporting-web"), &ctx)
|
||||
.await
|
||||
.expect("distill should succeed against the mock provider");
|
||||
|
||||
assert_eq!(brief.meeting_id, "m1");
|
||||
assert_eq!(brief.title, "Bulk CSV export for the reporting view");
|
||||
assert!(!brief.acceptance_criteria.is_empty());
|
||||
assert_eq!(brief.target_repo.as_deref(), Some("acme/reporting-web"));
|
||||
assert!(!brief.context_excerpts.is_empty());
|
||||
for excerpt in &brief.context_excerpts {
|
||||
assert!(
|
||||
segments.iter().any(|s| s.text.contains(&excerpt.text)),
|
||||
"grounding invariant violated: {:?}",
|
||||
excerpt.text
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn distill_propagates_an_llm_error_without_panicking() {
|
||||
struct FailingProvider;
|
||||
#[async_trait]
|
||||
impl LlmProvider for FailingProvider {
|
||||
async fn status(&self) -> LlmStatus {
|
||||
LlmStatus {
|
||||
provider: "mock".to_string(),
|
||||
reachable: false,
|
||||
is_local: true,
|
||||
models: Vec::new(),
|
||||
}
|
||||
}
|
||||
async fn summarize(
|
||||
&self,
|
||||
_prompt: Prompt,
|
||||
_out: TokenSink,
|
||||
) -> Result<Summary, LlmError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn suggest_tags(&self, _transcript: &str) -> Result<Vec<String>, LlmError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn complete(&self, _system: &str, _user: &str) -> Result<String, LlmError> {
|
||||
Err(LlmError::Unreachable("connection refused".to_string()))
|
||||
}
|
||||
fn is_local(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
let ctx = MeetingContext {
|
||||
title: "Meeting",
|
||||
participants: &[],
|
||||
transcript_md: "transcript",
|
||||
segments: &[],
|
||||
speakers: &[],
|
||||
};
|
||||
let result = distill(&FailingProvider, &"m1".to_string(), None, &ctx).await;
|
||||
assert!(matches!(result, Err(BriefError::Llm(_))));
|
||||
}
|
||||
}
|
||||
+448
-11
@@ -10,6 +10,7 @@
|
||||
|
||||
use crate::models::{AttendeeInfo, CalendarEvent, ImportedEvent};
|
||||
use chrono::{Datelike, Duration, Local, NaiveDate, TimeZone, Timelike, Utc};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -23,11 +24,18 @@ pub enum CalError {
|
||||
Password,
|
||||
#[error("readpst isn't installed — install libpst and ensure readpst is on PATH")]
|
||||
ToolMissing,
|
||||
#[error("network request failed: {0}")]
|
||||
Network(String),
|
||||
}
|
||||
|
||||
pub struct CalImport {
|
||||
pub path: String,
|
||||
pub password: Option<String>,
|
||||
/// Date-range window (unix seconds) for a source that fetches by range
|
||||
/// (Graph's `calendarView`, M4.4); ignored by file-based sources like
|
||||
/// `PstSource`, which import everything a `.pst` contains.
|
||||
pub from: Option<i64>,
|
||||
pub to: Option<i64>,
|
||||
}
|
||||
|
||||
pub trait CalendarSource: Send + Sync {
|
||||
@@ -54,10 +62,41 @@ impl CalendarSource for PstSource {
|
||||
|
||||
let result = run_readpst(&input.path, &out_dir).and_then(|_| collect_events(&out_dir));
|
||||
let _ = std::fs::remove_dir_all(&out_dir); // best-effort cleanup either way
|
||||
result
|
||||
|
||||
// Bug fix: `from`/`to` used to be silently ignored for PST (only
|
||||
// Graph honored a range) — a long-lived mailbox has no natural
|
||||
// upper bound on history, so "import everything" meant every
|
||||
// recurring series expanded across its full lifetime (up to 500
|
||||
// occurrences each, T4.1's RECURRENCE_MAX_OCCURRENCES) plus every
|
||||
// one-off entry (e.g. a decade of Outlook's auto-generated yearly
|
||||
// holidays) the file has ever held. Filtering here, after parsing,
|
||||
// is the simplest correct place: it doesn't need to change how
|
||||
// readpst is invoked or how recurrence expansion works.
|
||||
result.map(|events| filter_by_range(events, input.from, input.to))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "pst")]
|
||||
fn filter_by_range(
|
||||
events: Vec<ImportedEvent>,
|
||||
from: Option<i64>,
|
||||
to: Option<i64>,
|
||||
) -> Vec<ImportedEvent> {
|
||||
if from.is_none() && to.is_none() {
|
||||
return events;
|
||||
}
|
||||
events
|
||||
.into_iter()
|
||||
.filter(|e| match e.event.starts_at {
|
||||
// An event with no known start time can't be range-tested —
|
||||
// keep it rather than silently drop something the user might
|
||||
// still want (this is rare; most PST appointments have a start).
|
||||
None => true,
|
||||
Some(start) => from.map_or(true, |f| start >= f) && to.map_or(true, |t| start <= t),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(feature = "pst")]
|
||||
fn run_readpst(pst_path: &str, out_dir: &Path) -> Result<(), CalError> {
|
||||
// -S: one file per item. -e: extension matches item type (.ics for
|
||||
@@ -70,12 +109,22 @@ fn run_readpst(pst_path: &str, out_dir: &Path) -> Result<(), CalError> {
|
||||
// still handled safely (collect_ics_files skips a file that doesn't
|
||||
// read as valid UTF-8 rather than erroring), just silently dropped
|
||||
// instead of correctly decoded. Revisit if that's observed in practice.
|
||||
let output = Command::new("readpst")
|
||||
.args(["-S", "-e", "-t", "a", "-o"])
|
||||
let mut cmd = Command::new("readpst");
|
||||
cmd.args(["-S", "-e", "-t", "a", "-o"])
|
||||
.arg(out_dir)
|
||||
.arg(pst_path)
|
||||
.output()
|
||||
.map_err(|e| match e.kind() {
|
||||
.arg(pst_path);
|
||||
// Bug fix: readpst.exe is a console-subsystem binary, and WhispAssist is
|
||||
// a GUI app with no console of its own — Windows was popping a brand
|
||||
// new console window for it on every import. CREATE_NO_WINDOW spawns it
|
||||
// fully headless instead; readpst's own stdout/stderr are still
|
||||
// captured normally via `.output()` below.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let output = cmd.output().map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => CalError::ToolMissing,
|
||||
_ => CalError::Open(e.to_string()),
|
||||
})?;
|
||||
@@ -121,6 +170,205 @@ fn collect_ics_files(dir: &Path, out: &mut Vec<ImportedEvent>) -> Result<(), Cal
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Microsoft Graph calendar source (M4.4, T8.9, FR-CAL-6) ----
|
||||
//
|
||||
// Opt-in, explicit-consent (OAuth 2.0 PKCE via `sync::oauth` — same identity
|
||||
// platform and token-set shape as the OneDrive sync target, ADR-0010) and
|
||||
// metadata-only per ADR-0008: subject, organizer, start/end, attendees —
|
||||
// never the event body. Uses Graph's `calendarView` endpoint, which expands
|
||||
// recurring series into concrete occurrences server-side, so unlike
|
||||
// `PstSource` there's no local RRULE expansion to do.
|
||||
#[cfg(feature = "sync")]
|
||||
pub struct GraphSource {
|
||||
pub credential_ref: String,
|
||||
}
|
||||
|
||||
// ponytail: one page (no `@odata.nextLink` follow) — plenty for a personal
|
||||
// calendar's near-term window; add pagination if a real user's date range
|
||||
// ever needs more than this in one import.
|
||||
#[cfg(feature = "sync")]
|
||||
const GRAPH_EVENTS_PAGE_SIZE: u32 = 250;
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
impl GraphSource {
|
||||
/// Graph API base — overridable via `WA_GRAPH_CALENDAR_BASE_URL` so tests
|
||||
/// can point this at a local mock. Kept 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.
|
||||
fn graph_base() -> String {
|
||||
std::env::var("WA_GRAPH_CALENDAR_BASE_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "https://graph.microsoft.com/v1.0".to_string())
|
||||
}
|
||||
|
||||
async fn fetch_events(&self, from: i64, to: i64) -> Result<Vec<ImportedEvent>, CalError> {
|
||||
let token = crate::sync::resolve_access_token("graph-calendar", &self.credential_ref)
|
||||
.await
|
||||
.map_err(|e| CalError::Network(e.to_string()))?;
|
||||
let url = format!(
|
||||
"{}/me/calendarView?startDateTime={}&endDateTime={}&$select=id,subject,organizer,start,end,attendees&$top={}",
|
||||
Self::graph_base(),
|
||||
iso_datetime(from),
|
||||
iso_datetime(to),
|
||||
GRAPH_EVENTS_PAGE_SIZE,
|
||||
);
|
||||
let resp = reqwest::Client::new()
|
||||
.get(&url)
|
||||
// Ask Graph to return every dateTime already normalized to UTC —
|
||||
// avoids needing a timezone database (same tradeoff PST parsing
|
||||
// makes: see `parse_ics_datetime`'s doc comment).
|
||||
.header("Prefer", r#"outlook.timezone="UTC""#)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CalError::Network(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(CalError::Network(format!(
|
||||
"graph calendarView returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
let body: GraphEventsResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| CalError::Parse(e.to_string()))?;
|
||||
Ok(body
|
||||
.value
|
||||
.into_iter()
|
||||
.map(graph_event_to_imported)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
impl CalendarSource for GraphSource {
|
||||
/// Sync per the trait — bridges to the async Graph call via
|
||||
/// `tauri::async_runtime::block_on`. Callers (the `import_graph_calendar`
|
||||
/// command) run this inside `spawn_blocking`, exactly like `PstSource`'s
|
||||
/// blocking subprocess call.
|
||||
fn import(&self, input: CalImport) -> Result<Vec<ImportedEvent>, CalError> {
|
||||
let now = Utc::now().timestamp();
|
||||
let from = input.from.unwrap_or(now - 30 * 86_400);
|
||||
let to = input.to.unwrap_or(now + 90 * 86_400);
|
||||
tauri::async_runtime::block_on(self.fetch_events(from, to))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[derive(Deserialize)]
|
||||
struct GraphEventsResponse {
|
||||
value: Vec<GraphEvent>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[derive(Deserialize)]
|
||||
struct GraphEvent {
|
||||
id: String,
|
||||
subject: Option<String>,
|
||||
organizer: Option<GraphOrganizer>,
|
||||
start: Option<GraphDateTime>,
|
||||
end: Option<GraphDateTime>,
|
||||
attendees: Option<Vec<GraphAttendee>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[derive(Deserialize)]
|
||||
struct GraphOrganizer {
|
||||
#[serde(rename = "emailAddress")]
|
||||
email_address: GraphEmailAddress,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[derive(Deserialize)]
|
||||
struct GraphEmailAddress {
|
||||
name: Option<String>,
|
||||
address: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[derive(Deserialize)]
|
||||
struct GraphDateTime {
|
||||
#[serde(rename = "dateTime")]
|
||||
date_time: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[derive(Deserialize)]
|
||||
struct GraphAttendee {
|
||||
#[serde(rename = "emailAddress")]
|
||||
email_address: GraphEmailAddress,
|
||||
// required|optional|resource, passed through as-is (Graph's own vocabulary
|
||||
// is a superset of the organizer|required|optional convention the rest of
|
||||
// WA uses for attendee role).
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
fn graph_event_to_imported(e: GraphEvent) -> ImportedEvent {
|
||||
let attendees = e
|
||||
.attendees
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|a| {
|
||||
let name = a
|
||||
.email_address
|
||||
.name
|
||||
.or_else(|| a.email_address.address.clone())?;
|
||||
Some(AttendeeInfo {
|
||||
name,
|
||||
email: a.email_address.address,
|
||||
role: a.kind,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let organizer = e
|
||||
.organizer
|
||||
.and_then(|o| o.email_address.name.or(o.email_address.address));
|
||||
ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
source: "graph".to_string(),
|
||||
subject: e.subject,
|
||||
organizer,
|
||||
starts_at: e.start.and_then(|s| parse_graph_datetime(&s.date_time)),
|
||||
ends_at: e.end.and_then(|s| parse_graph_datetime(&s.date_time)),
|
||||
description: None, // metadata only (FR-CAL-6) — the event body is never fetched
|
||||
raw_uid: Some(e.id),
|
||||
},
|
||||
attendees,
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a unix timestamp as the `YYYY-MM-DDTHH:MM:SS` Graph's
|
||||
/// `calendarView` query params expect.
|
||||
#[cfg(feature = "sync")]
|
||||
fn iso_datetime(unix_secs: i64) -> String {
|
||||
Utc.timestamp_opt(unix_secs, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Parses a Graph `dateTime` value (`"2026-07-01T09:00:00.0000000"`, already
|
||||
/// normalized to UTC by the `Prefer: outlook.timezone="UTC"` request header)
|
||||
/// to a unix epoch. Fixed-width slicing, not a general datetime parser — the
|
||||
/// fractional-second suffix (if any) is simply ignored.
|
||||
#[cfg(feature = "sync")]
|
||||
fn parse_graph_datetime(value: &str) -> Option<i64> {
|
||||
if value.len() < 19 {
|
||||
return None;
|
||||
}
|
||||
let year: i64 = value.get(0..4)?.parse().ok()?;
|
||||
let month: u32 = value.get(5..7)?.parse().ok()?;
|
||||
let day: u32 = value.get(8..10)?.parse().ok()?;
|
||||
let hour: u32 = value.get(11..13)?.parse().ok()?;
|
||||
let min: u32 = value.get(14..16)?.parse().ok()?;
|
||||
let sec: u32 = value.get(17..19)?.parse().ok()?;
|
||||
Some(ymd_hms_to_unix(year, month, day, hour, min, sec))
|
||||
}
|
||||
|
||||
// ---- iCalendar (RFC 5545) VEVENT parsing — pure, no I/O ----
|
||||
|
||||
fn unfold_lines(text: &str) -> Vec<String> {
|
||||
@@ -253,6 +501,17 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Bug fix: a VEVENT with no UID line used to import
|
||||
// with `raw_uid: None`, which the dedup unique index
|
||||
// (source, raw_uid) explicitly exempts (`WHERE
|
||||
// raw_uid IS NOT NULL`) — so every re-import created
|
||||
// a brand-new duplicate row for it forever. Fall back
|
||||
// to a deterministic hash of the event's own content
|
||||
// so re-imports of the same source still resolve to
|
||||
// the same key and upsert instead of duplicating.
|
||||
let raw_uid = uid.take().or_else(|| {
|
||||
Some(content_uid(&summary, &organizer, starts_at, ends_at))
|
||||
});
|
||||
events.push(ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
@@ -262,7 +521,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
starts_at,
|
||||
ends_at,
|
||||
description: description.take(),
|
||||
raw_uid: uid.take(),
|
||||
raw_uid,
|
||||
},
|
||||
attendees,
|
||||
});
|
||||
@@ -408,12 +667,37 @@ fn local_to_utc_secs(dt: chrono::NaiveDateTime) -> i64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic stand-in identity for a VEVENT that has no `UID` of its own
|
||||
/// (see the `_` arm of `parse_vevents` above), so re-running the same import
|
||||
/// resolves to the same `raw_uid` and upserts in place rather than mints a
|
||||
/// fresh duplicate row every time. Format matches migration
|
||||
/// `0008_calendar_dedup_cleanup.sql`'s SQL-side backfill exactly, so a
|
||||
/// re-import after that cleanup migration converges onto the same row it
|
||||
/// already collapsed duplicates into rather than minting one more.
|
||||
pub(crate) fn content_uid(
|
||||
subject: &Option<String>,
|
||||
organizer: &Option<String>,
|
||||
starts_at: Option<i64>,
|
||||
ends_at: Option<i64>,
|
||||
) -> String {
|
||||
format!(
|
||||
"content:{}|{}|{}|{}",
|
||||
subject.as_deref().unwrap_or(""),
|
||||
organizer.as_deref().unwrap_or(""),
|
||||
starts_at.unwrap_or(0),
|
||||
ends_at.unwrap_or(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bug fix: this used to convert to the machine's *local* timezone before
|
||||
/// formatting, so an occurrence's date (and therefore its dedup key, see
|
||||
/// `raw_uid` below) could come out differently on two imports run either
|
||||
/// side of a DST transition or a timezone change — silently producing a
|
||||
/// second row for the same occurrence on re-import. UTC is stable no matter
|
||||
/// when/where the import runs.
|
||||
fn ymd_digits(unix_secs: i64) -> String {
|
||||
match Utc.timestamp_opt(unix_secs, 0).single() {
|
||||
Some(dt) => {
|
||||
let d = dt.with_timezone(&Local).date_naive();
|
||||
format!("{:04}{:02}{:02}", d.year(), d.month(), d.day())
|
||||
}
|
||||
Some(dt) => format!("{:04}{:02}{:02}", dt.year(), dt.month(), dt.day()),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
@@ -600,6 +884,70 @@ fn ymd_hms_to_unix(year: i64, month: u32, day: u32, hour: u32, min: u32, sec: u3
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[test]
|
||||
fn parse_graph_datetime_ignores_fractional_seconds() {
|
||||
assert_eq!(
|
||||
parse_graph_datetime("2026-07-01T09:00:00.0000000"),
|
||||
Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_graph_datetime("2026-07-01T09:00:00"),
|
||||
Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0))
|
||||
);
|
||||
assert_eq!(parse_graph_datetime("not-a-date"), None);
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[test]
|
||||
fn iso_datetime_formats_for_graph_query_params() {
|
||||
assert_eq!(
|
||||
iso_datetime(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0)),
|
||||
"2026-07-01T09:00:00"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[test]
|
||||
fn graph_event_to_imported_extracts_metadata_only_no_body() {
|
||||
let event = GraphEvent {
|
||||
id: "AAMk...".to_string(),
|
||||
subject: Some("Sprint planning".to_string()),
|
||||
organizer: Some(GraphOrganizer {
|
||||
email_address: GraphEmailAddress {
|
||||
name: Some("Jordan Lee".to_string()),
|
||||
address: Some("jordan@example.com".to_string()),
|
||||
},
|
||||
}),
|
||||
start: Some(GraphDateTime {
|
||||
date_time: "2026-07-01T09:00:00.0000000".to_string(),
|
||||
}),
|
||||
end: Some(GraphDateTime {
|
||||
date_time: "2026-07-01T10:00:00.0000000".to_string(),
|
||||
}),
|
||||
attendees: Some(vec![GraphAttendee {
|
||||
email_address: GraphEmailAddress {
|
||||
name: Some("Alex Kim".to_string()),
|
||||
address: Some("alex@example.com".to_string()),
|
||||
},
|
||||
kind: Some("required".to_string()),
|
||||
}]),
|
||||
};
|
||||
let imported = graph_event_to_imported(event);
|
||||
assert_eq!(imported.event.source, "graph");
|
||||
assert_eq!(imported.event.raw_uid.as_deref(), Some("AAMk..."));
|
||||
assert_eq!(imported.event.subject.as_deref(), Some("Sprint planning"));
|
||||
assert_eq!(imported.event.organizer.as_deref(), Some("Jordan Lee"));
|
||||
assert_eq!(imported.event.description, None);
|
||||
assert_eq!(
|
||||
imported.event.starts_at,
|
||||
Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0))
|
||||
);
|
||||
assert_eq!(imported.attendees.len(), 1);
|
||||
assert_eq!(imported.attendees[0].name, "Alex Kim");
|
||||
assert_eq!(imported.attendees[0].role.as_deref(), Some("required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ymd_hms_to_unix_matches_known_epoch_values() {
|
||||
assert_eq!(ymd_hms_to_unix(1970, 1, 1, 0, 0, 0), 0);
|
||||
@@ -695,6 +1043,8 @@ END:VCALENDAR\r\n";
|
||||
let result = PstSource.import(CalImport {
|
||||
path: "Z:\\no\\such\\file.pst".to_string(),
|
||||
password: None,
|
||||
from: None,
|
||||
to: None,
|
||||
});
|
||||
assert!(matches!(result, Err(CalError::Open(_))));
|
||||
}
|
||||
@@ -809,4 +1159,91 @@ END:VEVENT\r\n";
|
||||
assert_eq!(e.event.ends_at.unwrap() - e.event.starts_at.unwrap(), 1800);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression for the duplicate-import bug: a VEVENT with no `UID` line
|
||||
/// used to get `raw_uid: None`, which the storage layer's unique index
|
||||
/// exempts from dedup entirely -- so re-importing the same source
|
||||
/// duplicated it on every single run. It must now get a stable,
|
||||
/// content-derived `raw_uid` so re-parsing the identical source resolves
|
||||
/// to the same key.
|
||||
#[test]
|
||||
fn parse_vevents_gives_a_uid_less_event_a_stable_content_based_raw_uid() {
|
||||
let ics = "BEGIN:VEVENT\r\n\
|
||||
SUMMARY:No UID here\r\n\
|
||||
DTSTART:20260112T163000Z\r\n\
|
||||
DTEND:20260112T170000Z\r\n\
|
||||
END:VEVENT\r\n";
|
||||
let first = parse_vevents(ics, "pst");
|
||||
let second = parse_vevents(ics, "pst");
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(second.len(), 1);
|
||||
assert!(first[0].event.raw_uid.is_some());
|
||||
assert_eq!(first[0].event.raw_uid, second[0].event.raw_uid);
|
||||
}
|
||||
|
||||
/// Distinct UID-less events (different subjects) must not collide onto
|
||||
/// the same content-based key.
|
||||
#[test]
|
||||
fn content_based_raw_uid_differs_for_distinct_uid_less_events() {
|
||||
let ics_a = "BEGIN:VEVENT\r\nSUMMARY:Event A\r\nDTSTART:20260112T163000Z\r\nDTEND:20260112T170000Z\r\nEND:VEVENT\r\n";
|
||||
let ics_b = "BEGIN:VEVENT\r\nSUMMARY:Event B\r\nDTSTART:20260112T163000Z\r\nDTEND:20260112T170000Z\r\nEND:VEVENT\r\n";
|
||||
let a = parse_vevents(ics_a, "pst");
|
||||
let b = parse_vevents(ics_b, "pst");
|
||||
assert_ne!(a[0].event.raw_uid, b[0].event.raw_uid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ymd_digits_is_timezone_independent() {
|
||||
// A UTC midnight timestamp must format to the same UTC calendar date
|
||||
// regardless of the machine's local timezone (the original bug:
|
||||
// this used to convert to `Local` first, so the same recurring
|
||||
// occurrence could compute a different dedup-key suffix on a machine
|
||||
// in a different timezone, or after a DST transition).
|
||||
let utc_new_year = Utc
|
||||
.with_ymd_and_hms(2026, 1, 1, 0, 0, 0)
|
||||
.unwrap()
|
||||
.timestamp();
|
||||
assert_eq!(ymd_digits(utc_new_year), "20260101");
|
||||
}
|
||||
|
||||
fn dated_event(subject: &str, starts_at: Option<i64>) -> ImportedEvent {
|
||||
ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
source: "pst".to_string(),
|
||||
subject: Some(subject.to_string()),
|
||||
organizer: None,
|
||||
starts_at,
|
||||
ends_at: starts_at.map(|s| s + 1800),
|
||||
description: None,
|
||||
raw_uid: Some(subject.to_string()),
|
||||
},
|
||||
attendees: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_range_keeps_everything_when_unbounded() {
|
||||
let events = vec![dated_event("a", Some(0)), dated_event("b", Some(1_000_000))];
|
||||
assert_eq!(filter_by_range(events.clone(), None, None).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_range_excludes_events_outside_the_window() {
|
||||
let events = vec![
|
||||
dated_event("too old", Some(100)),
|
||||
dated_event("in range", Some(500)),
|
||||
dated_event("too new", Some(900)),
|
||||
];
|
||||
let kept = filter_by_range(events, Some(200), Some(800));
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept[0].event.subject.as_deref(), Some("in range"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_range_keeps_undated_events_rather_than_guessing() {
|
||||
let events = vec![dated_event("no date", None)];
|
||||
let kept = filter_by_range(events, Some(200), Some(800));
|
||||
assert_eq!(kept.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
+2777
-111
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"));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use crate::models::{SpeakerSpan, TranscriptSegment};
|
||||
use std::path::Path;
|
||||
|
||||
pub mod models;
|
||||
pub mod voiceprint;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DiarError {
|
||||
@@ -22,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]);
|
||||
}
|
||||
@@ -52,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
|
||||
@@ -69,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)
|
||||
@@ -85,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]) {
|
||||
@@ -97,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 {
|
||||
@@ -164,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"))]
|
||||
@@ -183,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 =
|
||||
|
||||
@@ -52,6 +52,9 @@ pub fn list() -> Vec<ModelInfo> {
|
||||
// Both models are always "active" once installed — diarization
|
||||
// has no interchangeable-size picker like whisper's (yet).
|
||||
active: true,
|
||||
// Not a whisper model — the language picker (T8.7) never applies
|
||||
// to diarization's segmentation/embedding pair.
|
||||
multilingual: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Voiceprint matching: identifies which diarized speaker cluster is the
|
||||
//! meeting's own microphone, so it can be auto-labeled "You" instead of a
|
||||
//! clustered "S1"/"S2" (bug: the mic speaker wasn't reliably first/labeled).
|
||||
//! Mic and system audio are already summed into one mono stream before
|
||||
//! diarization ever runs, so the only way to tell them apart afterwards is a
|
||||
//! voiceprint: a short mic-only sample, captured live, compared by embedding
|
||||
//! similarity against each cluster's own audio from the finished recording.
|
||||
//! Runs once per meeting, entirely offline via the same sherpa-onnx
|
||||
//! speaker-embedding model diarization already uses (ADR-0005).
|
||||
|
||||
use crate::models::SpeakerSpan;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// At least this much clean audio (mic sample or candidate cluster) before an
|
||||
/// embedding computed from it is trusted at all — a fragment of a word gives
|
||||
/// an unstable embedding that's as likely to mismatch as match.
|
||||
const MIN_VOICEPRINT_SAMPLES: usize = 16_000; // 1s @ 16kHz
|
||||
|
||||
/// Per-candidate audio is capped so one very long-talking speaker doesn't
|
||||
/// blow up embedding compute time; a few seconds is already stable.
|
||||
const MAX_CANDIDATE_SAMPLES: usize = 16_000 * 10;
|
||||
|
||||
/// sherpa's own default "is this a match" similarity threshold
|
||||
/// (`speaker_id::DEFAULT_SIMILARITY_THRESHOLD`) — kept as a local constant so
|
||||
/// this module doesn't need the `diarization` feature just to state its
|
||||
/// policy (used by both the real and no-op builds' doc comments/tests).
|
||||
const SIMILARITY_THRESHOLD: f32 = 0.5;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VoiceprintError {
|
||||
#[error("model load failed: {0}")]
|
||||
Load(String),
|
||||
#[error("embedding failed: {0}")]
|
||||
Embed(String),
|
||||
#[error("failed to read the recording: {0}")]
|
||||
Read(String),
|
||||
}
|
||||
|
||||
/// The label -> display-name map to auto-apply after diarization: whichever
|
||||
/// speaker's audio matches `mic_samples` best -> `"You"`; every other label,
|
||||
/// in first-appearance order, -> `"Speaker 2"`, `"Speaker 3"`, … An empty map
|
||||
/// means "couldn't tell" (too little mic audio, no cluster cleared the
|
||||
/// similarity threshold, embedding model unavailable) — callers leave the
|
||||
/// existing "S1"/"S2" labels alone rather than guess (FR-SPK-5).
|
||||
#[cfg(feature = "diarization")]
|
||||
pub fn match_mic_speaker(
|
||||
embedding_model: &Path,
|
||||
mic_samples: &[f32],
|
||||
wav_path: &Path,
|
||||
spans: &[SpeakerSpan],
|
||||
) -> Result<HashMap<String, String>, VoiceprintError> {
|
||||
if mic_samples.len() < MIN_VOICEPRINT_SAMPLES || spans.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let labels_in_order = first_appearance_order(spans);
|
||||
|
||||
let wav_samples = crate::audio::read_wav_mono_16k(wav_path)
|
||||
.map_err(|e| VoiceprintError::Read(e.to_string()))?;
|
||||
|
||||
let mut extractor =
|
||||
sherpa_rs::speaker_id::EmbeddingExtractor::new(sherpa_rs::speaker_id::ExtractorConfig {
|
||||
model: embedding_model.to_string_lossy().to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.map_err(|e| VoiceprintError::Load(e.to_string()))?;
|
||||
|
||||
let mic_embedding = extractor
|
||||
.compute_speaker_embedding(mic_samples.to_vec(), 16_000)
|
||||
.map_err(|e| VoiceprintError::Embed(e.to_string()))?;
|
||||
|
||||
let mut best: Option<(&str, f32)> = None;
|
||||
for label in &labels_in_order {
|
||||
let candidate_samples = candidate_audio(&wav_samples, spans, label);
|
||||
if candidate_samples.len() < MIN_VOICEPRINT_SAMPLES {
|
||||
continue;
|
||||
}
|
||||
let embedding = extractor
|
||||
.compute_speaker_embedding(candidate_samples, 16_000)
|
||||
.map_err(|e| VoiceprintError::Embed(e.to_string()))?;
|
||||
let score = cosine_similarity(&mic_embedding, &embedding);
|
||||
let is_better = match best {
|
||||
Some((_, best_score)) => score > best_score,
|
||||
None => true,
|
||||
};
|
||||
if is_better {
|
||||
best = Some((label, score));
|
||||
}
|
||||
}
|
||||
|
||||
let Some((mic_label, score)) = best else {
|
||||
return Ok(HashMap::new());
|
||||
};
|
||||
if score < SIMILARITY_THRESHOLD {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
Ok(build_name_map(&labels_in_order, mic_label))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "diarization"))]
|
||||
pub fn match_mic_speaker(
|
||||
_embedding_model: &Path,
|
||||
_mic_samples: &[f32],
|
||||
_wav_path: &Path,
|
||||
_spans: &[SpeakerSpan],
|
||||
) -> Result<HashMap<String, String>, VoiceprintError> {
|
||||
Ok(HashMap::new())
|
||||
}
|
||||
|
||||
/// Distinct speaker labels in first-appearance order — spans come back from
|
||||
/// the diarizer already sorted by start time. `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()
|
||||
.filter(|s| seen.insert(s.speaker.clone()))
|
||||
.map(|s| s.speaker.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Concatenates up to `MAX_CANDIDATE_SAMPLES` of `label`'s audio out of the
|
||||
/// full 16kHz-mono recording, using each span's millisecond range.
|
||||
fn candidate_audio(wav_samples: &[f32], spans: &[SpeakerSpan], label: &str) -> Vec<f32> {
|
||||
const SAMPLES_PER_MS: u64 = 16; // 16_000 Hz / 1000
|
||||
let mut out = Vec::new();
|
||||
for span in spans.iter().filter(|s| s.speaker == label) {
|
||||
if out.len() >= MAX_CANDIDATE_SAMPLES {
|
||||
break;
|
||||
}
|
||||
let start = (span.start_ms * SAMPLES_PER_MS) as usize;
|
||||
let end = ((span.end_ms * SAMPLES_PER_MS) as usize).min(wav_samples.len());
|
||||
if start < end {
|
||||
out.extend_from_slice(&wav_samples[start..end]);
|
||||
}
|
||||
}
|
||||
out.truncate(MAX_CANDIDATE_SAMPLES);
|
||||
out
|
||||
}
|
||||
|
||||
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||
let norm_a = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let norm_b = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm_a == 0.0 || norm_b == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
dot / (norm_a * norm_b)
|
||||
}
|
||||
}
|
||||
|
||||
/// `mic_label` -> "You"; every other label, in first-appearance order ->
|
||||
/// "Speaker 2", "Speaker 3", … (numbering starts at 2 — "You" stands in for
|
||||
/// "Speaker 1" without ever being called that). `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 {
|
||||
if label == mic_label {
|
||||
names.insert(label.clone(), "You".to_string());
|
||||
} else {
|
||||
names.insert(label.clone(), format!("Speaker {next_speaker_number}"));
|
||||
next_speaker_number += 1;
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn span(start_ms: u64, end_ms: u64, speaker: &str) -> SpeakerSpan {
|
||||
SpeakerSpan {
|
||||
start_ms,
|
||||
end_ms,
|
||||
speaker: speaker.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_appearance_order_dedupes_in_encounter_order() {
|
||||
let spans = vec![
|
||||
span(0, 1000, "S2"),
|
||||
span(1000, 2000, "S1"),
|
||||
span(2000, 3000, "S2"),
|
||||
];
|
||||
assert_eq!(first_appearance_order(&spans), vec!["S2", "S1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_audio_concatenates_only_that_speakers_spans() {
|
||||
let wav: Vec<f32> = (0..32_000).map(|i| i as f32).collect(); // 2s @16kHz
|
||||
let spans = vec![
|
||||
span(0, 500, "S1"),
|
||||
span(500, 1000, "S2"),
|
||||
span(1000, 1500, "S1"),
|
||||
];
|
||||
let s1 = candidate_audio(&wav, &spans, "S1");
|
||||
// 500ms + 500ms of S1 = 1s = 16_000 samples, taken from [0,8000) and [16000,24000).
|
||||
assert_eq!(s1.len(), 16_000);
|
||||
assert_eq!(s1[0], 0.0);
|
||||
assert_eq!(s1[8000], 16_000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_audio_caps_at_the_maximum() {
|
||||
let wav: Vec<f32> = vec![0.0; MAX_CANDIDATE_SAMPLES + 10_000];
|
||||
let spans = vec![span(0, (MAX_CANDIDATE_SAMPLES as u64 + 10_000) / 16, "S1")];
|
||||
assert_eq!(
|
||||
candidate_audio(&wav, &spans, "S1").len(),
|
||||
MAX_CANDIDATE_SAMPLES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_similarity_of_identical_vectors_is_one() {
|
||||
let v = [1.0, 2.0, 3.0];
|
||||
assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_similarity_of_opposite_vectors_is_negative_one() {
|
||||
let a = [1.0, 0.0];
|
||||
let b = [-1.0, 0.0];
|
||||
assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_similarity_handles_a_zero_vector_without_dividing_by_zero() {
|
||||
let a = [0.0, 0.0];
|
||||
let b = [1.0, 1.0];
|
||||
assert_eq!(cosine_similarity(&a, &b), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_name_map_labels_the_mic_you_and_numbers_the_rest_from_two() {
|
||||
let labels = vec!["S2".to_string(), "S1".to_string(), "S3".to_string()];
|
||||
let names = build_name_map(&labels, "S1");
|
||||
assert_eq!(names.get("S1"), Some(&"You".to_string()));
|
||||
assert_eq!(names.get("S2"), Some(&"Speaker 2".to_string()));
|
||||
assert_eq!(names.get("S3"), Some(&"Speaker 3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_mic_speaker_returns_empty_when_mic_sample_is_too_short() {
|
||||
let spans = vec![span(0, 1000, "S1")];
|
||||
let names = match_mic_speaker(
|
||||
Path::new("model.onnx"),
|
||||
&[0.0; 100],
|
||||
Path::new("audio.wav"),
|
||||
&spans,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(names.is_empty());
|
||||
}
|
||||
}
|
||||
+252
-8
@@ -6,13 +6,16 @@
|
||||
|
||||
pub mod agent;
|
||||
pub mod audio;
|
||||
pub mod briefs;
|
||||
pub mod calendar;
|
||||
pub mod commands;
|
||||
pub mod deploy;
|
||||
pub mod diarization;
|
||||
pub mod error;
|
||||
pub mod hardware;
|
||||
pub mod llm;
|
||||
pub mod mcp;
|
||||
pub mod media;
|
||||
pub mod models;
|
||||
pub mod notes;
|
||||
pub mod paths;
|
||||
@@ -25,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;
|
||||
|
||||
@@ -62,6 +66,13 @@ pub struct RecordingSession {
|
||||
/// can pick a different model than `Settings.whisper_model`).
|
||||
pub active_backend: Arc<StdMutex<models::BackendId>>,
|
||||
pub model_id: String,
|
||||
/// Resolved transcription language (T8.7, FR-TRX-4): `None` = auto.
|
||||
/// Set by the transcription worker once the engine loads (to the
|
||||
/// request resolved against model capability, e.g. forced "en" for an
|
||||
/// English-only model) and updated after every decode to whatever was
|
||||
/// actually used/detected — `stop_recording` reads the final value to
|
||||
/// persist on the meeting record.
|
||||
pub language: Arc<StdMutex<Option<String>>>,
|
||||
/// `None` when diarization models aren't installed yet (T4.7) — live
|
||||
/// provisional turns and the final post-stop pass are both skipped, same
|
||||
/// graceful-degradation treatment as a missing hardware backend (T4.3).
|
||||
@@ -70,24 +81,94 @@ pub struct RecordingSession {
|
||||
/// (T4.4, FR-SPK-2). Never rewritten onto segments (FR-SPK-5); resolved
|
||||
/// at render/finalize time instead.
|
||||
pub speaker_names: Arc<StdMutex<std::collections::HashMap<String, String>>>,
|
||||
/// A few seconds of raw mic-only audio, captured once early in the
|
||||
/// recording — used at `stop_recording` to voiceprint-match the mic
|
||||
/// against the diarized speaker clusters so the mic speaker can be
|
||||
/// auto-labeled "You" instead of a clustered "S1"/"S2". `None` when the
|
||||
/// mic is disabled (same conditions as `mic_capture`).
|
||||
pub mic_voice_sample: Option<Arc<audio::VoiceSample>>,
|
||||
/// `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`
|
||||
/// and write-through persisted to `manual_notes.json` on every edit (crash
|
||||
/// safety, same spirit as T2.8 recovery); folded into the final `notes.md`
|
||||
/// at `stop_recording` via `notes::MarkdownNotes::merge`.
|
||||
pub manual_notes: Arc<StdMutex<models::ManualNotes>>,
|
||||
}
|
||||
|
||||
/// Wraps the tray icon so it can be looked up from commands to update its
|
||||
/// tooltip on recording state changes (FR-CAP-4).
|
||||
pub struct TrayHandle(pub TrayIcon);
|
||||
|
||||
/// Show a blocking native error dialog and exit non-zero. Used for failures
|
||||
/// that happen before the Tauri app (and its window) exist, where a silent
|
||||
/// `panic=abort` fast-fail would otherwise close with no explanation. Exits
|
||||
/// cleanly (code 1) rather than aborting so the OS reports no crash.
|
||||
fn fatal_startup_error(body: &str) -> ! {
|
||||
tracing::error!("fatal startup error: {body}");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
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 text = to_wide(body);
|
||||
let caption = to_wide("WhispAssist can't start");
|
||||
// SAFETY: both buffers are null-terminated and outlive the call.
|
||||
unsafe {
|
||||
MessageBoxW(
|
||||
None,
|
||||
PCWSTR(text.as_ptr()),
|
||||
PCWSTR(caption.as_ptr()),
|
||||
MB_OK | MB_ICONERROR | MB_SYSTEMMODAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
eprintln!("WhispAssist can't start: {body}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
/// Build state, register commands/events, and run the app.
|
||||
pub fn run() {
|
||||
tracing_subscriber::fmt().with_env_filter("info").init();
|
||||
|
||||
let store: Arc<dyn storage::Store> = Arc::new(
|
||||
tauri::async_runtime::block_on(storage::SqliteStore::connect())
|
||||
.expect("failed to initialize storage (wa.db)"),
|
||||
);
|
||||
let store: Arc<dyn storage::Store> =
|
||||
match tauri::async_runtime::block_on(storage::SqliteStore::connect()) {
|
||||
Ok(s) => Arc::new(s),
|
||||
// A migration checksum mismatch (an edited-after-shipping migration)
|
||||
// or a locked/corrupt file lands here. Tell the user instead of
|
||||
// fast-failing into a silent no-launch.
|
||||
Err(e) => fatal_startup_error(&format!(
|
||||
"WhispAssist couldn't open its database (wa.db):\n\n{e}\n\n\
|
||||
Your recordings and notes are not deleted. This usually means the \
|
||||
database is from a newer version of WhispAssist, or a data migration \
|
||||
is incompatible. Reinstalling the matching version, or moving wa.db \
|
||||
aside from %LOCALAPPDATA%\\WhispAssist, will let the app start."
|
||||
)),
|
||||
};
|
||||
let store_for_setup = store.clone();
|
||||
|
||||
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| {
|
||||
@@ -98,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.
|
||||
@@ -182,8 +338,13 @@ pub fn run() {
|
||||
let pst_settings = commands::load_settings();
|
||||
if pst_settings.pst_auto_sync {
|
||||
if let Some(path) = pst_settings.pst_last_path {
|
||||
if let Err(e) =
|
||||
commands::import_pst_core(&startup_app, store.as_ref(), path, None)
|
||||
if let Err(e) = commands::import_pst_core(
|
||||
&startup_app,
|
||||
store.as_ref(),
|
||||
path,
|
||||
None,
|
||||
pst_settings.pst_import_range_days,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("startup PST auto-sync failed: {e:?}");
|
||||
@@ -193,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,
|
||||
@@ -200,8 +372,11 @@ 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,
|
||||
commands::set_segment_note,
|
||||
commands::resume_transcription,
|
||||
commands::app_info,
|
||||
commands::open_url,
|
||||
@@ -209,13 +384,18 @@ 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,
|
||||
commands::download_directml_package,
|
||||
commands::list_diarization_models,
|
||||
commands::download_model,
|
||||
commands::remove_model,
|
||||
commands::reprocess_transcript,
|
||||
commands::import_media,
|
||||
commands::list_meetings,
|
||||
commands::search,
|
||||
commands::set_tags,
|
||||
@@ -226,6 +406,7 @@ pub fn run() {
|
||||
commands::update_notes,
|
||||
commands::export_meeting,
|
||||
commands::bulk_export_meetings,
|
||||
commands::import_meeting_bundle,
|
||||
commands::rename_speaker,
|
||||
commands::merge_speakers,
|
||||
commands::map_speaker_to_participant,
|
||||
@@ -234,12 +415,17 @@ 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,
|
||||
commands::cleanup_calendar_events,
|
||||
commands::list_calendar_events,
|
||||
commands::get_calendar_event,
|
||||
commands::attach_meeting_to_event,
|
||||
commands::begin_graph_calendar_link,
|
||||
commands::import_graph_calendar,
|
||||
commands::disconnect_graph_calendar,
|
||||
commands::rename_meeting,
|
||||
commands::list_sync_targets,
|
||||
commands::add_sync_target,
|
||||
@@ -269,14 +455,72 @@ 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>() {
|
||||
let _ = tray.0.set_tooltip(Some(text));
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry point for `whispassist.exe --mcp-stdio` (FR-MCP-6): serves one MCP
|
||||
/// session over this process's own stdin/stdout instead of showing a window,
|
||||
/// against the same `wa.db` the GUI instance uses. There is no Tauri
|
||||
/// `AppHandle` in this mode, so `mcp://access` events have nowhere to go —
|
||||
/// the `mcp_access_log` DB row is still written regardless (FR-MCP-5).
|
||||
pub fn run_mcp_stdio() {
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
// stderr, not stdout: stdout is the MCP JSON-RPC channel.
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter("info")
|
||||
.with_writer(std::io::stderr)
|
||||
.try_init();
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!("whispassist --mcp-stdio: failed to start a runtime: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
rt.block_on(async {
|
||||
let store: std::sync::Arc<dyn storage::Store> =
|
||||
match storage::SqliteStore::connect().await {
|
||||
Ok(s) => std::sync::Arc::new(s),
|
||||
Err(e) => {
|
||||
eprintln!("whispassist --mcp-stdio: failed to open wa.db: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let handler = mcp::handler::WaMcpHandler::new(store, None);
|
||||
if let Err(e) = mcp::stdio_transport::serve_once(handler).await {
|
||||
eprintln!("whispassist --mcp-stdio: session ended with an error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
{
|
||||
eprintln!("this build was compiled without MCP support (the `mcp` cargo feature is off)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+730
-23
@@ -46,6 +46,12 @@ pub trait LlmProvider: Send + Sync {
|
||||
/// Non-streaming — the reply is short enough that a single round trip is
|
||||
/// simpler than wiring up another token-stream event for it.
|
||||
async fn suggest_tags(&self, transcript: &str) -> Result<Vec<String>, LlmError>;
|
||||
/// One-shot, non-streaming completion (T10.6, ADR-0011): a caller-supplied
|
||||
/// system prompt + user message, one full reply back — no output-format
|
||||
/// contract of its own (unlike `summarize`'s three fixed sections), since
|
||||
/// callers like `briefs::FeatureBriefBuilder` define their own. Mirrors
|
||||
/// `suggest_tags`'s single-round-trip shape.
|
||||
async fn complete(&self, system: &str, user: &str) -> Result<String, LlmError>;
|
||||
/// True if the endpoint resolves to loopback/local (FR-LLM-6, FR-SEC-1).
|
||||
fn is_local(&self) -> bool;
|
||||
}
|
||||
@@ -61,7 +67,12 @@ const RESPONSE_FORMAT_INSTRUCTIONS: &str = "Respond in Markdown with exactly thr
|
||||
are bullet lists (each line starting with \"- \"); write \"- None\" if a section has nothing \
|
||||
to report. Do not add any other top-level sections.";
|
||||
|
||||
fn build_messages(prompt: &Prompt, user_system: Option<&str>) -> Vec<serde_json::Value> {
|
||||
/// Splits the assembled prompt into its `(system, user)` halves. Shared by
|
||||
/// every provider: OpenAI-shaped chat APIs (Ollama, OpenAI-compat) fold both
|
||||
/// into a `messages` array via `build_messages` below; Anthropic's Messages
|
||||
/// API (ADR-0011) takes `system` as its own top-level field instead, so it
|
||||
/// calls this directly.
|
||||
fn build_system_and_user(prompt: &Prompt, user_system: Option<&str>) -> (String, String) {
|
||||
let mut user = String::new();
|
||||
if !prompt.metadata.is_empty() {
|
||||
user.push_str(&prompt.metadata);
|
||||
@@ -84,6 +95,11 @@ fn build_messages(prompt: &Prompt, user_system: Option<&str>) -> Vec<serde_json:
|
||||
}
|
||||
system.push_str(RESPONSE_FORMAT_INSTRUCTIONS);
|
||||
|
||||
(system, user)
|
||||
}
|
||||
|
||||
fn build_messages(prompt: &Prompt, user_system: Option<&str>) -> Vec<serde_json::Value> {
|
||||
let (system, user) = build_system_and_user(prompt, user_system);
|
||||
vec![
|
||||
serde_json::json!({ "role": "system", "content": system }),
|
||||
serde_json::json!({ "role": "user", "content": user }),
|
||||
@@ -154,8 +170,10 @@ fn parse_summary(text: &str) -> Summary {
|
||||
}
|
||||
|
||||
/// `- text` / `* text` -> `Some("text")`; skips empty bullets and the
|
||||
/// placeholder "- None" the prompt asks for when a section is empty.
|
||||
fn bullet_text(line: &str) -> Option<String> {
|
||||
/// placeholder "- None" the prompt asks for when a section is empty. Shared
|
||||
/// with `briefs::parse_brief` — its "Acceptance Criteria" section is the same
|
||||
/// bullet-list shape as `summarize`'s Decisions/Action Items.
|
||||
pub(crate) fn bullet_text(line: &str) -> Option<String> {
|
||||
let trimmed = line.trim();
|
||||
let stripped = trimmed
|
||||
.strip_prefix("- ")
|
||||
@@ -175,10 +193,20 @@ const TAG_INSTRUCTIONS: &str = "You generate short topical tags for a meeting tr
|
||||
explanation, no quotes. Each tag: lowercase, 1-3 words, hyphenated instead of spaces (e.g. \
|
||||
\"budget-review\" not \"budget review\").";
|
||||
|
||||
/// `(system, user)` halves for the tag-suggestion prompt — see
|
||||
/// `build_system_and_user` above for why Anthropic needs these split out.
|
||||
fn tag_system_and_user(transcript: &str) -> (String, String) {
|
||||
(
|
||||
TAG_INSTRUCTIONS.to_string(),
|
||||
format!("Transcript:\n{transcript}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_tag_messages(transcript: &str) -> Vec<serde_json::Value> {
|
||||
let (system, user) = tag_system_and_user(transcript);
|
||||
vec![
|
||||
serde_json::json!({ "role": "system", "content": TAG_INSTRUCTIONS }),
|
||||
serde_json::json!({ "role": "user", "content": format!("Transcript:\n{transcript}") }),
|
||||
serde_json::json!({ "role": "system", "content": system }),
|
||||
serde_json::json!({ "role": "user", "content": user }),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -438,11 +466,84 @@ impl LlmProvider for OllamaProvider {
|
||||
))
|
||||
}
|
||||
|
||||
/// One-shot completion (T10.6) — same non-streaming `/api/chat` shape as
|
||||
/// `suggest_tags`, just with a caller-supplied system/user pair instead of
|
||||
/// the fixed tag-list prompt.
|
||||
async fn complete(&self, system: &str, user: &str) -> Result<String, LlmError> {
|
||||
let body = serde_json::json!({
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{ "role": "system", "content": system },
|
||||
{ "role": "user", "content": user },
|
||||
],
|
||||
"stream": false,
|
||||
});
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("{}/api/chat", self.base()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
|
||||
}
|
||||
let chunk: OllamaChatChunk = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::Request(e.to_string()))?;
|
||||
Ok(chunk.message.map(|m| m.content).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn is_local(&self) -> bool {
|
||||
is_local_endpoint(&self.endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hosted-provider API keys (Anthropic, and a hosted OpenAI-compatible
|
||||
/// gateway once `credential_ref` is set on `OpenAiCompatProvider`) live in
|
||||
/// the OS credential store, keyed by `credential_ref` — never in
|
||||
/// settings.json/wa.db/logs (ADR-0011, FR-SEC-1). Mirrors
|
||||
/// `sync::credentials`, with its own service name so LLM keys and sync
|
||||
/// secrets don't share a keyring namespace.
|
||||
#[cfg(feature = "sync")]
|
||||
pub mod credentials {
|
||||
use super::LlmError;
|
||||
|
||||
const SERVICE: &str = "WhispAssist-llm";
|
||||
|
||||
fn entry(credential_ref: &str) -> Result<keyring::Entry, LlmError> {
|
||||
keyring::Entry::new(SERVICE, credential_ref)
|
||||
.map_err(|e| LlmError::Unreachable(format!("credential store: {e}")))
|
||||
}
|
||||
|
||||
pub fn set(credential_ref: &str, secret: &str) -> Result<(), LlmError> {
|
||||
entry(credential_ref)?
|
||||
.set_password(secret)
|
||||
.map_err(|e| LlmError::Unreachable(format!("credential store: {e}")))
|
||||
}
|
||||
|
||||
pub fn get(credential_ref: &str) -> Result<String, LlmError> {
|
||||
entry(credential_ref)?
|
||||
.get_password()
|
||||
.map_err(|e| LlmError::Unreachable(format!("credential store: {e}")))
|
||||
}
|
||||
|
||||
pub fn delete(credential_ref: &str) -> Result<(), LlmError> {
|
||||
// Missing entry is fine — deletion is best-effort cleanup.
|
||||
match entry(credential_ref)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(LlmError::Unreachable(format!("credential store: {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed credential-store key for the Anthropic API key (ADR-0011). Only one
|
||||
/// hosted Anthropic configuration is supported at a time (single `llm_*`
|
||||
/// settings fields, like every other provider here), so a deterministic ref —
|
||||
/// rather than a per-target uuid like sync's — is enough; nothing to
|
||||
/// disambiguate.
|
||||
pub const ANTHROPIC_CREDENTIAL_REF: &str = "wa-llm-anthropic";
|
||||
|
||||
// ---- OpenAI-compatible endpoint (Phase 5's "custom" option; Phase 10a reuses ----
|
||||
// this for hosted providers by setting `credential_ref`, ADR-0011.)
|
||||
|
||||
@@ -491,10 +592,16 @@ impl OpenAiCompatProvider {
|
||||
}
|
||||
|
||||
/// Reads the API key from the OS credential store (never settings/DB —
|
||||
/// FR-SEC-1). `None` for local, unauthenticated custom endpoints.
|
||||
/// FR-SEC-1). `None` for local, unauthenticated custom endpoints (no
|
||||
/// `credential_ref` at all) or if the store lookup fails.
|
||||
#[cfg(feature = "sync")]
|
||||
fn api_key(&self) -> Option<String> {
|
||||
self.credential_ref
|
||||
.as_deref()
|
||||
.and_then(|r| credentials::get(r).ok())
|
||||
}
|
||||
#[cfg(not(feature = "sync"))]
|
||||
fn api_key(&self) -> Option<String> {
|
||||
// T10a.2: `keyring` lookup by `credential_ref` lands with hosted
|
||||
// providers; a local custom endpoint has no `credential_ref` at all.
|
||||
None
|
||||
}
|
||||
|
||||
@@ -627,6 +734,55 @@ impl LlmProvider for OpenAiCompatProvider {
|
||||
Ok(parse_tags(&full_text))
|
||||
}
|
||||
|
||||
/// One-shot completion (T10.6) — reuses `suggest_tags`'s SSE-accumulate
|
||||
/// path (this API has no simpler non-streaming reply shape) with a
|
||||
/// caller-supplied system/user pair instead of the fixed tag prompt.
|
||||
async fn complete(&self, system: &str, user: &str) -> Result<String, LlmError> {
|
||||
let body = serde_json::json!({
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{ "role": "system", "content": system },
|
||||
{ "role": "user", "content": user },
|
||||
],
|
||||
"stream": true,
|
||||
});
|
||||
let req = self.auth(
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/v1/chat/completions", self.base()))
|
||||
.json(&body),
|
||||
);
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
|
||||
}
|
||||
|
||||
let mut full_text = String::new();
|
||||
stream_lines(resp, |line| {
|
||||
let Some(payload) = line.strip_prefix("data:") else {
|
||||
return true;
|
||||
};
|
||||
let payload = payload.trim();
|
||||
if payload == "[DONE]" {
|
||||
return false;
|
||||
}
|
||||
let Ok(chunk) = serde_json::from_str::<OpenAiChatChunk>(payload) else {
|
||||
return true;
|
||||
};
|
||||
for choice in chunk.choices {
|
||||
if let Some(content) = choice.delta.content {
|
||||
full_text.push_str(&content);
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(full_text)
|
||||
}
|
||||
|
||||
fn is_local(&self) -> bool {
|
||||
self.credential_ref.is_none() && is_local_endpoint(&self.endpoint)
|
||||
}
|
||||
@@ -727,34 +883,297 @@ pub async fn pull_model(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Anthropic Messages API (`/v1/messages`) — native shape, NOT OpenAI-compatible.
|
||||
/// Anthropic Messages API version header (ADR-0011) — Anthropic's REST API is
|
||||
/// versioned by header, not URL path.
|
||||
const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
|
||||
/// `max_tokens` is required by the Messages API and WA has no per-meeting
|
||||
/// tuning surface for it yet; a fixed generous cap is enough for a summary or
|
||||
/// a short tag list (T10a.1 — revisit if a real need for a larger cap shows up).
|
||||
const ANTHROPIC_MAX_TOKENS: u32 = 4096;
|
||||
|
||||
/// Anthropic Messages API (`/v1/messages`) — native shape, NOT OpenAI-
|
||||
/// compatible (ADR-0011): `system` is a top-level field rather than a
|
||||
/// `messages[0]` entry, auth is `x-api-key` (not `Authorization: Bearer`),
|
||||
/// and it needs the `anthropic-version` header. Always hosted — `is_local()`
|
||||
/// is unconditionally `false` (unlike `OpenAiCompatProvider`, there is no
|
||||
/// unauthenticated/local mode for this API).
|
||||
pub struct AnthropicProvider {
|
||||
pub model: String,
|
||||
pub credential_ref: String,
|
||||
/// `https://api.anthropic.com` in production; overridable so tests can
|
||||
/// point this at a local mock server.
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for AnthropicProvider {
|
||||
async fn status(&self) -> LlmStatus {
|
||||
// Not built yet (Phase 10a) and not reachable today: `set_llm_provider`
|
||||
// rejects "anthropic" before settings could ever select this provider.
|
||||
LlmStatus {
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicModelsResponse {
|
||||
#[serde(default)]
|
||||
data: Vec<AnthropicModel>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicModel {
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// One line of a Messages API SSE stream, e.g.
|
||||
/// `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}`.
|
||||
/// Only `content_block_delta` events carry text; every other event type
|
||||
/// (`message_start`, `content_block_start/stop`, `message_delta`,
|
||||
/// `message_stop`, `ping`) is ignored except to detect the stream's end.
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicStreamEvent {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
delta: Option<AnthropicDelta>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct AnthropicDelta {
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
/// Non-streaming Messages API reply (used for `suggest_tags`, whose short
|
||||
/// reply doesn't need token-by-token streaming — same call shape Ollama uses).
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicMessageResponse {
|
||||
#[serde(default)]
|
||||
content: Vec<AnthropicContentBlock>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicContentBlock {
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
fn base(&self) -> &str {
|
||||
self.endpoint.trim_end_matches('/')
|
||||
}
|
||||
|
||||
/// Reads the API key from the OS credential store (never settings/DB —
|
||||
/// FR-SEC-1, ADR-0011). Unlike `OpenAiCompatProvider` this always fails
|
||||
/// closed (`Err`, not a silent `None`) — Anthropic has no unauthenticated
|
||||
/// mode, so a missing key means the request simply cannot be made.
|
||||
#[cfg(feature = "sync")]
|
||||
fn api_key(&self) -> Result<String, LlmError> {
|
||||
credentials::get(&self.credential_ref)
|
||||
}
|
||||
#[cfg(not(feature = "sync"))]
|
||||
fn api_key(&self) -> Result<String, LlmError> {
|
||||
Err(LlmError::Unreachable(
|
||||
"credential store unavailable (built without the \"sync\" feature)".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn auth(&self, req: reqwest::RequestBuilder, key: &str) -> reqwest::RequestBuilder {
|
||||
req.header("x-api-key", key)
|
||||
.header("anthropic-version", ANTHROPIC_VERSION)
|
||||
}
|
||||
|
||||
/// `status()`'s actual HTTP call, taking an already-resolved key — split
|
||||
/// out so tests can exercise the request/response shape against a mock
|
||||
/// server with a fixed key, without needing that key to actually exist
|
||||
/// in the real OS credential store (the store lookup itself is a thin,
|
||||
/// already-trusted `keyring` wrapper — see `credentials` above — not
|
||||
/// worth re-proving with an HTTP mock).
|
||||
async fn status_with_key(&self, key: &str) -> LlmStatus {
|
||||
let req = self.auth(
|
||||
reqwest::Client::new().get(format!("{}/v1/models", self.base())),
|
||||
key,
|
||||
);
|
||||
let models = match req.send().await {
|
||||
Ok(resp) if resp.status().is_success() => resp
|
||||
.json::<AnthropicModelsResponse>()
|
||||
.await
|
||||
.map(|r| r.data.into_iter().map(|m| m.id).collect())
|
||||
.unwrap_or_default(),
|
||||
_ => {
|
||||
return LlmStatus {
|
||||
provider: "anthropic".to_string(),
|
||||
reachable: false,
|
||||
is_local: false,
|
||||
models: Vec::new(),
|
||||
}
|
||||
}
|
||||
async fn summarize(&self, _prompt: Prompt, _out: TokenSink) -> Result<Summary, LlmError> {
|
||||
Err(LlmError::Request(
|
||||
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
|
||||
))
|
||||
};
|
||||
LlmStatus {
|
||||
provider: "anthropic".to_string(),
|
||||
reachable: true,
|
||||
is_local: false,
|
||||
models,
|
||||
}
|
||||
async fn suggest_tags(&self, _transcript: &str) -> Result<Vec<String>, LlmError> {
|
||||
Err(LlmError::Request(
|
||||
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `summarize()`'s actual HTTP call — see `status_with_key`.
|
||||
async fn summarize_with_key(
|
||||
&self,
|
||||
key: &str,
|
||||
prompt: Prompt,
|
||||
out: TokenSink,
|
||||
) -> Result<Summary, LlmError> {
|
||||
let (system, user) = build_system_and_user(&prompt, None);
|
||||
let body = serde_json::json!({
|
||||
"model": self.model,
|
||||
"max_tokens": ANTHROPIC_MAX_TOKENS,
|
||||
"system": system,
|
||||
"messages": [{ "role": "user", "content": user }],
|
||||
"stream": true,
|
||||
});
|
||||
let req = self.auth(
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/v1/messages", self.base()))
|
||||
.json(&body),
|
||||
key,
|
||||
);
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
|
||||
}
|
||||
|
||||
let mut full_text = String::new();
|
||||
stream_lines(resp, |line| {
|
||||
let Some(payload) = line.strip_prefix("data:") else {
|
||||
return true; // "event: ..." lines and blanks — not JSON, skip
|
||||
};
|
||||
let payload = payload.trim();
|
||||
let Ok(evt) = serde_json::from_str::<AnthropicStreamEvent>(payload) else {
|
||||
return true; // ignore an unparseable line, keep reading
|
||||
};
|
||||
if evt.kind == "content_block_delta" {
|
||||
if let Some(text) = evt.delta.and_then(|d| d.text) {
|
||||
if !text.is_empty() {
|
||||
full_text.push_str(&text);
|
||||
let _ = out.send(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
evt.kind != "message_stop"
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(parse_summary(&full_text))
|
||||
}
|
||||
|
||||
/// `suggest_tags()`'s actual HTTP call — see `status_with_key`.
|
||||
async fn suggest_tags_with_key(
|
||||
&self,
|
||||
key: &str,
|
||||
transcript: &str,
|
||||
) -> Result<Vec<String>, LlmError> {
|
||||
let (system, user) = tag_system_and_user(transcript);
|
||||
let body = serde_json::json!({
|
||||
"model": self.model,
|
||||
"max_tokens": ANTHROPIC_MAX_TOKENS,
|
||||
"system": system,
|
||||
"messages": [{ "role": "user", "content": user }],
|
||||
"stream": false,
|
||||
});
|
||||
let req = self.auth(
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/v1/messages", self.base()))
|
||||
.json(&body),
|
||||
key,
|
||||
);
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
|
||||
}
|
||||
let parsed: AnthropicMessageResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::Request(e.to_string()))?;
|
||||
let text: String = parsed
|
||||
.content
|
||||
.into_iter()
|
||||
.filter_map(|b| b.text)
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
Ok(parse_tags(&text))
|
||||
}
|
||||
|
||||
/// `complete()`'s actual HTTP call — see `status_with_key`. Unlike
|
||||
/// `summarize`/`suggest_tags`, `system`/`user` arrive pre-assembled
|
||||
/// (`FeatureBriefBuilder`'s prompt contract, M1) rather than a `Prompt`,
|
||||
/// so this skips `build_system_and_user` and posts them directly.
|
||||
async fn complete_with_key(
|
||||
&self,
|
||||
key: &str,
|
||||
system: &str,
|
||||
user: &str,
|
||||
) -> Result<String, LlmError> {
|
||||
let body = serde_json::json!({
|
||||
"model": self.model,
|
||||
"max_tokens": ANTHROPIC_MAX_TOKENS,
|
||||
"system": system,
|
||||
"messages": [{ "role": "user", "content": user }],
|
||||
"stream": false,
|
||||
});
|
||||
let req = self.auth(
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/v1/messages", self.base()))
|
||||
.json(&body),
|
||||
key,
|
||||
);
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
|
||||
}
|
||||
let parsed: AnthropicMessageResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::Request(e.to_string()))?;
|
||||
Ok(parsed
|
||||
.content
|
||||
.into_iter()
|
||||
.filter_map(|b| b.text)
|
||||
.collect::<Vec<_>>()
|
||||
.join(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for AnthropicProvider {
|
||||
async fn status(&self) -> LlmStatus {
|
||||
let Ok(key) = self.api_key() else {
|
||||
return LlmStatus {
|
||||
provider: "anthropic".to_string(),
|
||||
reachable: false,
|
||||
is_local: false,
|
||||
models: Vec::new(),
|
||||
};
|
||||
};
|
||||
self.status_with_key(&key).await
|
||||
}
|
||||
|
||||
async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result<Summary, LlmError> {
|
||||
let key = self.api_key()?;
|
||||
self.summarize_with_key(&key, prompt, out).await
|
||||
}
|
||||
|
||||
async fn suggest_tags(&self, transcript: &str) -> Result<Vec<String>, LlmError> {
|
||||
let key = self.api_key()?;
|
||||
self.suggest_tags_with_key(&key, transcript).await
|
||||
}
|
||||
|
||||
async fn complete(&self, system: &str, user: &str) -> Result<String, LlmError> {
|
||||
let key = self.api_key()?;
|
||||
self.complete_with_key(&key, system, user).await
|
||||
}
|
||||
|
||||
fn is_local(&self) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -764,6 +1183,294 @@ impl LlmProvider for AnthropicProvider {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Minimal single-request HTTP mock: binds loopback on an OS-chosen port,
|
||||
/// accepts one connection on a background thread, hands the raw request
|
||||
/// bytes back over a channel, and writes `response` (a full raw
|
||||
/// `"HTTP/1.1 ..."` response) to the socket. Mirrors the blocking-loopback
|
||||
/// pattern `sync::oauth::LoopbackRedirect` already uses for a similar
|
||||
/// reason (no HTTP mocking crate in the dependency tree) — good enough
|
||||
/// for "one provider call, one reply" test shapes, which is all
|
||||
/// `summarize`/`suggest_tags`/`status` ever do.
|
||||
struct MockServer {
|
||||
port: u16,
|
||||
captured: std::sync::mpsc::Receiver<String>,
|
||||
}
|
||||
|
||||
impl MockServer {
|
||||
fn start(response: &'static str) -> Self {
|
||||
let listener =
|
||||
std::net::TcpListener::bind("127.0.0.1:0").expect("bind a loopback test port");
|
||||
let port = listener.local_addr().expect("local_addr").port();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
use std::io::{Read, Write};
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
// A POST's headers and JSON body aren't guaranteed to
|
||||
// arrive in a single `read()` (separate writev calls can
|
||||
// land as separate TCP segments even on loopback), so
|
||||
// accumulate reads until the client goes quiet rather
|
||||
// than trusting one read to have the whole request.
|
||||
let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(300)));
|
||||
let mut request = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match stream.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break, // EOF or read-timeout: client is done sending
|
||||
Ok(n) => request.extend_from_slice(&buf[..n]),
|
||||
}
|
||||
}
|
||||
let _ = tx.send(String::from_utf8_lossy(&request).to_string());
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
}
|
||||
});
|
||||
Self { port, captured: rx }
|
||||
}
|
||||
|
||||
fn base_url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}", self.port)
|
||||
}
|
||||
|
||||
/// The raw request the mock received (headers + body), for asserting
|
||||
/// shape (path, auth header, JSON body fields).
|
||||
fn request(&self) -> String {
|
||||
self.captured
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
fn http_ok(content_type: &str, body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn drain_tokens(rx: &std::sync::mpsc::Receiver<String>) -> String {
|
||||
let mut out = String::new();
|
||||
while let Ok(tok) = rx.try_recv() {
|
||||
out.push_str(&tok);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- AnthropicProvider (M3.1/M3.2, ADR-0011) ----
|
||||
|
||||
fn anthropic_provider(endpoint: String) -> AnthropicProvider {
|
||||
AnthropicProvider {
|
||||
model: "claude-3-5-sonnet-latest".to_string(),
|
||||
credential_ref: "test-anthropic-key".to_string(),
|
||||
endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_summarize_streams_tokens_and_hits_v1_messages_with_auth_headers() {
|
||||
let sse = "event: message_start\n\
|
||||
data: {\"type\":\"message_start\"}\n\n\
|
||||
event: content_block_delta\n\
|
||||
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"## Summary\\n\"}}\n\n\
|
||||
event: content_block_delta\n\
|
||||
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Shipped M3.\\n\\n## Decisions\\n- None\\n\\n## Action Items\\n- None\\n\"}}\n\n\
|
||||
event: message_stop\n\
|
||||
data: {\"type\":\"message_stop\"}\n\n";
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
sse.len(),
|
||||
sse
|
||||
);
|
||||
let response: &'static str = Box::leak(response.into_boxed_str());
|
||||
let server = MockServer::start(response);
|
||||
let provider = anthropic_provider(server.base_url());
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
let prompt = Prompt {
|
||||
transcript: "Alice: let's ship it.".to_string(),
|
||||
metadata: "Meeting: M3 review".to_string(),
|
||||
template: None,
|
||||
};
|
||||
let summary = provider
|
||||
.summarize_with_key("test-anthropic-key", prompt, tx)
|
||||
.await
|
||||
.expect("summarize should succeed against the mock");
|
||||
|
||||
let request = server.request();
|
||||
assert!(request.starts_with("POST /v1/messages"), "{request}");
|
||||
assert!(
|
||||
request.contains("x-api-key: test-anthropic-key"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(
|
||||
request.contains(&format!("anthropic-version: {ANTHROPIC_VERSION}")),
|
||||
"{request}"
|
||||
);
|
||||
assert!(
|
||||
request.contains("\"model\":\"claude-3-5-sonnet-latest\""),
|
||||
"{request}"
|
||||
);
|
||||
assert!(request.contains("\"stream\":true"), "{request}");
|
||||
// Anthropic's own shape: system is a top-level field, not messages[0].
|
||||
assert!(request.contains("\"system\":"), "{request}");
|
||||
assert!(!request.contains("\"role\":\"system\""), "{request}");
|
||||
|
||||
assert_eq!(summary.summary_md, "Shipped M3.");
|
||||
assert_eq!(
|
||||
drain_tokens(&rx),
|
||||
"## Summary\nShipped M3.\n\n## Decisions\n- None\n\n## Action Items\n- None\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_suggest_tags_parses_the_non_streaming_reply() {
|
||||
let body = r#"{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"golang, webrtc, dtls"}],"model":"claude-3-5-sonnet-latest","stop_reason":"end_turn"}"#;
|
||||
let response = http_ok("application/json", body);
|
||||
let response: &'static str = Box::leak(response.into_boxed_str());
|
||||
let server = MockServer::start(response);
|
||||
let provider = anthropic_provider(server.base_url());
|
||||
|
||||
let tags = provider
|
||||
.suggest_tags_with_key(
|
||||
"test-anthropic-key",
|
||||
"Alice: let's talk webrtc and dtls in golang.",
|
||||
)
|
||||
.await
|
||||
.expect("suggest_tags should succeed against the mock");
|
||||
|
||||
let request = server.request();
|
||||
assert!(request.starts_with("POST /v1/messages"), "{request}");
|
||||
assert!(request.contains("\"stream\":false"), "{request}");
|
||||
assert_eq!(tags, vec!["golang", "webrtc", "dtls"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_status_reports_reachable_and_models_from_v1_models() {
|
||||
let body = r#"{"data":[{"type":"model","id":"claude-3-5-sonnet-latest","display_name":"Claude 3.5 Sonnet"}],"has_more":false}"#;
|
||||
let response = http_ok("application/json", body);
|
||||
let response: &'static str = Box::leak(response.into_boxed_str());
|
||||
let server = MockServer::start(response);
|
||||
let provider = anthropic_provider(server.base_url());
|
||||
|
||||
let status = provider.status_with_key("test-anthropic-key").await;
|
||||
let request = server.request();
|
||||
assert!(request.starts_with("GET /v1/models"), "{request}");
|
||||
assert!(
|
||||
request.contains("x-api-key: test-anthropic-key"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(status.reachable);
|
||||
assert!(!status.is_local);
|
||||
assert_eq!(status.provider, "anthropic");
|
||||
assert_eq!(status.models, vec!["claude-3-5-sonnet-latest"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_provider_is_never_local() {
|
||||
// Unlike OpenAiCompatProvider (which is local until a credential_ref
|
||||
// is set), Anthropic has no unauthenticated/local mode at all.
|
||||
let provider = anthropic_provider("http://127.0.0.1:1".to_string());
|
||||
assert!(!provider.is_local());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_status_fails_closed_when_no_credential_is_stored() {
|
||||
// Exercises the real trait-level status() (not status_with_key), i.e.
|
||||
// the actual credential-store lookup path — a fresh/unused
|
||||
// credential_ref should never be present, so this proves the "no key
|
||||
// => unreachable, no request attempted" behavior without needing to
|
||||
// seed the real OS credential store from a test.
|
||||
let provider = AnthropicProvider {
|
||||
model: "claude-3-5-sonnet-latest".to_string(),
|
||||
credential_ref: "wa-llm-anthropic-test-missing-credential-do-not-create".to_string(),
|
||||
endpoint: "http://127.0.0.1:1".to_string(), // would refuse the connection if ever hit
|
||||
};
|
||||
let status = provider.status().await;
|
||||
assert!(!status.reachable);
|
||||
assert!(!status.is_local);
|
||||
assert!(status.models.is_empty());
|
||||
}
|
||||
|
||||
// ---- OpenAiCompatProvider (already-scaffolded reference; adding the ----
|
||||
// mock-server coverage M3's acceptance criteria calls for, which didn't
|
||||
// exist yet — see openai_compat_provider_is_not_local_once_a_credential_is_set
|
||||
// above for the pre-existing pure-logic test.)
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_compat_summarize_streams_sse_tokens_from_v1_chat_completions() {
|
||||
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"## Summary\\n\"}}]}\n\n\
|
||||
data: {\"choices\":[{\"delta\":{\"content\":\"All good.\\n\\n## Decisions\\n- None\\n\\n## Action Items\\n- None\\n\"}}]}\n\n\
|
||||
data: [DONE]\n\n";
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
sse.len(),
|
||||
sse
|
||||
);
|
||||
let response: &'static str = Box::leak(response.into_boxed_str());
|
||||
let server = MockServer::start(response);
|
||||
let provider = OpenAiCompatProvider {
|
||||
endpoint: server.base_url(),
|
||||
model: "gpt-4o-mini".to_string(),
|
||||
credential_ref: None,
|
||||
};
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
let prompt = Prompt {
|
||||
transcript: "Alice: let's ship it.".to_string(),
|
||||
metadata: "Meeting: M3 review".to_string(),
|
||||
template: None,
|
||||
};
|
||||
let summary = provider
|
||||
.summarize(prompt, tx)
|
||||
.await
|
||||
.expect("summarize should succeed against the mock");
|
||||
|
||||
let request = server.request();
|
||||
assert!(
|
||||
request.starts_with("POST /v1/chat/completions"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(request.contains("\"model\":\"gpt-4o-mini\""), "{request}");
|
||||
assert!(request.contains("\"role\":\"system\""), "{request}"); // OpenAI shape: system IS a message
|
||||
assert_eq!(summary.summary_md, "All good.");
|
||||
assert_eq!(
|
||||
drain_tokens(&rx),
|
||||
"## Summary\nAll good.\n\n## Decisions\n- None\n\n## Action Items\n- None\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_compat_provider_with_unset_credential_sends_no_auth_header() {
|
||||
let sse = "data: [DONE]\n\n";
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
sse.len(),
|
||||
sse
|
||||
);
|
||||
let response: &'static str = Box::leak(response.into_boxed_str());
|
||||
let server = MockServer::start(response);
|
||||
// `credential_ref: None` is the local/unauthenticated custom-endpoint
|
||||
// case (ADR-0007) — no lookup happens at all, so this asserts the
|
||||
// request truly carries no Authorization header, unlike the
|
||||
// `Some(ref)` hosted case above.
|
||||
let provider = OpenAiCompatProvider {
|
||||
endpoint: server.base_url(),
|
||||
model: "gpt-4o-mini".to_string(),
|
||||
credential_ref: None,
|
||||
};
|
||||
assert!(provider.is_local());
|
||||
|
||||
let _ = provider.suggest_tags("hello").await;
|
||||
let request = server.request();
|
||||
assert!(
|
||||
request.starts_with("POST /v1/chat/completions"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(
|
||||
!request.to_lowercase().contains("authorization:"),
|
||||
"{request}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_summary_splits_the_three_requested_sections() {
|
||||
let text = "## Summary\nWe discussed the roadmap.\nIt went well.\n\n\
|
||||
|
||||
@@ -3,5 +3,14 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
// `whispassist.exe --mcp-stdio` (FR-MCP-6): the stdio "adapter the agent
|
||||
// spawns" is this same binary, in headless mode -- it serves one MCP
|
||||
// session over its own stdin/stdout and exits, instead of opening the
|
||||
// GUI window. A coding agent's MCP client config spawns this exact
|
||||
// command line (see `mcp_status`/`set_mcp_enabled`'s returned endpoint).
|
||||
if std::env::args().any(|a| a == "--mcp-stdio") {
|
||||
whispassist_lib::run_mcp_stdio();
|
||||
return;
|
||||
}
|
||||
whispassist_lib::run();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! `rmcp::ServerHandler` implementation — the tools-first surface (FR-MCP-2)
|
||||
//! that a connected coding agent actually calls. Every tool handler:
|
||||
//! 1. Reads the *current* scope from `Settings` (not a snapshot taken at
|
||||
//! server start) so `set_mcp_scope` takes effect immediately.
|
||||
//! 2. Logs the read (FR-MCP-5) — even when the read is denied, so the audit
|
||||
//! trail reflects what an agent *asked for*.
|
||||
//! 3. Independently re-checks scope + the recordings gate (FR-MCP-3) — there
|
||||
//! is deliberately no single choke point upstream of this file.
|
||||
|
||||
use crate::mcp::{scope, ExposeScope};
|
||||
use crate::models::MeetingId;
|
||||
use crate::storage::{MeetingFilter, Store};
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, CallToolResult, Implementation, JsonObject, ListToolsResult,
|
||||
PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool,
|
||||
};
|
||||
use rmcp::service::{RequestContext, RoleServer};
|
||||
use rmcp::{ErrorData as McpProtoError, ServerHandler};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
/// Shared handle the HTTP/stdio transports build a fresh `rmcp` service
|
||||
/// around per-connection (`ServerHandler` methods take `&self`, so this just
|
||||
/// needs to be `Clone` + cheap — it's an `Arc<Store>` and an `AppHandle`).
|
||||
/// `app` is `None` in `--mcp-stdio` mode (a separate process with no Tauri
|
||||
/// window to emit events to, see `mcp::stdio_transport`/`main.rs`) — the
|
||||
/// `mcp_access_log` row is still written either way (FR-MCP-5), only the
|
||||
/// live `"mcp://access"` event has nowhere to go.
|
||||
#[derive(Clone)]
|
||||
pub struct WaMcpHandler {
|
||||
store: Arc<dyn Store>,
|
||||
app: Option<AppHandle>,
|
||||
}
|
||||
|
||||
impl WaMcpHandler {
|
||||
pub fn new(store: Arc<dyn Store>, app: Option<AppHandle>) -> Self {
|
||||
Self { store, app }
|
||||
}
|
||||
|
||||
/// Live scope read (not cached) so `set_mcp_scope` applies without a
|
||||
/// server restart.
|
||||
fn current_scope(&self) -> (ExposeScope, bool) {
|
||||
let settings = crate::commands::load_settings();
|
||||
(
|
||||
ExposeScope::parse(&settings.mcp_expose),
|
||||
settings.mcp_expose_recordings,
|
||||
)
|
||||
}
|
||||
|
||||
async fn log_access(&self, tool: &str, meeting_id: Option<&MeetingId>, client: Option<&str>) {
|
||||
if let Err(e) = self.store.record_mcp_access(tool, meeting_id, client).await {
|
||||
tracing::warn!("failed to record mcp access log row: {e}");
|
||||
}
|
||||
if let Some(app) = &self.app {
|
||||
let _ = app.emit(
|
||||
"mcp://access",
|
||||
json!({
|
||||
"at": now_ms(),
|
||||
"tool": tool,
|
||||
"meetingId": meeting_id,
|
||||
"client": client,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn client_name(context: &RequestContext<RoleServer>) -> Option<String> {
|
||||
context
|
||||
.peer
|
||||
.peer_info()
|
||||
.map(|info| info.client_info.name.clone())
|
||||
}
|
||||
|
||||
async fn tool_list_recent_meetings(
|
||||
&self,
|
||||
args: &Option<JsonObject>,
|
||||
client: Option<&str>,
|
||||
) -> Result<CallToolResult, McpProtoError> {
|
||||
self.log_access("list_recent_meetings", None, client).await;
|
||||
let (scope_val, expose_recordings) = self.current_scope();
|
||||
if !scope::meetings_visible(scope_val) {
|
||||
return Ok(CallToolResult::structured(json!({ "meetings": [] })));
|
||||
}
|
||||
let limit = arg_u64(args, "limit").unwrap_or(20).clamp(1, 100) as usize;
|
||||
let items = self
|
||||
.store
|
||||
.list_meetings(MeetingFilter::default())
|
||||
.await
|
||||
.map_err(store_err)?;
|
||||
let mut out = Vec::with_capacity(limit);
|
||||
for item in items {
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
let Ok(full) = self.store.get_meeting(&item.id).await else {
|
||||
continue;
|
||||
};
|
||||
if !scope::recording_gate_ok(expose_recordings, full.recorded) {
|
||||
continue;
|
||||
}
|
||||
out.push(json!({
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"startedAt": item.started_at,
|
||||
"durationSecs": item.duration_secs,
|
||||
"status": item.status.as_str(),
|
||||
"tags": item.tags,
|
||||
}));
|
||||
}
|
||||
Ok(CallToolResult::structured(json!({ "meetings": out })))
|
||||
}
|
||||
|
||||
async fn tool_get_transcript(
|
||||
&self,
|
||||
args: &Option<JsonObject>,
|
||||
client: Option<&str>,
|
||||
) -> Result<CallToolResult, McpProtoError> {
|
||||
let meeting_id = arg_str(args, "meetingId")
|
||||
.ok_or_else(|| McpProtoError::invalid_params("meetingId is required", None))?;
|
||||
self.log_access("get_transcript", Some(&meeting_id), client)
|
||||
.await;
|
||||
let (scope_val, expose_recordings) = self.current_scope();
|
||||
if !scope::meetings_visible(scope_val) {
|
||||
return Ok(denied("get_transcript scope is not `all`"));
|
||||
}
|
||||
let meeting = self
|
||||
.store
|
||||
.get_meeting(&meeting_id)
|
||||
.await
|
||||
.map_err(store_err)?;
|
||||
if !scope::recording_gate_ok(expose_recordings, meeting.recorded) {
|
||||
return Ok(denied(
|
||||
"this meeting retained its recording; expose_recordings is off",
|
||||
));
|
||||
}
|
||||
Ok(CallToolResult::structured(json!({
|
||||
"meetingId": meeting.id,
|
||||
"title": meeting.title,
|
||||
"segments": meeting.segments,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn tool_get_action_items(
|
||||
&self,
|
||||
args: &Option<JsonObject>,
|
||||
client: Option<&str>,
|
||||
) -> Result<CallToolResult, McpProtoError> {
|
||||
let meeting_id = arg_str(args, "meetingId")
|
||||
.ok_or_else(|| McpProtoError::invalid_params("meetingId is required", None))?;
|
||||
self.log_access("get_action_items", Some(&meeting_id), client)
|
||||
.await;
|
||||
let (scope_val, expose_recordings) = self.current_scope();
|
||||
if !scope::meetings_visible(scope_val) {
|
||||
return Ok(denied("get_action_items scope is not `all`"));
|
||||
}
|
||||
let meeting = self
|
||||
.store
|
||||
.get_meeting(&meeting_id)
|
||||
.await
|
||||
.map_err(store_err)?;
|
||||
if !scope::recording_gate_ok(expose_recordings, meeting.recorded) {
|
||||
return Ok(denied(
|
||||
"this meeting retained its recording; expose_recordings is off",
|
||||
));
|
||||
}
|
||||
let items = self
|
||||
.store
|
||||
.list_action_items(&meeting_id)
|
||||
.await
|
||||
.map_err(store_err)?;
|
||||
Ok(CallToolResult::structured(json!({
|
||||
"meetingId": meeting_id,
|
||||
"items": items,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn tool_get_feature_brief(
|
||||
&self,
|
||||
args: &Option<JsonObject>,
|
||||
client: Option<&str>,
|
||||
) -> Result<CallToolResult, McpProtoError> {
|
||||
let id = arg_str(args, "id")
|
||||
.ok_or_else(|| McpProtoError::invalid_params("id is required", None))?;
|
||||
self.log_access("get_feature_brief", None, client).await;
|
||||
let (scope_val, _expose_recordings) = self.current_scope();
|
||||
if matches!(scope_val, ExposeScope::None) {
|
||||
return Ok(denied("MCP scope is `none`; no briefs are exposed"));
|
||||
}
|
||||
let row = match self.store.get_feature_brief_row(&id).await {
|
||||
Ok(row) => row,
|
||||
Err(e) => {
|
||||
return Ok(CallToolResult::structured_error(json!({
|
||||
"error": "storage",
|
||||
"message": e.to_string(),
|
||||
})))
|
||||
}
|
||||
};
|
||||
if !scope::brief_visible(scope_val, row.exposed) {
|
||||
return Ok(denied(
|
||||
"this brief is not exposed (toggle it on via set_brief_exposed, or set scope to `all`)",
|
||||
));
|
||||
}
|
||||
match crate::commands::get_feature_brief_core(&self.store, &id).await {
|
||||
Ok(brief) => Ok(CallToolResult::structured(
|
||||
serde_json::to_value(brief)
|
||||
.map_err(|e| McpProtoError::internal_error(e.to_string(), None))?,
|
||||
)),
|
||||
Err(e) => Ok(CallToolResult::structured_error(json!({
|
||||
"error": e.kind,
|
||||
"message": e.message,
|
||||
}))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerHandler for WaMcpHandler {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
server_info: Implementation {
|
||||
name: "whispassist".into(),
|
||||
title: Some("WhispAssist".into()),
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
description: None,
|
||||
icons: None,
|
||||
website_url: None,
|
||||
},
|
||||
instructions: Some(
|
||||
"WhispAssist meeting-assistant tools. Served data may be forwarded by this \
|
||||
agent to its own model provider outside WhispAssist's control -- WA discloses \
|
||||
this in its UI and logs every read (FR-MCP-5). Recordings (.wav) are never \
|
||||
served by any tool here."
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, McpProtoError> {
|
||||
let tools = vec![
|
||||
Tool::new(
|
||||
"list_recent_meetings",
|
||||
"Recent meetings, most recent first (scoped by the user's MCP settings).",
|
||||
obj_schema(json!({
|
||||
"type": "object",
|
||||
"properties": { "limit": { "type": "integer", "minimum": 1, "maximum": 100 } },
|
||||
"additionalProperties": false,
|
||||
})),
|
||||
),
|
||||
Tool::new(
|
||||
"get_transcript",
|
||||
"Full transcript (speaker-labeled segments) for one meeting.",
|
||||
obj_schema(json!({
|
||||
"type": "object",
|
||||
"properties": { "meetingId": { "type": "string" } },
|
||||
"required": ["meetingId"],
|
||||
"additionalProperties": false,
|
||||
})),
|
||||
),
|
||||
Tool::new(
|
||||
"get_action_items",
|
||||
"Confirmed action items for one meeting.",
|
||||
obj_schema(json!({
|
||||
"type": "object",
|
||||
"properties": { "meetingId": { "type": "string" } },
|
||||
"required": ["meetingId"],
|
||||
"additionalProperties": false,
|
||||
})),
|
||||
),
|
||||
Tool::new(
|
||||
"get_feature_brief",
|
||||
"Agent-ready spec (problem/outcome/acceptance criteria) distilled from a meeting.",
|
||||
obj_schema(json!({
|
||||
"type": "object",
|
||||
"properties": { "id": { "type": "string" } },
|
||||
"required": ["id"],
|
||||
"additionalProperties": false,
|
||||
})),
|
||||
),
|
||||
];
|
||||
Ok(ListToolsResult::with_all_items(tools))
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, McpProtoError> {
|
||||
let client = Self::client_name(&context);
|
||||
match request.name.as_ref() {
|
||||
"list_recent_meetings" => {
|
||||
self.tool_list_recent_meetings(&request.arguments, client.as_deref())
|
||||
.await
|
||||
}
|
||||
"get_transcript" => {
|
||||
self.tool_get_transcript(&request.arguments, client.as_deref())
|
||||
.await
|
||||
}
|
||||
"get_action_items" => {
|
||||
self.tool_get_action_items(&request.arguments, client.as_deref())
|
||||
.await
|
||||
}
|
||||
"get_feature_brief" => {
|
||||
self.tool_get_feature_brief(&request.arguments, client.as_deref())
|
||||
.await
|
||||
}
|
||||
other => Err(McpProtoError::invalid_params(
|
||||
format!("unknown tool: {other}"),
|
||||
None,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn obj_schema(value: Value) -> Arc<JsonObject> {
|
||||
Arc::new(value.as_object().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn arg_str(args: &Option<JsonObject>, key: &str) -> Option<String> {
|
||||
args.as_ref()?.get(key)?.as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
fn arg_u64(args: &Option<JsonObject>, key: &str) -> Option<u64> {
|
||||
args.as_ref()?.get(key)?.as_u64()
|
||||
}
|
||||
|
||||
fn store_err(e: crate::storage::StoreError) -> McpProtoError {
|
||||
McpProtoError::internal_error(e.to_string(), None)
|
||||
}
|
||||
|
||||
fn denied(reason: &str) -> CallToolResult {
|
||||
CallToolResult::structured_error(json!({ "error": "scope_denied", "message": reason }))
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Streamable HTTP transport (FR-MCP-6): loopback-only bind + a bearer-token
|
||||
//! gate that runs in front of every connection, before a single byte reaches
|
||||
//! the MCP service. `rmcp`'s `StreamableHttpService` is a bare
|
||||
//! `tower_service::Service` (not an axum app), so this module supplies the
|
||||
//! actual TCP accept loop + HTTP/1 framing via `hyper`.
|
||||
|
||||
use crate::mcp::handler::WaMcpHandler;
|
||||
use crate::mcp::{token, McpError};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{combinators::BoxBody, BodyExt, Full};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
|
||||
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
|
||||
use std::convert::Infallible;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Binds `host:port`, refusing anything that doesn't resolve to a loopback
|
||||
/// address (127.0.0.0/8 or ::1) -- the only way this crate ever opens a
|
||||
/// listening socket for MCP (FR-MCP-1, NFR-SEC-5). Kept generic over `host`
|
||||
/// purely so the refusal path is directly unit-testable; the only production
|
||||
/// caller (`mcp::server`) always passes `"127.0.0.1"`.
|
||||
pub(crate) async fn bind_loopback(host: &str, port: u16) -> Result<TcpListener, McpError> {
|
||||
let ip: IpAddr = host.parse().map_err(|_| McpError::NonLoopback)?;
|
||||
if !ip.is_loopback() {
|
||||
return Err(McpError::NonLoopback);
|
||||
}
|
||||
TcpListener::bind(SocketAddr::new(ip, port))
|
||||
.await
|
||||
.map_err(|e| McpError::Server(e.to_string()))
|
||||
}
|
||||
|
||||
/// A running HTTP server; `stop()` cancels the accept loop and all live
|
||||
/// connections and waits for cleanup.
|
||||
pub(crate) struct HttpServerHandle {
|
||||
pub local_addr: SocketAddr,
|
||||
shutdown: CancellationToken,
|
||||
join: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl HttpServerHandle {
|
||||
pub async fn stop(self) {
|
||||
self.shutdown.cancel();
|
||||
let _ = self.join.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves the MCP Streamable HTTP endpoint (`/mcp`, per the config's session
|
||||
/// routing) on an already-bound loopback listener. Every request must present
|
||||
/// `Authorization: Bearer <token>` matching the stored token (constant-time
|
||||
/// compare, `mcp::token::verify`) or it never reaches `rmcp`.
|
||||
pub(crate) fn serve(
|
||||
listener: TcpListener,
|
||||
expected_token: String,
|
||||
handler: WaMcpHandler,
|
||||
) -> HttpServerHandle {
|
||||
let local_addr = listener
|
||||
.local_addr()
|
||||
.expect("a just-bound TcpListener has a local addr");
|
||||
let shutdown = CancellationToken::new();
|
||||
|
||||
let config = StreamableHttpServerConfig {
|
||||
stateful_mode: true,
|
||||
..Default::default()
|
||||
};
|
||||
let session_manager = Arc::new(LocalSessionManager::default());
|
||||
let service = StreamableHttpService::new(move || Ok(handler.clone()), session_manager, config);
|
||||
|
||||
let accept_ct = shutdown.clone();
|
||||
let join = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = accept_ct.cancelled() => break,
|
||||
accepted = listener.accept() => {
|
||||
let Ok((stream, _peer)) = accepted else { continue };
|
||||
let io = TokioIo::new(stream);
|
||||
let svc = service.clone();
|
||||
let token = expected_token.clone();
|
||||
let conn_ct = accept_ct.clone();
|
||||
tokio::spawn(async move {
|
||||
let guarded = service_fn(move |req: Request<Incoming>| {
|
||||
let mut svc = svc.clone();
|
||||
let token = token.clone();
|
||||
async move { Ok::<_, Infallible>(handle_request(req, &mut svc, &token).await) }
|
||||
});
|
||||
let conn = hyper::server::conn::http1::Builder::new().serve_connection(io, guarded);
|
||||
tokio::select! {
|
||||
_ = conn_ct.cancelled() => {}
|
||||
_ = conn => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
HttpServerHandle {
|
||||
local_addr,
|
||||
shutdown,
|
||||
join,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_request(
|
||||
req: Request<Incoming>,
|
||||
svc: &mut StreamableHttpService<WaMcpHandler>,
|
||||
expected_token: &str,
|
||||
) -> Response<BoxBody<Bytes, Infallible>> {
|
||||
if !is_authorized(&req, expected_token) {
|
||||
return unauthorized_response();
|
||||
}
|
||||
let (parts, body) = req.into_parts();
|
||||
let req = Request::from_parts(parts, body.boxed());
|
||||
tower_service::Service::call(svc, req)
|
||||
.await
|
||||
.unwrap_or_else(|never: Infallible| match never {})
|
||||
}
|
||||
|
||||
fn is_authorized(req: &Request<Incoming>, expected_token: &str) -> bool {
|
||||
let Some(header) = req.headers().get(hyper::header::AUTHORIZATION) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(header) = header.to_str() else {
|
||||
return false;
|
||||
};
|
||||
let Some(presented) = header.strip_prefix("Bearer ") else {
|
||||
return false;
|
||||
};
|
||||
token::verify(presented, expected_token)
|
||||
}
|
||||
|
||||
fn unauthorized_response() -> Response<BoxBody<Bytes, Infallible>> {
|
||||
Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header(hyper::header::CONTENT_TYPE, "application/json")
|
||||
.body(Full::new(Bytes::from_static(b"{\"error\":\"unauthorized\"}")).boxed())
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_a_non_loopback_bind() {
|
||||
// A real routable address is never allowed regardless of port
|
||||
// availability -- the check happens before any socket syscall.
|
||||
let err = bind_loopback("8.8.8.8", 0).await.unwrap_err();
|
||||
assert!(matches!(err, McpError::NonLoopback));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_an_unparseable_host() {
|
||||
let err = bind_loopback("not-an-ip", 0).await.unwrap_err();
|
||||
assert!(matches!(err, McpError::NonLoopback));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binds_127_0_0_1_on_an_os_assigned_port() {
|
||||
let listener = bind_loopback("127.0.0.1", 0).await.expect("loopback bind");
|
||||
assert!(listener.local_addr().unwrap().ip().is_loopback());
|
||||
}
|
||||
}
|
||||
+81
-40
@@ -14,10 +14,28 @@
|
||||
use crate::models::{FeatureBrief, MeetingId};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub mod scope;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod handler;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod http_transport;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod server;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod stdio_transport;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod token;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
pub use server::RmcpServer;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum McpError {
|
||||
#[error("refusing to bind non-loopback address")]
|
||||
NonLoopback,
|
||||
#[error("unauthorized: missing or invalid token")]
|
||||
Unauthorized,
|
||||
#[error("server error: {0}")]
|
||||
Server(String),
|
||||
}
|
||||
@@ -38,7 +56,7 @@ pub struct McpConfig {
|
||||
pub expose_recordings: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum McpTransport {
|
||||
/// Streamable HTTP on http://127.0.0.1:<port>/mcp (loopback only).
|
||||
Http,
|
||||
@@ -46,24 +64,85 @@ pub enum McpTransport {
|
||||
Stdio,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
impl McpTransport {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
McpTransport::Http => "http",
|
||||
McpTransport::Stdio => "stdio",
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown/missing values fall back to `Http` — the safer default to
|
||||
/// document to the user (stdio requires a client that spawns a process).
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"stdio" => McpTransport::Stdio,
|
||||
_ => McpTransport::Http,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExposeScope {
|
||||
None,
|
||||
Selected,
|
||||
All,
|
||||
}
|
||||
|
||||
impl ExposeScope {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ExposeScope::None => "none",
|
||||
ExposeScope::Selected => "selected",
|
||||
ExposeScope::All => "all",
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown values fall back to `None` — scope-control is a privacy
|
||||
/// control, so an unparsed value must never silently become permissive.
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"selected" => ExposeScope::Selected,
|
||||
"all" => ExposeScope::All,
|
||||
_ => ExposeScope::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returned on start: where to point the agent + the token it must present.
|
||||
pub struct McpHandle {
|
||||
pub endpoint: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct McpToolDescriptor {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
/// The four tools-first-surface descriptors (FR-MCP-2), shared by the trait's
|
||||
/// default listing and anything else that needs to enumerate them without a
|
||||
/// running server (e.g. the settings/privacy UI).
|
||||
pub const TOOL_DESCRIPTORS: [McpToolDescriptor; 4] = [
|
||||
McpToolDescriptor {
|
||||
name: "list_recent_meetings",
|
||||
description: "Recent meetings (scoped).",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_transcript",
|
||||
description: "Transcript for a meeting (scoped).",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_action_items",
|
||||
description: "Action items for a meeting.",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_feature_brief",
|
||||
description: "Agent-ready spec distilled from a meeting.",
|
||||
},
|
||||
];
|
||||
|
||||
/// The MCP server. Built on the official Rust SDK (`rmcp`, feature `mcp`).
|
||||
#[async_trait]
|
||||
pub trait McpServer: Send + Sync {
|
||||
@@ -82,41 +161,3 @@ pub trait FeatureBriefBuilder: Send + Sync {
|
||||
target_repo: Option<&str>,
|
||||
) -> Result<FeatureBrief, BriefError>;
|
||||
}
|
||||
|
||||
/// Default rmcp-backed server (feature `mcp`).
|
||||
#[cfg(feature = "mcp")]
|
||||
pub struct RmcpServer;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
#[async_trait]
|
||||
impl McpServer for RmcpServer {
|
||||
async fn start(&self, _cfg: McpConfig) -> Result<McpHandle, McpError> {
|
||||
// T10.4: bind loopback ONLY (reject non-loopback), mint a token, register tools,
|
||||
// serve over Streamable HTTP (/mcp) or stdio. Never opens an outbound socket.
|
||||
todo!("Phase 10b — start MCP server (loopback, token)")
|
||||
}
|
||||
async fn stop(&self, _handle: McpHandle) -> Result<(), McpError> {
|
||||
todo!("Phase 10b — stop MCP server")
|
||||
}
|
||||
fn tools(&self) -> Vec<McpToolDescriptor> {
|
||||
// T10.5: the tools an agent can call. Tools-first for Copilot compatibility.
|
||||
vec![
|
||||
McpToolDescriptor {
|
||||
name: "list_recent_meetings",
|
||||
description: "Recent meetings (scoped).",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_transcript",
|
||||
description: "Transcript for a meeting (scoped).",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_action_items",
|
||||
description: "Action items for a meeting.",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_feature_brief",
|
||||
description: "Agent-ready spec distilled from a meeting.",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Pure scope-control logic (FR-MCP-3), split out from `mcp/mod.rs` so it's
|
||||
//! unit-testable without a DB, a running server, or the `mcp` cargo feature.
|
||||
//!
|
||||
//! Design note (documented here because the schema doesn't (yet) carry a
|
||||
//! per-meeting "expose this meeting" flag -- only `feature_briefs.exposed`
|
||||
//! does, per `docs/03-data-model.md`): with `ExposeScope::Selected`, meetings/
|
||||
//! transcripts/action-items have no selection mechanism to key off in this
|
||||
//! milestone, so they are treated the same as `None` (deny) rather than the
|
||||
//! same as `All` (allow) -- a privacy-conservative default consistent with
|
||||
//! every other WA default (recording/sync/hosted-AI/MCP itself all default
|
||||
//! OFF). Only `get_feature_brief` has real per-item selection today, via the
|
||||
//! brief's own `exposed` flag (M1). A future "select meetings" UI/schema
|
||||
//! addition should upgrade `Selected` for the other three tools without
|
||||
//! changing this function's callers.
|
||||
|
||||
use crate::mcp::ExposeScope;
|
||||
|
||||
/// Whether `list_recent_meetings`/`get_transcript`/`get_action_items` may see
|
||||
/// meetings at all under the current scope. `Selected` has no per-meeting
|
||||
/// selection mechanism yet (see module docs) so it is conservatively treated
|
||||
/// like `None`.
|
||||
pub fn meetings_visible(scope: ExposeScope) -> bool {
|
||||
matches!(scope, ExposeScope::All)
|
||||
}
|
||||
|
||||
/// Whether a specific feature brief may be served. `exposed` is the brief's
|
||||
/// own per-item flag (`feature_briefs.exposed`, set via `set_brief_exposed`).
|
||||
pub fn brief_visible(scope: ExposeScope, exposed: bool) -> bool {
|
||||
match scope {
|
||||
ExposeScope::None => false,
|
||||
ExposeScope::Selected => exposed,
|
||||
ExposeScope::All => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recordings (`.wav`) are never exposed unless explicitly allowed (FR-MCP-3),
|
||||
/// independent of `ExposeScope`. None of the four MCP tools serve raw audio
|
||||
/// bytes today, but a meeting that retained its recording (ADR-0009) is
|
||||
/// treated as more sensitive-by-association: its transcript/action items are
|
||||
/// also withheld unless the user opted into `expose_recordings`. Every tool
|
||||
/// handler must call this for each candidate meeting -- there is no central
|
||||
/// choke point (FR-MCP-3 "enforce in every tool handler").
|
||||
pub fn recording_gate_ok(expose_recordings: bool, meeting_recorded: bool) -> bool {
|
||||
expose_recordings || !meeting_recorded
|
||||
}
|
||||
|
||||
/// Combined check a tool handler runs before including one meeting's data.
|
||||
pub fn meeting_allowed(
|
||||
scope: ExposeScope,
|
||||
expose_recordings: bool,
|
||||
meeting_recorded: bool,
|
||||
) -> bool {
|
||||
meetings_visible(scope) && recording_gate_ok(expose_recordings, meeting_recorded)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn none_hides_all_meetings() {
|
||||
assert!(!meetings_visible(ExposeScope::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_hides_meetings_pending_a_selection_mechanism() {
|
||||
// Documented conservative choice -- see module docs.
|
||||
assert!(!meetings_visible(ExposeScope::Selected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_shows_meetings() {
|
||||
assert!(meetings_visible(ExposeScope::All));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brief_visibility_follows_the_exposed_flag_only_under_selected() {
|
||||
assert!(!brief_visible(ExposeScope::None, true));
|
||||
assert!(!brief_visible(ExposeScope::Selected, false));
|
||||
assert!(brief_visible(ExposeScope::Selected, true));
|
||||
assert!(brief_visible(ExposeScope::All, false));
|
||||
assert!(brief_visible(ExposeScope::All, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recordings_never_served_unless_explicitly_allowed() {
|
||||
assert!(!recording_gate_ok(false, true));
|
||||
assert!(recording_gate_ok(false, false));
|
||||
assert!(recording_gate_ok(true, true));
|
||||
assert!(recording_gate_ok(true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meeting_allowed_requires_both_scope_and_recording_gate() {
|
||||
assert!(!meeting_allowed(ExposeScope::All, false, true)); // recorded, not opted-in
|
||||
assert!(meeting_allowed(ExposeScope::All, false, false)); // not recorded
|
||||
assert!(meeting_allowed(ExposeScope::All, true, true)); // opted-in
|
||||
assert!(!meeting_allowed(ExposeScope::None, true, false)); // scope still wins
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! `RmcpServer` — the concrete `McpServer` implementation (T10.4). Owns the
|
||||
//! one running transport (HTTP listener, if any) so `stop()` can tear it
|
||||
//! down; a process-wide singleton (`instance`) is what `commands.rs` reaches
|
||||
//! for, since Tauri command handlers are separate calls with no shared state
|
||||
//! of their own beyond `AppState`.
|
||||
|
||||
use crate::mcp::handler::WaMcpHandler;
|
||||
use crate::mcp::{
|
||||
http_transport, token, McpConfig, McpError, McpHandle, McpServer, McpToolDescriptor,
|
||||
McpTransport,
|
||||
};
|
||||
use crate::storage::Store;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tauri::AppHandle;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
enum Running {
|
||||
Http(http_transport::HttpServerHandle),
|
||||
/// stdio has nothing running *in this process* — the agent spawns its
|
||||
/// own `--mcp-stdio` child (see `mcp::stdio_transport`); this variant
|
||||
/// just records "enabled" for `mcp_status`.
|
||||
Stdio,
|
||||
}
|
||||
|
||||
pub struct RmcpServer {
|
||||
store: Arc<dyn Store>,
|
||||
app: AppHandle,
|
||||
running: Mutex<Option<Running>>,
|
||||
}
|
||||
|
||||
impl RmcpServer {
|
||||
pub fn new(store: Arc<dyn Store>, app: AppHandle) -> Self {
|
||||
Self {
|
||||
store,
|
||||
app,
|
||||
running: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_running(&self) {
|
||||
if let Some(Running::Http(handle)) = self.running.lock().await.take() {
|
||||
handle.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` once a `start()` has actually taken effect (HTTP listener bound
|
||||
/// or stdio mode recorded) — used by `mcp_status`.
|
||||
pub async fn is_running(&self) -> bool {
|
||||
self.running.lock().await.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl McpServer for RmcpServer {
|
||||
async fn start(&self, cfg: McpConfig) -> Result<McpHandle, McpError> {
|
||||
// Re-enabling (or switching transport/port) replaces whatever was running.
|
||||
self.stop_running().await;
|
||||
let auth_token = token::mint_and_store()?;
|
||||
|
||||
match cfg.transport {
|
||||
McpTransport::Http => {
|
||||
let listener = http_transport::bind_loopback("127.0.0.1", cfg.port).await?;
|
||||
let handler = WaMcpHandler::new(self.store.clone(), Some(self.app.clone()));
|
||||
let handle = http_transport::serve(listener, auth_token.clone(), handler);
|
||||
let endpoint = format!("http://{}/mcp", handle.local_addr);
|
||||
*self.running.lock().await = Some(Running::Http(handle));
|
||||
Ok(McpHandle {
|
||||
endpoint,
|
||||
token: auth_token,
|
||||
})
|
||||
}
|
||||
McpTransport::Stdio => {
|
||||
*self.running.lock().await = Some(Running::Stdio);
|
||||
let exe = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.to_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "whispassist.exe".to_string());
|
||||
Ok(McpHandle {
|
||||
endpoint: format!("{exe} --mcp-stdio"),
|
||||
token: auth_token,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop(&self, _handle: McpHandle) -> Result<(), McpError> {
|
||||
self.stop_running().await;
|
||||
token::delete();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tools(&self) -> Vec<McpToolDescriptor> {
|
||||
crate::mcp::TOOL_DESCRIPTORS.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
static INSTANCE: OnceLock<Arc<RmcpServer>> = OnceLock::new();
|
||||
|
||||
/// The process-wide `RmcpServer`. `store`/`app` are only used on the first
|
||||
/// call (they're the same `AppState`/`AppHandle` for the process's whole
|
||||
/// life); later calls just return the existing instance.
|
||||
pub fn instance(store: Arc<dyn Store>, app: AppHandle) -> Arc<RmcpServer> {
|
||||
INSTANCE
|
||||
.get_or_init(|| Arc::new(RmcpServer::new(store, app)))
|
||||
.clone()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! stdio transport (FR-MCP-6) — "a thin adapter the agent spawns". A coding
|
||||
//! agent's MCP client config spawns `whispassist.exe --mcp-stdio` and talks
|
||||
//! JSON-RPC over that child process's stdin/stdout; `main.rs` checks for that
|
||||
//! flag before building the Tauri window and calls `serve_once` here instead.
|
||||
//!
|
||||
//! There is no bearer-token header to check here (unlike HTTP): the ability
|
||||
//! to spawn this process at all already requires the same OS-level privilege
|
||||
//! as running any other local command as the signed-in user, so process-spawn
|
||||
//! capability is the trust boundary for stdio, same as other local-only MCP
|
||||
//! servers. `set_mcp_enabled` still mints/stores a token (`mcp::token`) for
|
||||
//! parity with the HTTP transport and in case a future stdio client wants to
|
||||
//! pass it, but this transport does not require presenting it.
|
||||
|
||||
use crate::mcp::handler::WaMcpHandler;
|
||||
use crate::mcp::McpError;
|
||||
use rmcp::ServiceExt;
|
||||
|
||||
/// Serves one MCP session over the current process's stdin/stdout until the
|
||||
/// peer disconnects, then returns.
|
||||
pub async fn serve_once(handler: WaMcpHandler) -> Result<(), McpError> {
|
||||
let transport = rmcp::transport::io::stdio();
|
||||
let running = handler
|
||||
.serve(transport)
|
||||
.await
|
||||
.map_err(|e| McpError::Server(e.to_string()))?;
|
||||
running
|
||||
.waiting()
|
||||
.await
|
||||
.map_err(|e| McpError::Server(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! MCP auth-token storage (FR-MCP-1/6). The token itself is **never** written
|
||||
//! to `settings.json`/`wa.db`/logs — only the OS credential store, exactly
|
||||
//! like sync secrets (`sync::credentials`) and hosted-AI API keys.
|
||||
|
||||
use crate::mcp::McpError;
|
||||
|
||||
const SERVICE: &str = "WhispAssist-mcp";
|
||||
const ACCOUNT: &str = "token";
|
||||
const TOKEN_BYTES: usize = 32;
|
||||
|
||||
fn entry() -> Result<keyring::Entry, McpError> {
|
||||
keyring::Entry::new(SERVICE, ACCOUNT).map_err(|e| McpError::Server(e.to_string()))
|
||||
}
|
||||
|
||||
/// Generates a fresh random token (hex-encoded, 64 chars) and persists it,
|
||||
/// replacing whatever was there before (each `set_mcp_enabled` mints a new
|
||||
/// one — there is no "reveal the existing token" path, same treatment as a
|
||||
/// password).
|
||||
pub fn mint_and_store() -> Result<String, McpError> {
|
||||
let mut buf = [0u8; TOKEN_BYTES];
|
||||
getrandom::getrandom(&mut buf).map_err(|e| McpError::Server(e.to_string()))?;
|
||||
let token = hex_encode(&buf);
|
||||
entry()?
|
||||
.set_password(&token)
|
||||
.map_err(|e| McpError::Server(e.to_string()))?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Best-effort read for `mcp_status`'s `tokenSet` flag — never returned to
|
||||
/// the frontend as a value, only whether one exists.
|
||||
pub fn is_set() -> bool {
|
||||
entry()
|
||||
.and_then(|e| {
|
||||
e.get_password()
|
||||
.map_err(|e| McpError::Server(e.to_string()))
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub fn get() -> Result<String, McpError> {
|
||||
entry()?
|
||||
.get_password()
|
||||
.map_err(|e| McpError::Server(e.to_string()))
|
||||
}
|
||||
|
||||
/// Best-effort cleanup on disable — a missing entry is not an error.
|
||||
pub fn delete() {
|
||||
if let Ok(e) = entry() {
|
||||
match e.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
||||
Err(err) => tracing::warn!("failed to delete MCP token: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant-time comparison so token checking doesn't leak timing
|
||||
/// information about how many leading bytes matched (NFR-SEC-5).
|
||||
pub fn verify(presented: &str, expected: &str) -> bool {
|
||||
let a = presented.as_bytes();
|
||||
let b = expected.as_bytes();
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn verify_requires_exact_match() {
|
||||
assert!(verify("abc123", "abc123"));
|
||||
assert!(!verify("abc123", "abc124"));
|
||||
assert!(!verify("abc12", "abc123"));
|
||||
assert!(!verify("", "abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_encode_is_lowercase_and_fixed_width() {
|
||||
assert_eq!(hex_encode(&[0, 255, 16]), "00ff10");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Media import: turn an arbitrary local audio/video file, a direct media URL,
|
||||
//! or a streaming/YouTube page URL into WhispAssist's canonical 16kHz-mono
|
||||
//! 16-bit WAV, so it can go through the same transcription/diarization path as a
|
||||
//! live recording (manual "add a meeting from a file/URL" feature).
|
||||
//!
|
||||
//! Two external tools do the work and are deliberately **not bundled** (same
|
||||
//! call as `readpst` for .pst, ADR-0008): they must be installed and on PATH.
|
||||
//! - `ffmpeg` transcodes whatever we have to the target WAV.
|
||||
//! - `yt-dlp` resolves URLs (YouTube and other sites via its extractors, and
|
||||
//! direct media URLs via its generic extractor) down to an audio file that
|
||||
//! ffmpeg can then convert.
|
||||
//!
|
||||
//! A missing tool surfaces as a clear, named error rather than a generic
|
||||
//! "program not found".
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MediaError {
|
||||
#[error("{0} isn't installed or on PATH — install it and try again")]
|
||||
ToolMissing(&'static str),
|
||||
#[error("{tool} failed: {message}")]
|
||||
Failed { tool: &'static str, message: String },
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Whether `source` should be resolved as a URL (via yt-dlp) rather than opened
|
||||
/// as a local file path.
|
||||
pub fn is_url(source: &str) -> bool {
|
||||
let s = source.trim_start();
|
||||
s.starts_with("http://") || s.starts_with("https://")
|
||||
}
|
||||
|
||||
/// A `Command` for `program` that, on Windows, never pops a console window —
|
||||
/// WhispAssist is a GUI app with no console of its own (same fix as `readpst`).
|
||||
fn command(program: &str) -> Command {
|
||||
let mut cmd = Command::new(program);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Run `cmd`, mapping a missing binary to `ToolMissing(program)` and a non-zero
|
||||
/// exit to `Failed` carrying the tail of the tool's output (where the real
|
||||
/// error message from ffmpeg/yt-dlp lives — both are verbose).
|
||||
fn run(program: &'static str, cmd: &mut Command) -> Result<(), MediaError> {
|
||||
let output = cmd.output().map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => MediaError::ToolMissing(program),
|
||||
_ => MediaError::Io(e),
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let text = if stderr.trim().is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
let tail: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
let start = tail.len().saturating_sub(6);
|
||||
return Err(MediaError::Failed {
|
||||
tool: program,
|
||||
message: tail[start..].join("\n"),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transcode `source` (a local file path or a URL) into a 16kHz-mono 16-bit WAV
|
||||
/// at `dest_wav`. URLs are first fetched with yt-dlp into `work_dir` (which the
|
||||
/// caller creates and cleans up), then ffmpeg converts whatever landed. Blocks;
|
||||
/// run it off the async runtime.
|
||||
pub fn import_to_wav(source: &str, dest_wav: &Path, work_dir: &Path) -> Result<(), MediaError> {
|
||||
if is_url(source) {
|
||||
// bestaudio keeps the download small; ffmpeg does the actual 16kHz-mono
|
||||
// conversion in one predictable pass. `--no-part` avoids a leftover
|
||||
// `.part` file so `first_file_in` finds the finished download.
|
||||
let template = work_dir.join("download.%(ext)s");
|
||||
let mut cmd = command("yt-dlp");
|
||||
cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-part", "-o"])
|
||||
.arg(&template)
|
||||
.arg(source);
|
||||
run("yt-dlp", &mut cmd)?;
|
||||
let downloaded = first_file_in(work_dir)?.ok_or(MediaError::Failed {
|
||||
tool: "yt-dlp",
|
||||
message: "no media file was produced".into(),
|
||||
})?;
|
||||
ffmpeg_to_wav(&downloaded, dest_wav)
|
||||
} else {
|
||||
let input = Path::new(source);
|
||||
if !input.exists() {
|
||||
return Err(MediaError::Failed {
|
||||
tool: "import",
|
||||
message: format!("file not found: {source}"),
|
||||
});
|
||||
}
|
||||
ffmpeg_to_wav(input, dest_wav)
|
||||
}
|
||||
}
|
||||
|
||||
/// ffmpeg: any input → 16kHz mono 16-bit PCM WAV (drops video, matches the
|
||||
/// format `audio::read_wav_mono_16k` and the diarizer both expect).
|
||||
fn ffmpeg_to_wav(input: &Path, dest_wav: &Path) -> Result<(), MediaError> {
|
||||
let mut cmd = command("ffmpeg");
|
||||
cmd.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
|
||||
.arg(input)
|
||||
.args(["-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le"])
|
||||
.arg(dest_wav);
|
||||
run("ffmpeg", &mut cmd)
|
||||
}
|
||||
|
||||
/// The first regular file in `dir` (the single yt-dlp download; the caller uses
|
||||
/// a fresh temp dir per import so there's nothing else there).
|
||||
fn first_file_in(dir: &Path) -> Result<Option<PathBuf>, MediaError> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_file() {
|
||||
return Ok(Some(entry.path()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_url_distinguishes_urls_from_paths() {
|
||||
assert!(is_url("https://youtube.com/watch?v=abc"));
|
||||
assert!(is_url("http://example.com/a.mp4"));
|
||||
assert!(is_url(" https://leading-space.example/x")); // trimmed
|
||||
assert!(!is_url(r"C:\Users\me\meeting.mp4"));
|
||||
assert!(!is_url("/home/me/meeting.m4a"));
|
||||
assert!(!is_url("meeting.wav"));
|
||||
assert!(!is_url("ftp://not-http.example/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_reports_a_missing_local_file_without_touching_a_tool() {
|
||||
let dir = std::env::temp_dir().join(format!("wa-media-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let err = import_to_wav("no-such-file.mp4", &dir.join("out.wav"), &dir).unwrap_err();
|
||||
assert!(matches!(err, MediaError::Failed { tool: "import", .. }));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,19 @@ pub struct ModelInfo {
|
||||
pub size_mb: u32, // approximate download size
|
||||
pub installed: bool,
|
||||
pub active: bool,
|
||||
/// `false` for the `.en` (English-only) ggml variants; `true` for the
|
||||
/// multilingual variants (no `.en` suffix, T8.7/FR-TRX-4/M4.2) — gates
|
||||
/// whether the Settings language picker is enabled for this model.
|
||||
pub multilingual: bool,
|
||||
}
|
||||
|
||||
/// One selectable transcription language (T8.7, FR-TRX-4) — ISO-639-1 code
|
||||
/// (as accepted by `whisper_rs::FullParams::set_language`) plus a display
|
||||
/// label for the Settings dropdown.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LanguageOption {
|
||||
pub code: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -101,6 +114,41 @@ pub struct SpeakerInfo {
|
||||
pub participant_id: Option<String>,
|
||||
}
|
||||
|
||||
/// A user-typed note attached to a moment in the recording, anchored by
|
||||
/// timestamp rather than segment id — a segment id can be invalidated by a
|
||||
/// later batch re-transcription (T3.8), but the moment in time it pointed at
|
||||
/// never changes. `text: ""` marks a cleared note (kept rather than removed
|
||||
/// so `updated_at` still reflects the clear).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SegmentNote {
|
||||
pub anchor_ms: u64,
|
||||
pub text: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// On-disk shape of `manual_notes.json` (`docs/03-data-model.md`) — the raw
|
||||
/// user-authored input a live recording accumulates (freeform notes typed
|
||||
/// while recording, plus any per-moment annotations), kept distinct from the
|
||||
/// transcript-derived `notes.md` so a re-render never has to guess which
|
||||
/// parts of `notes.md` were hand-written.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManualNotes {
|
||||
pub schema: u32,
|
||||
pub freeform_md: String,
|
||||
pub segment_notes: Vec<SegmentNote>,
|
||||
}
|
||||
|
||||
impl Default for ManualNotes {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema: 1,
|
||||
freeform_md: String::new(),
|
||||
segment_notes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A diarization result span before alignment to transcript segments.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpeakerSpan {
|
||||
@@ -145,6 +193,34 @@ pub struct ActionItem {
|
||||
pub reminder_set: bool,
|
||||
}
|
||||
|
||||
/// Portable meeting export manifest — the `meeting.json` inside an export
|
||||
/// bundle folder (FR-STORE-4). Carries everything needed to reconstruct a
|
||||
/// meeting on another machine alongside the bundle's files (`audio.wav`,
|
||||
/// `transcript.json`, `notes.md`, `summary.json`). Deliberately excludes the
|
||||
/// meeting id (a fresh one is minted on import to avoid collisions) and the
|
||||
/// calendar-event link (event ids are machine-local).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeetingBundle {
|
||||
pub schema: u32,
|
||||
pub title: String,
|
||||
pub started_at: i64,
|
||||
pub ended_at: Option<i64>,
|
||||
pub duration_secs: Option<i64>,
|
||||
pub language: Option<String>,
|
||||
pub backend_used: Option<String>,
|
||||
pub model_used: Option<String>,
|
||||
pub recorded: bool,
|
||||
pub template_id: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub speakers: Vec<SpeakerInfo>,
|
||||
pub action_items: Vec<ActionItem>,
|
||||
/// `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)]
|
||||
pub struct CalendarEvent {
|
||||
pub id: String,
|
||||
@@ -198,10 +274,26 @@ pub struct Settings {
|
||||
pub llm_advanced: serde_json::Value,
|
||||
pub preferred_backend: String, // auto|npu|nvidia|amd|intel|cpu
|
||||
pub whisper_model: String, // ModelInfo.id, e.g. "base.en-q5_1"
|
||||
/// Global default transcription language (T8.7, FR-TRX-4): `None`/`"auto"`
|
||||
/// lets whisper.cpp auto-detect; an ISO-639-1 code (e.g. "es") forces
|
||||
/// that language. Only takes effect with a multilingual model loaded —
|
||||
/// an English-only (`.en`) model always decodes English regardless of
|
||||
/// this setting (see `transcription::resolve_language`). Each meeting
|
||||
/// persists whatever was actually used at `Meeting.language`, so this is
|
||||
/// just the default applied at the next `start_recording`.
|
||||
#[serde(default)]
|
||||
pub whisper_language: Option<String>,
|
||||
pub low_overhead: bool,
|
||||
// Recording retention (ADR-0009). Default OFF.
|
||||
pub default_record: bool,
|
||||
pub consent_acknowledged: bool,
|
||||
/// One-time "data leaves your device" acknowledgment for hosted
|
||||
/// (non-local) AI providers — Anthropic, or a hosted OpenAI-compatible
|
||||
/// gateway (ADR-0011, T10.3/M3.3). Shown once before first hosted use;
|
||||
/// this flag is what makes it not nag every time. Independent of
|
||||
/// `consent_acknowledged` (that one's specifically about recording law).
|
||||
#[serde(default)]
|
||||
pub hosted_ai_acknowledged: bool,
|
||||
// Sync master switch (ADR-0010). Default OFF. Target rows live in the DB; secrets in OS keychain.
|
||||
pub sync_enabled: bool,
|
||||
// Storage retention policy (FR-STORE-2). None = no cap on that dimension.
|
||||
@@ -213,6 +305,27 @@ pub struct Settings {
|
||||
pub pst_last_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pst_auto_sync: bool,
|
||||
/// How far back to import (days before "now"); `None` = full mailbox
|
||||
/// history (the original, unbounded behavior). Applied to both a manual
|
||||
/// Import click and the `pst_auto_sync` startup re-import — a long-lived
|
||||
/// mailbox otherwise re-imports its entire multi-year history (every
|
||||
/// recurring series expanded to its cap, every one-off holiday entry
|
||||
/// Outlook ever generated) on every launch.
|
||||
#[serde(default)]
|
||||
pub pst_import_range_days: Option<u32>,
|
||||
/// Auto-start recording when a calendar event begins while the app is open
|
||||
/// (FR-CAL, opt-in). OFF by default. No background timer runs for this: the
|
||||
/// UI arms a single one-shot timer to the next event while the app is open
|
||||
/// and disarms it on close, so idle resource use stays at zero (NFR-RES-1).
|
||||
#[serde(default)]
|
||||
pub auto_record_calendar: bool,
|
||||
// Microsoft Graph calendar source (M4.4, T8.9, ADR-0008, FR-CAL-6). Opt-in,
|
||||
// explicit consent via OAuth PKCE — off by default. The credential ref
|
||||
// points into the OS credential store; the token itself never lives here.
|
||||
#[serde(default)]
|
||||
pub graph_calendar_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub graph_calendar_credential_ref: Option<String>,
|
||||
// Audio capture device override (FR-CAP-1). `Device::get_id()` string;
|
||||
// None = system default render device (loopback / system audio).
|
||||
#[serde(default)]
|
||||
@@ -226,6 +339,44 @@ pub struct Settings {
|
||||
// default capture device.
|
||||
#[serde(default)]
|
||||
pub audio_input_device: Option<String>,
|
||||
// Local MCP server (Phase 10b, ADR-0011, FR-MCP-1). OFF by default; the
|
||||
// auth token itself is NEVER stored here — only in the OS credential
|
||||
// store (see `mcp::token`). `mcp_expose` is one of none|selected|all;
|
||||
// `mcp_expose_recordings` gates access to meetings with retained audio
|
||||
// (ADR-0009) regardless of `mcp_expose` (FR-MCP-3).
|
||||
#[serde(default)]
|
||||
pub mcp_enabled: bool,
|
||||
#[serde(default = "default_mcp_transport")]
|
||||
pub mcp_transport: String, // http|stdio
|
||||
#[serde(default = "default_mcp_port")]
|
||||
pub mcp_port: u16,
|
||||
#[serde(default = "default_mcp_expose")]
|
||||
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 {
|
||||
"http".into()
|
||||
}
|
||||
|
||||
fn default_mcp_port() -> u16 {
|
||||
4849
|
||||
}
|
||||
|
||||
fn default_mcp_expose() -> String {
|
||||
"none".into()
|
||||
}
|
||||
|
||||
/// serde default for a `bool` field that should be `true` when absent from an
|
||||
|
||||
+183
-7
@@ -3,7 +3,7 @@
|
||||
//! Renders speaker-tagged Markdown from transcript + speaker names (+ optional
|
||||
//! summary). Names are resolved here from the mapping; segments keep internal IDs.
|
||||
|
||||
use crate::models::{SpeakerInfo, TranscriptSegment};
|
||||
use crate::models::{ManualNotes, SegmentNote, SpeakerInfo, TranscriptSegment};
|
||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -101,15 +101,47 @@ pub trait NotesRenderer: Send + Sync {
|
||||
|
||||
pub struct MarkdownNotes;
|
||||
|
||||
impl NotesRenderer for MarkdownNotes {
|
||||
fn to_markdown(
|
||||
impl MarkdownNotes {
|
||||
/// Bug fix / redesign: `notes.md` used to be generated *only* from the
|
||||
/// transcript (`to_markdown`), then blindly overwritten on every
|
||||
/// finalize/speaker-rename, discarding anything the user typed. `merge`
|
||||
/// is what `stop_recording` now calls instead — it folds in whatever was
|
||||
/// captured live in `manual` (freeform notes typed during the meeting,
|
||||
/// plus any per-moment annotations) alongside the transcript, so the
|
||||
/// generated document isn't transcript-only and isn't a single
|
||||
/// same-speaker-collapsed blob (`## Notes` / `## Transcript` sections,
|
||||
/// with each annotation placed right after the paragraph it points at).
|
||||
pub fn merge(
|
||||
&self,
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
manual: &ManualNotes,
|
||||
summary_md: Option<&str>,
|
||||
template: Option<&NoteTemplate>,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
push_prelude(&mut out, summary_md, template);
|
||||
|
||||
let freeform = manual.freeform_md.trim();
|
||||
if !freeform.is_empty() {
|
||||
out.push_str("## Notes\n\n");
|
||||
out.push_str(freeform);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
let transcript = transcript_with_notes(segments, speakers, &manual.segment_notes);
|
||||
if !transcript.is_empty() {
|
||||
out.push_str("## Transcript\n\n");
|
||||
out.push_str(&transcript);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
out.trim_end().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Template section scaffold + summary block shared by `to_markdown` and `merge`.
|
||||
fn push_prelude(out: &mut String, summary_md: Option<&str>, template: Option<&NoteTemplate>) {
|
||||
if let Some(template) = template {
|
||||
for section in &template.sections {
|
||||
out.push_str(&format!("## {section}\n\n"));
|
||||
@@ -122,7 +154,16 @@ impl NotesRenderer for MarkdownNotes {
|
||||
out.push_str("\n\n---\n\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups consecutive same-speaker segments into `(start_ms, end_ms,
|
||||
/// "**Name:** text")` paragraphs — the ms range is each paragraph's span in
|
||||
/// the original recording, used by `transcript_with_notes` to place a
|
||||
/// per-moment note right after the paragraph it was anchored to.
|
||||
fn dialogue_paragraphs(
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
) -> Vec<(u64, u64, String)> {
|
||||
let name_for = |label: &str| -> String {
|
||||
speakers
|
||||
.iter()
|
||||
@@ -131,10 +172,10 @@ impl NotesRenderer for MarkdownNotes {
|
||||
.unwrap_or_else(|| label.to_string())
|
||||
};
|
||||
|
||||
// Group consecutive segments from the same speaker into one paragraph
|
||||
// (matters once Phase 4 diarization produces more than one speaker).
|
||||
let mut out = Vec::new();
|
||||
let mut current_speaker: Option<&str> = None;
|
||||
let mut buffer = String::new();
|
||||
let mut range: (u64, u64) = (0, 0);
|
||||
for seg in segments {
|
||||
let text = seg.text.trim();
|
||||
if text.is_empty() {
|
||||
@@ -142,18 +183,88 @@ impl NotesRenderer for MarkdownNotes {
|
||||
}
|
||||
if current_speaker != Some(seg.speaker.as_str()) {
|
||||
if let Some(speaker) = current_speaker {
|
||||
out.push_str(&format!("**{}:** {}\n\n", name_for(speaker), buffer.trim()));
|
||||
out.push((
|
||||
range.0,
|
||||
range.1,
|
||||
format!("**{}:** {}", name_for(speaker), buffer.trim()),
|
||||
));
|
||||
}
|
||||
current_speaker = Some(seg.speaker.as_str());
|
||||
buffer.clear();
|
||||
range = (seg.start_ms, seg.end_ms);
|
||||
}
|
||||
if !buffer.is_empty() {
|
||||
buffer.push(' ');
|
||||
}
|
||||
buffer.push_str(text);
|
||||
range.1 = seg.end_ms;
|
||||
}
|
||||
if let Some(speaker) = current_speaker {
|
||||
out.push_str(&format!("**{}:** {}\n\n", name_for(speaker), buffer.trim()));
|
||||
out.push((
|
||||
range.0,
|
||||
range.1,
|
||||
format!("**{}:** {}", name_for(speaker), buffer.trim()),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The dialogue paragraphs, each followed by a `> 📝` blockquote for any
|
||||
/// `segment_notes` whose `anchor_ms` falls inside that paragraph's span.
|
||||
/// A note whose anchor doesn't land inside any paragraph (its segment fell
|
||||
/// in a gap, or vanished in a later re-transcription) still isn't dropped —
|
||||
/// it surfaces under "Other notes" at the end instead of silently
|
||||
/// disappearing.
|
||||
fn transcript_with_notes(
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
segment_notes: &[SegmentNote],
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
let mut matched = vec![false; segment_notes.len()];
|
||||
for (start, end, paragraph) in dialogue_paragraphs(segments, speakers) {
|
||||
out.push_str(¶graph);
|
||||
out.push_str("\n\n");
|
||||
for (i, note) in segment_notes.iter().enumerate() {
|
||||
let text = note.text.trim();
|
||||
if text.is_empty() || note.anchor_ms < start || note.anchor_ms > end {
|
||||
continue;
|
||||
}
|
||||
out.push_str(&format!("> 📝 {text}\n\n"));
|
||||
matched[i] = true;
|
||||
}
|
||||
}
|
||||
|
||||
let orphans: Vec<&str> = segment_notes
|
||||
.iter()
|
||||
.zip(matched.iter())
|
||||
.filter(|(n, was_matched)| !**was_matched && !n.text.trim().is_empty())
|
||||
.map(|(n, _)| n.text.trim())
|
||||
.collect();
|
||||
if !orphans.is_empty() {
|
||||
out.push_str("### Other notes\n\n");
|
||||
for text in orphans {
|
||||
out.push_str(&format!("> 📝 {text}\n\n"));
|
||||
}
|
||||
}
|
||||
|
||||
out.trim_end().to_string()
|
||||
}
|
||||
|
||||
impl NotesRenderer for MarkdownNotes {
|
||||
fn to_markdown(
|
||||
&self,
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
summary_md: Option<&str>,
|
||||
template: Option<&NoteTemplate>,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
push_prelude(&mut out, summary_md, template);
|
||||
|
||||
for (_, _, paragraph) in dialogue_paragraphs(segments, speakers) {
|
||||
out.push_str(¶graph);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
out.trim_end().to_string()
|
||||
@@ -350,6 +461,71 @@ mod tests {
|
||||
assert_eq!(md, "## Discussion\n\n## Action Items\n\n**S1:** Hi");
|
||||
}
|
||||
|
||||
fn note(anchor_ms: u64, text: &str) -> SegmentNote {
|
||||
SegmentNote {
|
||||
anchor_ms,
|
||||
text: text.to_string(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_with_no_manual_notes_matches_to_markdown() {
|
||||
let segments = vec![seg(0, "S1", "Hello"), seg(1, "S2", "Hi there.")];
|
||||
let manual = ManualNotes::default();
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Transcript\n\n**S1:** Hello\n\n**S2:** Hi there."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_adds_a_notes_section_for_freeform_text() {
|
||||
let segments = vec![seg(0, "S1", "Hello")];
|
||||
let manual = ManualNotes {
|
||||
freeform_md: "Remember to follow up.".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Notes\n\nRemember to follow up.\n\n## Transcript\n\n**S1:** Hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_places_a_segment_note_right_after_its_paragraph() {
|
||||
// seg 0 spans 0-900ms ("S1"); anchor 500 falls inside it.
|
||||
let segments = vec![seg(0, "S1", "Hello"), seg(1, "S2", "Hi there.")];
|
||||
let manual = ManualNotes {
|
||||
segment_notes: vec![note(500, "circle back on this")],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Transcript\n\n**S1:** Hello\n\n> \u{1f4dd} circle back on this\n\n**S2:** Hi there."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_surfaces_an_unmatched_anchor_under_other_notes() {
|
||||
let segments = vec![seg(0, "S1", "Hello")]; // spans 0-900ms
|
||||
let manual = ManualNotes {
|
||||
segment_notes: vec![note(50_000, "way outside any paragraph")],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Transcript\n\n**S1:** Hello\n\n### Other notes\n\n> \u{1f4dd} way outside any paragraph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_omits_empty_sections_for_an_empty_meeting() {
|
||||
let manual = ManualNotes::default();
|
||||
assert_eq!(MarkdownNotes.merge(&[], &[], &manual, None, None), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_to_blocks_parses_heading_bold_prefix_and_task_list() {
|
||||
let md = "## Summary\n\n**Alice:** Hello world.\n\n- [ ] Follow up\n- [x] Done thing";
|
||||
|
||||
@@ -29,6 +29,13 @@ pub fn meeting_dir(id: &MeetingId) -> PathBuf {
|
||||
meetings_dir().join(id)
|
||||
}
|
||||
|
||||
/// Raw user-authored notes (freeform + per-moment annotations) accumulated
|
||||
/// live during a recording — see `models::ManualNotes`. Distinct from the
|
||||
/// derived `notes.md` the same directory holds after finalize.
|
||||
pub fn manual_notes_file(id: &MeetingId) -> PathBuf {
|
||||
meeting_dir(id).join("manual_notes.json")
|
||||
}
|
||||
|
||||
pub fn models_dir() -> PathBuf {
|
||||
wa_root().join("models")
|
||||
}
|
||||
|
||||
+799
-51
File diff suppressed because it is too large
Load Diff
+2065
-41
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,15 @@ pub fn provider_for(kind: &str) -> Option<OAuthProvider> {
|
||||
scopes: &["root_readwrite"],
|
||||
client_id_env: "WA_OAUTH_BOX_CLIENT_ID",
|
||||
}),
|
||||
// Same identity platform as "onedrive", read-only calendar scope only
|
||||
// (M4.4, T8.9, FR-CAL-6) — WA never requests file/mail access here.
|
||||
"graph-calendar" => Some(OAuthProvider {
|
||||
kind: "graph-calendar",
|
||||
auth_endpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
token_endpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
scopes: &["offline_access", "Calendars.Read", "User.Read"],
|
||||
client_id_env: "WA_OAUTH_GRAPH_CALENDAR_CLIENT_ID",
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Selectable transcription languages (T8.7, FR-TRX-4, M4.2) — the Settings
|
||||
//! language dropdown shown when a multilingual model is active.
|
||||
//!
|
||||
//! ponytail: a fixed table, not a runtime query against whisper.cpp's
|
||||
//! `whisper_lang_str`/`whisper_lang_max_id` — the ~100-language set whisper.cpp
|
||||
//! ships is effectively static (OpenAI's Whisper `tokenizer.py` LANGUAGES
|
||||
//! table), and hardcoding it here means the list is available to the UI even
|
||||
//! before any model is loaded (no `cpu-transcription` feature dependency).
|
||||
|
||||
use crate::models::LanguageOption;
|
||||
|
||||
/// `(ISO-639-1 code, display label)`, exactly the codes whisper.cpp accepts
|
||||
/// via `whisper_full_params.language`.
|
||||
const LANGUAGES: &[(&str, &str)] = &[
|
||||
("en", "English"),
|
||||
("zh", "Chinese"),
|
||||
("de", "German"),
|
||||
("es", "Spanish"),
|
||||
("ru", "Russian"),
|
||||
("ko", "Korean"),
|
||||
("fr", "French"),
|
||||
("ja", "Japanese"),
|
||||
("pt", "Portuguese"),
|
||||
("tr", "Turkish"),
|
||||
("pl", "Polish"),
|
||||
("ca", "Catalan"),
|
||||
("nl", "Dutch"),
|
||||
("ar", "Arabic"),
|
||||
("sv", "Swedish"),
|
||||
("it", "Italian"),
|
||||
("id", "Indonesian"),
|
||||
("hi", "Hindi"),
|
||||
("fi", "Finnish"),
|
||||
("vi", "Vietnamese"),
|
||||
("he", "Hebrew"),
|
||||
("uk", "Ukrainian"),
|
||||
("el", "Greek"),
|
||||
("ms", "Malay"),
|
||||
("cs", "Czech"),
|
||||
("ro", "Romanian"),
|
||||
("da", "Danish"),
|
||||
("hu", "Hungarian"),
|
||||
("ta", "Tamil"),
|
||||
("no", "Norwegian"),
|
||||
("th", "Thai"),
|
||||
("ur", "Urdu"),
|
||||
("hr", "Croatian"),
|
||||
("bg", "Bulgarian"),
|
||||
("lt", "Lithuanian"),
|
||||
("la", "Latin"),
|
||||
("mi", "Maori"),
|
||||
("ml", "Malayalam"),
|
||||
("cy", "Welsh"),
|
||||
("sk", "Slovak"),
|
||||
("te", "Telugu"),
|
||||
("fa", "Persian"),
|
||||
("lv", "Latvian"),
|
||||
("bn", "Bengali"),
|
||||
("sr", "Serbian"),
|
||||
("az", "Azerbaijani"),
|
||||
("sl", "Slovenian"),
|
||||
("kn", "Kannada"),
|
||||
("et", "Estonian"),
|
||||
("mk", "Macedonian"),
|
||||
("br", "Breton"),
|
||||
("eu", "Basque"),
|
||||
("is", "Icelandic"),
|
||||
("hy", "Armenian"),
|
||||
("ne", "Nepali"),
|
||||
("mn", "Mongolian"),
|
||||
("bs", "Bosnian"),
|
||||
("kk", "Kazakh"),
|
||||
("sq", "Albanian"),
|
||||
("sw", "Swahili"),
|
||||
("gl", "Galician"),
|
||||
("mr", "Marathi"),
|
||||
("pa", "Punjabi"),
|
||||
("si", "Sinhala"),
|
||||
("km", "Khmer"),
|
||||
("sn", "Shona"),
|
||||
("yo", "Yoruba"),
|
||||
("so", "Somali"),
|
||||
("af", "Afrikaans"),
|
||||
("oc", "Occitan"),
|
||||
("ka", "Georgian"),
|
||||
("be", "Belarusian"),
|
||||
("tg", "Tajik"),
|
||||
("sd", "Sindhi"),
|
||||
("gu", "Gujarati"),
|
||||
("am", "Amharic"),
|
||||
("yi", "Yiddish"),
|
||||
("lo", "Lao"),
|
||||
("uz", "Uzbek"),
|
||||
("fo", "Faroese"),
|
||||
("ht", "Haitian Creole"),
|
||||
("ps", "Pashto"),
|
||||
("tk", "Turkmen"),
|
||||
("nn", "Nynorsk"),
|
||||
("mt", "Maltese"),
|
||||
("sa", "Sanskrit"),
|
||||
("lb", "Luxembourgish"),
|
||||
("my", "Myanmar"),
|
||||
("bo", "Tibetan"),
|
||||
("tl", "Tagalog"),
|
||||
("mg", "Malagasy"),
|
||||
("as", "Assamese"),
|
||||
("tt", "Tatar"),
|
||||
("haw", "Hawaiian"),
|
||||
("ln", "Lingala"),
|
||||
("ha", "Hausa"),
|
||||
("ba", "Bashkir"),
|
||||
("jw", "Javanese"),
|
||||
("su", "Sundanese"),
|
||||
("yue", "Cantonese"),
|
||||
];
|
||||
|
||||
/// The dropdown's contents (`list_whisper_languages` command) — "Auto-detect"
|
||||
/// itself is not in this list; the frontend prepends it (maps to `None`/no
|
||||
/// `language` argument).
|
||||
pub fn list() -> Vec<LanguageOption> {
|
||||
LANGUAGES
|
||||
.iter()
|
||||
.map(|(code, label)| LanguageOption {
|
||||
code: code.to_string(),
|
||||
label: label.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether `code` is a known whisper.cpp language code (case-insensitive).
|
||||
/// Used to validate an explicit selection before it's forced into
|
||||
/// `FullParams::set_language` — an unrecognized code is still passed through
|
||||
/// to whisper.cpp (it may support codes we haven't listed), but callers use
|
||||
/// this to warn rather than silently accept a typo.
|
||||
pub fn is_known(code: &str) -> bool {
|
||||
LANGUAGES.iter().any(|(c, _)| c.eq_ignore_ascii_case(code))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn list_includes_english_and_common_languages() {
|
||||
let langs = list();
|
||||
assert!(langs.iter().any(|l| l.code == "en" && l.label == "English"));
|
||||
assert!(langs.iter().any(|l| l.code == "es"));
|
||||
assert!(langs.iter().any(|l| l.code == "fr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codes_are_unique() {
|
||||
let mut codes: Vec<&str> = LANGUAGES.iter().map(|(c, _)| *c).collect();
|
||||
let before = codes.len();
|
||||
codes.sort_unstable();
|
||||
codes.dedup();
|
||||
assert_eq!(before, codes.len(), "duplicate language code in catalog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_known_is_case_insensitive() {
|
||||
assert!(is_known("en"));
|
||||
assert!(is_known("ES"));
|
||||
assert!(!is_known("xx-not-a-real-code"));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use crate::models::{BackendId, TranscriptSegment};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
pub mod languages;
|
||||
pub mod models;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -29,7 +30,13 @@ pub struct AudioWindow {
|
||||
pub type SegmentSink = Sender<TranscriptSegment>;
|
||||
|
||||
pub trait Transcriber: Send + Sync {
|
||||
fn load(model: &Path, backend: BackendId) -> Result<Self, TrxError>
|
||||
/// `language` (T8.7, FR-TRX-4): `None` or `Some("auto")` requests
|
||||
/// auto-detection; an explicit ISO-639-1 code (e.g. `"es"`) forces that
|
||||
/// language. Engines that can't honor a request (an English-only model,
|
||||
/// or an engine with no language selection at all, like the NPU/ONNX
|
||||
/// path) resolve it against their own capability at load time rather
|
||||
/// than erroring — see `resolve_language` and `effective_language`.
|
||||
fn load(model: &Path, backend: BackendId, language: Option<&str>) -> Result<Self, TrxError>
|
||||
where
|
||||
Self: Sized;
|
||||
/// Streaming: emit interim + final segments for a window (FR-TRX-2).
|
||||
@@ -37,6 +44,58 @@ pub trait Transcriber: Send + Sync {
|
||||
/// Batch: one-shot over a whole file, higher accuracy — also the crash-recovery
|
||||
/// path (FR-TRX-3, T2.8): re-run over the working `audio.wav` from scratch.
|
||||
fn transcribe_file(&self, wav: &Path) -> Result<Vec<TranscriptSegment>, TrxError>;
|
||||
|
||||
/// The language this engine is actually configured to decode, resolved
|
||||
/// against model capability at `load` time — `None` means auto-detect.
|
||||
/// Default: engines with no language selection of their own (the
|
||||
/// NPU/ONNX path, whose ONNX artifacts are exported English-only) always
|
||||
/// decode English.
|
||||
fn effective_language(&self) -> Option<String> {
|
||||
Some("en".to_string())
|
||||
}
|
||||
|
||||
/// The language actually used/detected on the most recent decode, if the
|
||||
/// engine surfaces one (whisper.cpp does, via `full_lang_id_from_state`).
|
||||
/// `None` until at least one decode has completed, or if the engine
|
||||
/// doesn't support detection reporting at all.
|
||||
fn detected_language(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a requested language against model capability: an English-only
|
||||
/// (`.en`) whisper.cpp model can only ever decode English, so an explicit
|
||||
/// non-English request is forced to `"en"` (with a warning) rather than
|
||||
/// being silently honored into a garbage transcript — the T8.7/FR-TRX-4
|
||||
/// "no silent footgun" requirement. `None`/`"auto"` always means
|
||||
/// auto-detect, regardless of model, since that's harmless either way
|
||||
/// (whisper.cpp itself forces English internally for a non-multilingual
|
||||
/// model even when `language` is unset).
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
fn resolve_language(requested: Option<&str>, multilingual: bool) -> Option<String> {
|
||||
match requested {
|
||||
None => None,
|
||||
Some(l) if l.eq_ignore_ascii_case("auto") => None,
|
||||
Some(l) if l.eq_ignore_ascii_case("en") => Some("en".to_string()),
|
||||
Some(l) if !multilingual => {
|
||||
tracing::warn!(
|
||||
"language '{l}' requested but the loaded model is English-only; forcing 'en'"
|
||||
);
|
||||
Some("en".to_string())
|
||||
}
|
||||
Some(l) => Some(l.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Segments whisper.cpp itself flags as more likely silence than speech are
|
||||
/// dropped rather than emitted (see `run_full`) — whisper.cpp's CLI ships
|
||||
/// this same 0.6 default for `--no-speech-thold`.
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
const NO_SPEECH_THRESHOLD: f32 = 0.6;
|
||||
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
fn is_likely_speech(no_speech_probability: f32) -> bool {
|
||||
no_speech_probability <= NO_SPEECH_THRESHOLD
|
||||
}
|
||||
|
||||
/// whisper.cpp-backed transcriber (CPU baseline; GPU via Cargo features).
|
||||
@@ -44,6 +103,14 @@ pub trait Transcriber: Send + Sync {
|
||||
pub struct WhisperTranscriber {
|
||||
ctx: whisper_rs::WhisperContext,
|
||||
next_id: std::sync::atomic::AtomicU64,
|
||||
/// What gets passed to `FullParams::set_language` on every decode —
|
||||
/// resolved once at `load` (see `resolve_language`), not re-resolved per
|
||||
/// window/file, since a whole recording session uses one language.
|
||||
configured_language: Option<String>,
|
||||
/// The language whisper.cpp actually used on the most recent `full()`
|
||||
/// call (`whisper_full_lang_id_from_state`), updated after every decode
|
||||
/// so "auto" mode has something concrete to persist (T8.7, FR-TRX-4).
|
||||
last_detected_language: std::sync::Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
@@ -85,6 +152,9 @@ impl WhisperTranscriber {
|
||||
params.set_print_timestamps(false);
|
||||
params.set_suppress_blank(true);
|
||||
params.set_single_segment(single_segment);
|
||||
// T8.7/FR-TRX-4: `None` here means auto-detect, matching
|
||||
// `configured_language`'s resolved meaning (see `resolve_language`).
|
||||
params.set_language(self.configured_language.as_deref());
|
||||
|
||||
if single_segment {
|
||||
// whisper.cpp's encoder always runs over a full, padded 30s mel
|
||||
@@ -100,6 +170,18 @@ impl WhisperTranscriber {
|
||||
.full(params, samples)
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
|
||||
// T8.7/FR-TRX-4: record whatever language whisper.cpp actually used
|
||||
// for this decode (explicit request or auto-detected) so "auto" mode
|
||||
// has a concrete value to persist per meeting. Best-effort — an
|
||||
// unrecognized lang id (or a poisoned mutex) just leaves the last
|
||||
// known value in place rather than failing the transcription.
|
||||
let lang_id = state.full_lang_id_from_state();
|
||||
if let Some(code) = whisper_rs::get_lang_str(lang_id) {
|
||||
if let Ok(mut last) = self.last_detected_language.lock() {
|
||||
*last = Some(code.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// A forced single_segment's reported end_timestamp() reflects
|
||||
// whisper.cpp's internal 30s-padded mel frame, not the real window
|
||||
// length — confirmed even with `duration_ms` set, so don't trust it.
|
||||
@@ -114,6 +196,15 @@ impl WhisperTranscriber {
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// whisper.cpp's own `no_speech_thold` gate is a no-op (unimplemented
|
||||
// upstream as of whisper-rs 0.16 / whisper.cpp v1.3.0+), so silent/
|
||||
// near-silent windows still decode — and greedy short-window decode
|
||||
// reliably hallucinates a short filler word ("you", "Thank you.")
|
||||
// instead of emitting nothing. Drop those ourselves: matches
|
||||
// whisper.cpp's own CLI default threshold for "this was silence".
|
||||
if !is_likely_speech(seg.no_speech_probability()) {
|
||||
continue;
|
||||
}
|
||||
// Whisper timestamps are centiseconds (10ms units).
|
||||
let start_ms = offset_ms + seg.start_timestamp().max(0) as u64 * 10;
|
||||
let end_ms = if single_segment {
|
||||
@@ -145,16 +236,19 @@ impl Transcriber for WhisperTranscriber {
|
||||
/// so `use_gpu` on a CPU-only build is a harmless no-op — this stays a
|
||||
/// single code path either way rather than branching on which features
|
||||
/// were compiled in.
|
||||
fn load(model: &Path, backend: BackendId) -> Result<Self, TrxError> {
|
||||
fn load(model: &Path, backend: BackendId, language: Option<&str>) -> Result<Self, TrxError> {
|
||||
let params = whisper_rs::WhisperContextParameters {
|
||||
use_gpu: !matches!(backend, BackendId::Cpu),
|
||||
..Default::default()
|
||||
};
|
||||
let ctx = whisper_rs::WhisperContext::new_with_params(model, params)
|
||||
.map_err(|e| TrxError::Load(e.to_string()))?;
|
||||
let configured_language = resolve_language(language, ctx.is_multilingual());
|
||||
Ok(Self {
|
||||
ctx,
|
||||
next_id: std::sync::atomic::AtomicU64::new(0),
|
||||
configured_language,
|
||||
last_detected_language: std::sync::Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -178,12 +272,37 @@ impl Transcriber for WhisperTranscriber {
|
||||
crate::audio::read_wav_mono_16k(wav).map_err(|e| TrxError::Load(e.to_string()))?;
|
||||
self.run_full(&samples, 0, false)
|
||||
}
|
||||
|
||||
fn effective_language(&self) -> Option<String> {
|
||||
self.configured_language.clone()
|
||||
}
|
||||
|
||||
fn detected_language(&self) -> Option<String> {
|
||||
self.last_detected_language
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
@@ -212,63 +331,215 @@ pub mod onnx_models;
|
||||
#[cfg(feature = "npu")]
|
||||
pub use npu::OnnxTranscriber;
|
||||
|
||||
/// Streaming window worker (Phase 1, T1.5/T1.6): accumulates raw 16kHz-mono
|
||||
/// chunks from the `audio` service into fixed-size, **non-overlapping** windows
|
||||
/// and runs one `transcribe_stream` pass per window as it fills, forwarding
|
||||
/// each produced segment to `on_segment` (e.g. a Tauri event emit).
|
||||
const STREAM_SAMPLE_RATE: usize = 16_000;
|
||||
|
||||
/// Cadence/length knobs for the live streaming worker (`Streamer`). Tuned for a
|
||||
/// fluid transcript that shows words ~1s after they're spoken and breaks lines
|
||||
/// at natural pauses rather than on a fixed clock.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct StreamTuning {
|
||||
/// How often the growing window is re-decoded (and the interim line
|
||||
/// refreshed). Lower = more responsive, but more CPU (each decode re-runs
|
||||
/// whisper over the whole in-progress window).
|
||||
pub step_ms: u64,
|
||||
/// Hard cap on an uncommitted window: once the current line reaches this
|
||||
/// without a natural pause, it's force-committed so the window (and its
|
||||
/// per-step decode cost) can't grow without bound.
|
||||
pub max_window_ms: u64,
|
||||
/// Don't commit a line shorter than this on a detected pause — avoids
|
||||
/// chopping a brief hesitation into its own one-word line.
|
||||
pub min_commit_ms: u64,
|
||||
}
|
||||
|
||||
impl StreamTuning {
|
||||
/// `low_overhead` doubles the decode step (halving CPU) at the cost of a
|
||||
/// slightly less immediate transcript — matches the Settings "low overhead"
|
||||
/// preset (CPU + smallest model, for battery/background use).
|
||||
pub fn new(low_overhead: bool) -> Self {
|
||||
Self {
|
||||
step_ms: if low_overhead { 2000 } else { 1000 },
|
||||
max_window_ms: 12_000,
|
||||
min_commit_ms: if low_overhead { 2000 } else { 1500 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StreamTuning {
|
||||
fn default() -> Self {
|
||||
Self::new(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental live-transcript state machine, independent of any transcription
|
||||
/// engine (a `decode: &[f32] -> String` closure is injected) so its
|
||||
/// commit/interim logic is unit-testable without whisper.
|
||||
///
|
||||
/// `transcribe_stream` takes a `SegmentSink` per the `Transcriber` trait (so a
|
||||
/// future async/threaded engine can push mid-inference), but whisper.cpp's
|
||||
/// `full()` call is synchronous — by the time a window's `transcribe_stream`
|
||||
/// call returns, every segment it produced is already sitting in a fresh
|
||||
/// per-window channel, so this drains it inline rather than needing a second
|
||||
/// long-lived thread just to bridge segments out.
|
||||
/// The model: keep one growing "uncommitted" window of audio. Every `step_ms`,
|
||||
/// re-decode the whole window and emit it as an **interim** segment with a
|
||||
/// stable id — so the current line grows in place (fluid, low-latency) instead
|
||||
/// of popping in whole every few seconds. When the decoded text stops changing
|
||||
/// for a step (the speaker paused, so the extra audio was silence) the line is
|
||||
/// **committed** (`interim = false`, same id) and a fresh window/line begins —
|
||||
/// so lines break at natural sentence pauses, not on a fixed 4s clock. A
|
||||
/// `max_window_ms` backstop force-commits a pause-free monologue.
|
||||
///
|
||||
/// True incremental/partial-word streaming (and window overlap for continuity)
|
||||
/// are out of scope for Phase 1: whisper.cpp transcribes each window from
|
||||
/// scratch, so an overlapping window would re-emit the overlapped words a
|
||||
/// second time with no stitching logic to merge them — a worse rough edge for
|
||||
/// a live transcript than the occasional word clipped at a window boundary.
|
||||
/// "Near real time" (FR-TRX-2) is met by short (~4s) windows; `interim` stays
|
||||
/// `false` for every segment produced here.
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
pub fn run_streaming_worker<T, F>(transcriber: &T, frame_rx: Receiver<Vec<f32>>, mut on_segment: F)
|
||||
/// whisper.cpp isn't a true streaming recognizer (it re-decodes from scratch),
|
||||
/// so this trades CPU — the growing window is re-decoded every step — for a
|
||||
/// natural-looking transcript. `audio_ctx_for_window` keeps each decode's cost
|
||||
/// proportional to the window length rather than a full 30s encode, and
|
||||
/// committing on pauses keeps the window short for conversational speech.
|
||||
pub struct Streamer {
|
||||
step_len: usize,
|
||||
max_len: usize,
|
||||
min_commit_len: usize,
|
||||
window: Vec<f32>,
|
||||
committed_offset_ms: u64,
|
||||
since_last_decode: usize,
|
||||
next_id: u64,
|
||||
last_text: String,
|
||||
}
|
||||
|
||||
impl Streamer {
|
||||
pub fn new(tuning: StreamTuning) -> Self {
|
||||
let per_ms = STREAM_SAMPLE_RATE / 1000;
|
||||
Self {
|
||||
step_len: tuning.step_ms as usize * per_ms,
|
||||
max_len: tuning.max_window_ms as usize * per_ms,
|
||||
min_commit_len: tuning.min_commit_ms as usize * per_ms,
|
||||
window: Vec::new(),
|
||||
committed_offset_ms: 0,
|
||||
since_last_decode: 0,
|
||||
next_id: 0,
|
||||
last_text: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append newly captured audio; decode + emit once a step's worth has
|
||||
/// accumulated. `decode(samples, offset_ms)` returns the transcript of the
|
||||
/// window so far (empty for silence).
|
||||
pub fn feed<D, F>(&mut self, chunk: &[f32], decode: &D, on_segment: &mut F)
|
||||
where
|
||||
D: Fn(&[f32], u64) -> String,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
self.window.extend_from_slice(chunk);
|
||||
self.since_last_decode += chunk.len();
|
||||
if self.since_last_decode >= self.step_len {
|
||||
self.since_last_decode = 0;
|
||||
self.tick(false, decode, on_segment);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit whatever's in flight — called once when capture stops so the last
|
||||
/// in-progress line is finalized rather than left interim.
|
||||
pub fn flush<D, F>(&mut self, decode: &D, on_segment: &mut F)
|
||||
where
|
||||
D: Fn(&[f32], u64) -> String,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
self.tick(true, decode, on_segment);
|
||||
}
|
||||
|
||||
fn tick<D, F>(&mut self, force: bool, decode: &D, on_segment: &mut F)
|
||||
where
|
||||
D: Fn(&[f32], u64) -> String,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
if self.window.is_empty() {
|
||||
return;
|
||||
}
|
||||
let window_ms = (self.window.len() as u64 * 1000) / STREAM_SAMPLE_RATE as u64;
|
||||
let text = decode(&self.window, self.committed_offset_ms);
|
||||
let over_max = self.window.len() >= self.max_len;
|
||||
|
||||
// Nothing recognized yet (leading/standalone silence): don't show an
|
||||
// empty line, but still drop the buffer once it's grown too big so we
|
||||
// aren't re-decoding a long silence every step.
|
||||
if text.is_empty() {
|
||||
if over_max || force {
|
||||
self.reset(window_ms, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let stable = text == self.last_text;
|
||||
let commit = force || over_max || (stable && self.window.len() >= self.min_commit_len);
|
||||
on_segment(TranscriptSegment {
|
||||
id: self.next_id,
|
||||
start_ms: self.committed_offset_ms,
|
||||
end_ms: self.committed_offset_ms + window_ms,
|
||||
// Provisional speaker; the post-stop diarization pass reassigns.
|
||||
speaker: "S1".to_string(),
|
||||
text: text.clone(),
|
||||
confidence: None,
|
||||
interim: !commit,
|
||||
});
|
||||
if commit {
|
||||
self.reset(window_ms, true);
|
||||
} else {
|
||||
self.last_text = text;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a fresh window/line after a commit (`new_line`) or after dropping
|
||||
/// leading silence (`!new_line`, which reuses the id since nothing was
|
||||
/// emitted for it).
|
||||
fn reset(&mut self, window_ms: u64, new_line: bool) {
|
||||
self.committed_offset_ms += window_ms;
|
||||
self.window.clear();
|
||||
self.last_text.clear();
|
||||
self.since_last_decode = 0;
|
||||
if new_line {
|
||||
self.next_id += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live streaming worker (FR-TRX-2): drives a `Streamer` from the `audio`
|
||||
/// service's 16kHz-mono frames, decoding each growing window with `transcriber`
|
||||
/// and forwarding interim-then-final segments to `on_segment` (a Tauri emit).
|
||||
///
|
||||
/// `transcribe_stream` is synchronous for whisper.cpp — by the time it returns,
|
||||
/// every segment it produced is already in the per-call channel — so this
|
||||
/// drains it inline and joins the text into the window's transcript for the
|
||||
/// `Streamer` to diff/commit.
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
pub fn run_streaming_worker<T, F>(
|
||||
transcriber: &T,
|
||||
frame_rx: Receiver<Vec<f32>>,
|
||||
tuning: StreamTuning,
|
||||
mut on_segment: F,
|
||||
) where
|
||||
// `?Sized` so the dispatcher can hand us a `&dyn Transcriber` (whisper.cpp
|
||||
// or the NPU engine, chosen at runtime) rather than a concrete type.
|
||||
T: Transcriber + ?Sized,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
const SAMPLE_RATE: usize = 16_000;
|
||||
const WINDOW_SECS: f32 = 4.0;
|
||||
let window_len = (WINDOW_SECS * SAMPLE_RATE as f32) as usize;
|
||||
|
||||
let mut buf: Vec<f32> = Vec::new();
|
||||
let mut offset_ms: u64 = 0;
|
||||
|
||||
let run_window = |transcriber: &T, samples: Vec<f32>, offset_ms: u64, on_segment: &mut F| {
|
||||
let decode = |samples: &[f32], offset_ms: u64| -> String {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let window = AudioWindow { samples, offset_ms };
|
||||
let window = AudioWindow {
|
||||
samples: samples.to_vec(),
|
||||
offset_ms,
|
||||
};
|
||||
if let Err(e) = transcriber.transcribe_stream(window, tx) {
|
||||
tracing::warn!("transcription window failed: {e}");
|
||||
return String::new();
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
while let Ok(segment) = rx.try_recv() {
|
||||
on_segment(segment);
|
||||
let t = segment.text.trim();
|
||||
if !t.is_empty() {
|
||||
parts.push(t.to_string());
|
||||
}
|
||||
}
|
||||
parts.join(" ")
|
||||
};
|
||||
|
||||
let mut streamer = Streamer::new(tuning);
|
||||
while let Ok(chunk) = frame_rx.recv() {
|
||||
buf.extend_from_slice(&chunk);
|
||||
while buf.len() >= window_len {
|
||||
let samples: Vec<f32> = buf.drain(..window_len).collect();
|
||||
run_window(transcriber, samples, offset_ms, &mut on_segment);
|
||||
offset_ms += (window_len as u64 * 1000) / SAMPLE_RATE as u64;
|
||||
}
|
||||
}
|
||||
// Final partial window on stop, if there's enough audio to be worth a pass.
|
||||
if buf.len() > SAMPLE_RATE / 2 {
|
||||
run_window(transcriber, buf, offset_ms, &mut on_segment);
|
||||
streamer.feed(&chunk, &decode, &mut on_segment);
|
||||
}
|
||||
streamer.flush(&decode, &mut on_segment);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -276,6 +547,119 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Collect a step's emitted segments into `out` — the pushing closure lives
|
||||
// and dies inside the call, so `out` is free to read in the asserts after
|
||||
// (a single long-lived `on` closure would keep `out` mutably borrowed).
|
||||
fn feed_into<D: Fn(&[f32], u64) -> String>(
|
||||
s: &mut Streamer,
|
||||
chunk: &[f32],
|
||||
decode: &D,
|
||||
out: &mut Vec<TranscriptSegment>,
|
||||
) {
|
||||
s.feed(chunk, decode, &mut |seg| out.push(seg));
|
||||
}
|
||||
|
||||
fn flush_into<D: Fn(&[f32], u64) -> String>(
|
||||
s: &mut Streamer,
|
||||
decode: &D,
|
||||
out: &mut Vec<TranscriptSegment>,
|
||||
) {
|
||||
s.flush(decode, &mut |seg| out.push(seg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamer_grows_interim_then_commits_on_a_pause() {
|
||||
// Text grows for two steps, then repeats (the speaker paused, so the
|
||||
// extra second was silence) → the line commits.
|
||||
let tuning = StreamTuning {
|
||||
step_ms: 1000,
|
||||
max_window_ms: 12_000,
|
||||
min_commit_ms: 1500,
|
||||
};
|
||||
let mut s = Streamer::new(tuning);
|
||||
let script = std::cell::Cell::new(0usize);
|
||||
let texts = ["hello", "hello world", "hello world"];
|
||||
let decode = |_: &[f32], _off: u64| -> String {
|
||||
let i = script.get();
|
||||
script.set(i + 1);
|
||||
texts.get(i).copied().unwrap_or("hello world").to_string()
|
||||
};
|
||||
let mut out: Vec<TranscriptSegment> = Vec::new();
|
||||
let step = vec![0.0f32; 16_000]; // exactly one 1s step
|
||||
|
||||
feed_into(&mut s, &step, &decode, &mut out); // "hello" — interim
|
||||
feed_into(&mut s, &step, &decode, &mut out); // "hello world" — interim
|
||||
feed_into(&mut s, &step, &decode, &mut out); // stable + past min_commit → commit
|
||||
|
||||
assert_eq!(out.len(), 3);
|
||||
assert!(out[0].interim && out[0].text == "hello");
|
||||
assert!(out[1].interim && out[1].text == "hello world");
|
||||
assert!(!out[2].interim && out[2].text == "hello world");
|
||||
assert_eq!(out[0].id, out[2].id, "same line id until it commits");
|
||||
|
||||
// A new line after the commit uses a fresh id and a later offset.
|
||||
let d2 = |_: &[f32], _o: u64| "next sentence".to_string();
|
||||
feed_into(&mut s, &step, &d2, &mut out);
|
||||
assert!(out[3].interim && out[3].text == "next sentence");
|
||||
assert_ne!(out[3].id, out[2].id);
|
||||
assert!(
|
||||
out[3].start_ms >= 3000,
|
||||
"starts after the 3 committed steps"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamer_shows_no_line_for_pure_silence() {
|
||||
let mut s = Streamer::new(StreamTuning::new(false));
|
||||
let decode = |_: &[f32], _o: u64| String::new();
|
||||
let mut out: Vec<TranscriptSegment> = Vec::new();
|
||||
let step = vec![0.0f32; 16_000];
|
||||
for _ in 0..5 {
|
||||
feed_into(&mut s, &step, &decode, &mut out);
|
||||
}
|
||||
assert!(out.is_empty(), "silence must not emit an empty line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamer_flush_commits_the_in_progress_line() {
|
||||
let mut s = Streamer::new(StreamTuning::new(false));
|
||||
let decode = |_: &[f32], _o: u64| "partial".to_string();
|
||||
let mut out: Vec<TranscriptSegment> = Vec::new();
|
||||
feed_into(&mut s, &vec![0.0f32; 16_000], &decode, &mut out);
|
||||
assert!(out.last().unwrap().interim, "still growing before flush");
|
||||
flush_into(&mut s, &decode, &mut out);
|
||||
let last = out.last().unwrap();
|
||||
assert!(
|
||||
!last.interim && last.text == "partial",
|
||||
"flush finalizes it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamer_force_commits_a_pause_free_monologue_at_max() {
|
||||
// Text never repeats (continuous speech), so only the max-window
|
||||
// backstop can commit it — otherwise the window (and decode cost) grows
|
||||
// without bound.
|
||||
let tuning = StreamTuning {
|
||||
step_ms: 1000,
|
||||
max_window_ms: 3000,
|
||||
min_commit_ms: 1500,
|
||||
};
|
||||
let mut s = Streamer::new(tuning);
|
||||
let n = std::cell::Cell::new(0usize);
|
||||
let decode = |_: &[f32], _o: u64| {
|
||||
let i = n.get();
|
||||
n.set(i + 1);
|
||||
format!("word{i}")
|
||||
};
|
||||
let mut out: Vec<TranscriptSegment> = Vec::new();
|
||||
let step = vec![0.0f32; 16_000];
|
||||
feed_into(&mut s, &step, &decode, &mut out); // 1s
|
||||
feed_into(&mut s, &step, &decode, &mut out); // 2s
|
||||
feed_into(&mut s, &step, &decode, &mut out); // 3s == max → force commit
|
||||
assert!(!out.last().unwrap().interim);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_ctx_scales_proportionally_to_window_length() {
|
||||
// The actual streaming WINDOW_SECS (4.0) -> ~200 (201 after `.ceil()`
|
||||
@@ -298,6 +682,17 @@ mod tests {
|
||||
assert_eq!(audio_ctx_for_window(60 * 16_000), 1500); // 60s window
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_likely_speech_drops_high_no_speech_probability_windows() {
|
||||
// Silence/near-silence: whisper.cpp's own greedy hallucination case
|
||||
// ("you", "Thank you.") on an otherwise-quiet window.
|
||||
assert!(!is_likely_speech(0.9));
|
||||
assert!(!is_likely_speech(NO_SPEECH_THRESHOLD + 0.01));
|
||||
// Confident speech kept, including right at the threshold.
|
||||
assert!(is_likely_speech(0.0));
|
||||
assert!(is_likely_speech(NO_SPEECH_THRESHOLD));
|
||||
}
|
||||
|
||||
/// GPU spike (opt-in): times whisper.cpp on a chosen backend against a real
|
||||
/// model + wav. With `--features vulkan` and `WA_BACKEND=intel` (or nvidia/amd)
|
||||
/// whisper.cpp offloads to the GPU; `WA_BACKEND=cpu` is the baseline. Run:
|
||||
@@ -315,7 +710,7 @@ mod tests {
|
||||
_ => BackendId::Intel, // use_gpu = true for any non-CPU backend
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let transcriber = WhisperTranscriber::load(Path::new(&model), backend).expect("load");
|
||||
let transcriber = WhisperTranscriber::load(Path::new(&model), backend, None).expect("load");
|
||||
let load_ms = t0.elapsed().as_millis();
|
||||
let t1 = std::time::Instant::now();
|
||||
let segments = transcriber
|
||||
@@ -330,4 +725,95 @@ mod tests {
|
||||
eprintln!("[spike] backend={backend:?} load={load_ms}ms infer={infer_ms}ms text={text:?}");
|
||||
assert!(!text.trim().is_empty(), "transcript was empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_language_auto_and_none_both_mean_auto_detect() {
|
||||
assert_eq!(resolve_language(None, true), None);
|
||||
assert_eq!(resolve_language(Some("auto"), true), None);
|
||||
assert_eq!(resolve_language(Some("AUTO"), true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_language_explicit_on_multilingual_model_passes_through() {
|
||||
assert_eq!(resolve_language(Some("es"), true), Some("es".to_string()));
|
||||
assert_eq!(resolve_language(Some("fr"), true), Some("fr".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_language_forces_english_on_english_only_model() {
|
||||
// The footgun guard (T8.7/FR-TRX-4): an English-only model can't
|
||||
// honor a non-English request, so it's forced to "en" rather than
|
||||
// silently producing garbage.
|
||||
assert_eq!(resolve_language(Some("es"), false), Some("en".to_string()));
|
||||
assert_eq!(resolve_language(Some("EN"), false), Some("en".to_string()));
|
||||
// Auto is still allowed on an English-only model — harmless, since
|
||||
// whisper.cpp forces English internally for it either way.
|
||||
assert_eq!(resolve_language(None, false), None);
|
||||
assert_eq!(resolve_language(Some("auto"), false), None);
|
||||
}
|
||||
|
||||
/// Multilingual decode acceptance spike (opt-in, mirrors
|
||||
/// `gpu_transcribes_and_times`): loads a *multilingual* model with an
|
||||
/// explicit non-English `language` and confirms it decodes non-empty
|
||||
/// text without being forced to English. Needs a real multilingual ggml
|
||||
/// model + a non-English wav, neither of which are fetched by CI/this
|
||||
/// sandbox — run manually:
|
||||
/// WA_WHISPER_MODEL=…ggml-small-q5_1.bin WA_TEST_WAV=…spanish.wav \
|
||||
/// WA_TEST_LANGUAGE=es cargo test multilingual_model_decodes_requested_language \
|
||||
/// -- --ignored --nocapture
|
||||
#[test]
|
||||
#[ignore = "requires a multilingual whisper model + non-English wav; run manually"]
|
||||
fn multilingual_model_decodes_requested_language() {
|
||||
let model = std::env::var("WA_WHISPER_MODEL").expect("set WA_WHISPER_MODEL");
|
||||
let wav = std::env::var("WA_TEST_WAV").expect("set WA_TEST_WAV");
|
||||
let language = std::env::var("WA_TEST_LANGUAGE").unwrap_or_else(|_| "es".to_string());
|
||||
|
||||
let transcriber =
|
||||
WhisperTranscriber::load(Path::new(&model), BackendId::Cpu, Some(&language))
|
||||
.expect("load multilingual model");
|
||||
assert_eq!(transcriber.effective_language(), Some(language.clone()));
|
||||
|
||||
let segments = transcriber
|
||||
.transcribe_file(Path::new(&wav))
|
||||
.expect("transcribe");
|
||||
let text = segments
|
||||
.iter()
|
||||
.map(|s| s.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
eprintln!("[spike] language={language} text={text:?}");
|
||||
assert!(!text.trim().is_empty(), "transcript was empty");
|
||||
// whisper.cpp reports back whichever language it actually decoded in.
|
||||
assert_eq!(
|
||||
transcriber.detected_language().as_deref(),
|
||||
Some(language.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
/// "Auto" acceptance spike (opt-in): confirms auto-detection actually
|
||||
/// runs (no `language` forced) and surfaces a detected language after
|
||||
/// decode. Same manual-only posture as the spike above.
|
||||
#[test]
|
||||
#[ignore = "requires a multilingual whisper model + wav; run manually"]
|
||||
fn auto_mode_detects_a_language() {
|
||||
let model = std::env::var("WA_WHISPER_MODEL").expect("set WA_WHISPER_MODEL");
|
||||
let wav = std::env::var("WA_TEST_WAV").expect("set WA_TEST_WAV");
|
||||
|
||||
let transcriber = WhisperTranscriber::load(Path::new(&model), BackendId::Cpu, None)
|
||||
.expect("load multilingual model");
|
||||
assert_eq!(
|
||||
transcriber.effective_language(),
|
||||
None,
|
||||
"auto should stay unset"
|
||||
);
|
||||
assert_eq!(transcriber.detected_language(), None, "nothing decoded yet");
|
||||
|
||||
transcriber
|
||||
.transcribe_file(Path::new(&wav))
|
||||
.expect("transcribe");
|
||||
assert!(
|
||||
transcriber.detected_language().is_some(),
|
||||
"auto mode should report a detected language after a decode"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,28 +12,132 @@ struct Catalog {
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
size_mb: u32,
|
||||
/// `false` for the `.en` (English-only) ggml variants; `true` for the
|
||||
/// multilingual variants, which whisper.cpp ships as the same filename
|
||||
/// minus the `.en` infix (e.g. `ggml-base.bin` vs `ggml-base.en.bin`) —
|
||||
/// same download/install machinery, just a different id/URL (T8.7,
|
||||
/// FR-TRX-4, M4.2).
|
||||
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",
|
||||
label: "Tiny (English, quantized) — fastest, least accurate",
|
||||
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: "medium.en-q5_1",
|
||||
label: "Medium (English, quantized) — best accuracy, slowest",
|
||||
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) — 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) — 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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -50,10 +154,19 @@ pub fn list(active_id: &str) -> Vec<ModelInfo> {
|
||||
size_mb: m.size_mb,
|
||||
installed: whisper_model_file(m.id).exists(),
|
||||
active: m.id == active_id,
|
||||
multilingual: m.multilingual,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether `id` names a multilingual (non-`.en`) catalog model — an unknown
|
||||
/// id (shouldn't happen; callers validate against the catalog first) is
|
||||
/// conservatively treated as English-only rather than granting language
|
||||
/// selection it can't honor.
|
||||
pub fn is_multilingual(id: &str) -> bool {
|
||||
CATALOG.iter().any(|m| m.id == id && m.multilingual)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ModelError {
|
||||
#[error("unknown model id: {0}")]
|
||||
@@ -147,4 +260,67 @@ mod tests {
|
||||
let err = remove(active, active).unwrap_err();
|
||||
assert!(matches!(err, ModelError::Invalid(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_has_a_multilingual_counterpart_for_every_english_only_size() {
|
||||
// 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.
|
||||
// (The reverse doesn't hold: large-v3-turbo is multilingual-only.)
|
||||
for en in CATALOG.iter().filter(|m| !m.multilingual) {
|
||||
assert!(
|
||||
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"));
|
||||
assert!(is_multilingual("base-q5_1"));
|
||||
// Unknown ids are conservatively English-only (no model to check).
|
||||
assert!(!is_multilingual("nonexistent-id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_reports_multilingual_flag_per_model() {
|
||||
let models = list("base.en-q5_1");
|
||||
let base_en = models.iter().find(|m| m.id == "base.en-q5_1").unwrap();
|
||||
let base_multi = models.iter().find(|m| m.id == "base-q5_1").unwrap();
|
||||
assert!(!base_en.multilingual);
|
||||
assert!(base_multi.multilingual);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
let mut tokens = self.initial_tokens.clone();
|
||||
for _ in 0..MAX_NEW_TOKENS {
|
||||
let ids: Vec<i64> = tokens.clone();
|
||||
let ids_tensor = Tensor::from_array(([1usize, ids.len()], ids))
|
||||
// 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 next = {
|
||||
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> = 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()))?;
|
||||
|
||||
// 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;
|
||||
@@ -202,7 +294,24 @@ impl Transcriber for OnnxTranscriber {
|
||||
/// `Npu` → OpenVINO EP; `Amd`/`Intel` → DirectML EP (the non-Vulkan GPU
|
||||
/// path). Any other value is rejected so the dispatcher can fall back to
|
||||
/// whisper.cpp rather than us guessing.
|
||||
fn load(model: &Path, backend: BackendId) -> Result<Self, TrxError> {
|
||||
///
|
||||
/// `language` (T8.7, FR-TRX-4): the exported ONNX model's
|
||||
/// `forced_decoder_ids` bakes its language in at export time — this
|
||||
/// engine has no per-inference language selection to apply, unlike
|
||||
/// whisper.cpp's `FullParams::set_language`. A non-English, non-auto
|
||||
/// request is logged (not silently dropped) so a user picking a language
|
||||
/// on the NPU/DirectML path finds out it didn't take, rather than
|
||||
/// getting a quietly-wrong transcript; `effective_language`/
|
||||
/// `detected_language` fall back to the trait's English-only defaults.
|
||||
fn load(model: &Path, backend: BackendId, language: Option<&str>) -> Result<Self, TrxError> {
|
||||
if let Some(lang) = language {
|
||||
if !lang.eq_ignore_ascii_case("auto") && !lang.eq_ignore_ascii_case("en") {
|
||||
tracing::warn!(
|
||||
"language '{lang}' requested but the NPU/DirectML engine's ONNX model is \
|
||||
English-only (language is fixed at export time); ignoring the request"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Pick the runtime bundle + encoder EP for the requested accelerator.
|
||||
// error_on_failure makes a failed accelerator registration LOUD (Err)
|
||||
// instead of a silent CPU fallback, so the dispatcher can cleanly drop
|
||||
@@ -235,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");
|
||||
|
||||
@@ -246,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()
|
||||
// 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()))?;
|
||||
.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")
|
||||
@@ -258,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)?;
|
||||
@@ -273,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,
|
||||
@@ -440,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();
|
||||
@@ -464,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)
|
||||
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();
|
||||
@@ -507,7 +727,7 @@ mod tests {
|
||||
_ => BackendId::Intel,
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let t = OnnxTranscriber::load(Path::new(&model_dir), backend).expect("load DirectML");
|
||||
let t = OnnxTranscriber::load(Path::new(&model_dir), backend, None).expect("load DirectML");
|
||||
let load_ms = t0.elapsed().as_millis();
|
||||
let t1 = std::time::Instant::now();
|
||||
let segs = t.transcribe_file(Path::new(&wav)).expect("transcribe");
|
||||
|
||||
@@ -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.2.0",
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ async fn attach_meeting_to_event_and_map_speaker_to_participant_round_trip() {
|
||||
title: "Test meeting".to_string(),
|
||||
calendar_event_id: None,
|
||||
template_id: None,
|
||||
language: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -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
|
||||
+312
-63
@@ -7,17 +7,43 @@
|
||||
import Settings from "./lib/views/Settings.svelte";
|
||||
import ConsentNotice from "./lib/components/ConsentNotice.svelte";
|
||||
import LevelMeter from "./lib/components/LevelMeter.svelte";
|
||||
import ImportMeeting from "./lib/components/ImportMeeting.svelte";
|
||||
import { trapFocus } from "./lib/actions/trapFocus";
|
||||
import { recording } from "./lib/stores/recording.svelte";
|
||||
import { settings } from "./lib/stores/settings.svelte";
|
||||
import { meetings } from "./lib/stores/meetings.svelte";
|
||||
import { api, type NoteTemplate } from "./lib/api";
|
||||
import { 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";
|
||||
import ThemeToggle from "./lib/components/ThemeToggle.svelte";
|
||||
import { Circle, Square, Trash2, Settings as SettingsIcon, AlertTriangle } from "@lucide/svelte";
|
||||
import Splitter from "./lib/components/Splitter.svelte";
|
||||
import { layout, clamp } from "./lib/stores/layout.svelte";
|
||||
import { t } from "./lib/i18n/index.svelte";
|
||||
import {
|
||||
Circle,
|
||||
Square,
|
||||
Trash2,
|
||||
FilePlus,
|
||||
Mic,
|
||||
MicOff,
|
||||
Settings as SettingsIcon,
|
||||
AlertTriangle,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
let showSettings = $state(false);
|
||||
let showConsent = $state(false);
|
||||
let showImport = $state(false);
|
||||
|
||||
// A freshly imported meeting: refresh the list and open it.
|
||||
async function onImported(id: string) {
|
||||
await meetings.load();
|
||||
await meetings.select(id);
|
||||
}
|
||||
|
||||
// Vault unlock gate (T8.8): if the vault is enabled but locked at startup,
|
||||
// prompt for the password so encrypted meetings are readable.
|
||||
@@ -40,7 +66,7 @@
|
||||
vaultLocked = false;
|
||||
meetings.load(); // refresh now that encrypted content is readable
|
||||
} catch {
|
||||
vaultErr = "Incorrect password";
|
||||
vaultErr = t("app.vault_incorrect");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,14 +93,53 @@
|
||||
document.documentElement.dataset.theme = resolvedTheme;
|
||||
});
|
||||
|
||||
// Auto-start recording on calendar events (opt-in, FR-CAL). No background
|
||||
// work: while the app is open we arm a single one-shot timer to the next
|
||||
// event's start; nothing polls (NFR-RES-1). Events already auto-started this
|
||||
// session are remembered so stopping a recording doesn't re-trigger the same
|
||||
// one, and the horizon caps the timer at setTimeout's safe range.
|
||||
let autoRecordTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const autoStarted = new Set<string>();
|
||||
const AUTO_RECORD_HORIZON_MS = 24 * 60 * 60 * 1000;
|
||||
function autoStartForEvent(ev: CalendarEvent) {
|
||||
if (recording.state !== "idle") return;
|
||||
autoStarted.add(ev.id);
|
||||
meetings.deselect();
|
||||
recording.start(
|
||||
ev.subject ?? undefined,
|
||||
settings.settings.default_record,
|
||||
selectedTemplateId || undefined,
|
||||
ev.id,
|
||||
);
|
||||
}
|
||||
$effect(() => {
|
||||
if (!settings.settings.auto_record_calendar || recording.state !== "idle") return;
|
||||
const now = Date.now();
|
||||
const next = calendar.events
|
||||
.filter((e) => e.starts_at && !autoStarted.has(e.id))
|
||||
.filter((e) => ((e.ends_at ?? e.starts_at) as number) * 1000 > now) // not already over
|
||||
.sort((a, b) => (a.starts_at as number) - (b.starts_at as number))[0];
|
||||
if (!next) return;
|
||||
const delay = (next.starts_at as number) * 1000 - now;
|
||||
if (delay <= 0) {
|
||||
autoStartForEvent(next); // event is happening right now
|
||||
return;
|
||||
}
|
||||
if (delay > AUTO_RECORD_HORIZON_MS) return; // too far out; re-armed on state/event change
|
||||
autoRecordTimer = setTimeout(() => autoStartForEvent(next), delay);
|
||||
return () => clearTimeout(autoRecordTimer);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
recording.init();
|
||||
settings.load();
|
||||
meetings.init();
|
||||
imports.init(); // live background-import progress for the tracker
|
||||
calendar.load(); // events power the auto-record timer above
|
||||
checkVault();
|
||||
api
|
||||
.listNoteTemplates()
|
||||
.then((t) => (noteTemplates = t))
|
||||
.then((tpls) => (noteTemplates = tpls))
|
||||
.catch(() => (noteTemplates = []));
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
@@ -100,7 +165,7 @@
|
||||
}
|
||||
|
||||
async function cancelRecording() {
|
||||
if (!confirm("Discard this recording? Its audio and transcript will be deleted.")) return;
|
||||
if (!confirm(t("app.discard_confirm"))) return;
|
||||
await recording.cancel();
|
||||
meetings.deselect();
|
||||
}
|
||||
@@ -126,9 +191,9 @@
|
||||
$effect(() => {
|
||||
const current = recording.state;
|
||||
if (current !== previousRecordingState) {
|
||||
if (current === "recording") recordingAnnouncement = "Recording started";
|
||||
else if (current === "paused") recordingAnnouncement = "Recording paused";
|
||||
else if (previousRecordingState !== "idle") recordingAnnouncement = "Recording stopped";
|
||||
if (current === "recording") recordingAnnouncement = t("app.announce_started");
|
||||
else if (current === "paused") recordingAnnouncement = t("app.announce_paused");
|
||||
else if (previousRecordingState !== "idle") recordingAnnouncement = t("app.announce_stopped");
|
||||
previousRecordingState = current;
|
||||
}
|
||||
});
|
||||
@@ -146,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,64 +248,92 @@
|
||||
<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">local · private</span>
|
||||
<div class="spacer"></div>
|
||||
{#if recording.state === "idle"}
|
||||
<select
|
||||
class="theme-select"
|
||||
bind:value={selectedTemplateId}
|
||||
aria-label="Note template"
|
||||
title="Note template"
|
||||
>
|
||||
<option value="">No template</option>
|
||||
{#each noteTemplates as t (t.id)}
|
||||
<option value={t.id}>{t.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="record-btn"
|
||||
onclick={startRecording}
|
||||
title="Start recording (Ctrl+Shift+R)"
|
||||
title={t("app.record_title")}
|
||||
aria-keyshortcuts="Control+Shift+R"
|
||||
>
|
||||
<Circle size={11} fill="currentColor" aria-hidden="true" />
|
||||
Record
|
||||
{t("app.record")}
|
||||
</button>
|
||||
<select
|
||||
class="theme-select"
|
||||
bind:value={selectedTemplateId}
|
||||
aria-label={t("app.note_template")}
|
||||
title={t("app.note_template")}
|
||||
>
|
||||
<option value="">{t("app.no_template")}</option>
|
||||
{#each noteTemplates as tpl (tpl.id)}
|
||||
<option value={tpl.id}>{tpl.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<button
|
||||
class="stop-btn"
|
||||
onclick={() => recording.stop()}
|
||||
title="Stop recording (Ctrl+Shift+R)"
|
||||
title={t("app.stop_title")}
|
||||
aria-keyshortcuts="Control+Shift+R"
|
||||
>
|
||||
<Square size={11} fill="currentColor" aria-hidden="true" />
|
||||
Stop
|
||||
{t("app.stop")}
|
||||
</button>
|
||||
<button
|
||||
class="cancel-btn"
|
||||
onclick={cancelRecording}
|
||||
title="Discard this recording and delete it"
|
||||
>
|
||||
<button class="cancel-btn" onclick={cancelRecording} title={t("app.cancel_title")}>
|
||||
<Trash2 size={12} aria-hidden="true" />
|
||||
Cancel
|
||||
{t("app.cancel")}
|
||||
</button>
|
||||
{/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>
|
||||
Recording…
|
||||
{t("app.recording")}
|
||||
</span>
|
||||
<LevelMeter rms={recording.levelRms} peak={recording.levelPeak} />
|
||||
{#if settings.hardware}
|
||||
<span class="backend" title="Active transcription backend">{settings.hardware.active}</span>
|
||||
<LevelMeter
|
||||
rms={recording.levelRms}
|
||||
peak={recording.levelPeak}
|
||||
micRms={recording.levelRmsMic}
|
||||
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}
|
||||
<label class="retention" title="Save audio as .wav for this meeting">
|
||||
<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}
|
||||
<label class="retention" title={t("app.retention_title")}>
|
||||
<input type="checkbox" checked={recording.retention} onchange={onToggleRetention} />
|
||||
<span>{recording.retention ? "saving" : "not saved"}</span>
|
||||
<span>{recording.retention ? t("app.saving") : t("app.not_saved")}</span>
|
||||
</label>
|
||||
{#if recording.deviceNotice}
|
||||
<span class="device-notice" role="status" title={recording.deviceNotice}>
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
reconnecting audio device…
|
||||
{t("app.reconnecting")}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -238,8 +343,8 @@
|
||||
/>
|
||||
<button
|
||||
class="icon"
|
||||
aria-label="Settings"
|
||||
title="Settings (Ctrl+,)"
|
||||
aria-label={t("app.settings")}
|
||||
title={t("app.settings_title")}
|
||||
aria-keyshortcuts="Control+,"
|
||||
onclick={() => (showSettings = !showSettings)}
|
||||
>
|
||||
@@ -252,7 +357,7 @@
|
||||
class="consent-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Recording consent"
|
||||
aria-label={t("app.consent_dialog")}
|
||||
tabindex="-1"
|
||||
use:trapFocus
|
||||
>
|
||||
@@ -264,37 +369,124 @@
|
||||
<Settings onClose={() => (showSettings = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showImport}
|
||||
<ImportMeeting onClose={() => (showImport = false)} {onImported} />
|
||||
{/if}
|
||||
|
||||
{#if vaultLocked}
|
||||
<div
|
||||
class="vault-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Unlock encryption vault"
|
||||
aria-label={t("app.vault_dialog")}
|
||||
tabindex="-1"
|
||||
use:trapFocus
|
||||
>
|
||||
<div class="vault-card">
|
||||
<h2>Unlock encryption vault</h2>
|
||||
<p class="muted">Your meetings are encrypted at rest. Enter your password to read them.</p>
|
||||
<h2>{t("app.vault_title")}</h2>
|
||||
<p class="muted">{t("app.vault_desc")}</p>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Vault password"
|
||||
placeholder={t("app.vault_password")}
|
||||
bind:value={vaultPw}
|
||||
onkeydown={(e) => e.key === "Enter" && submitUnlock()}
|
||||
/>
|
||||
{#if vaultErr}<p class="vault-err">{vaultErr}</p>{/if}
|
||||
<div class="vault-actions">
|
||||
<button class="primary" onclick={submitUnlock} disabled={!vaultPw}>Unlock</button>
|
||||
<button class="link" onclick={() => (vaultLocked = false)}>Continue locked</button>
|
||||
<button class="primary" onclick={submitUnlock} disabled={!vaultPw}
|
||||
>{t("app.unlock")}</button
|
||||
>
|
||||
<button class="link" onclick={() => (vaultLocked = false)}
|
||||
>{t("app.continue_locked")}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="panes">
|
||||
<aside class="left"><MeetingsList /></aside>
|
||||
<main
|
||||
class="panes"
|
||||
style="grid-template-columns: {layout.leftCollapsed
|
||||
? 'auto'
|
||||
: layout.leftWidth + 'px'} auto 1fr auto {layout.rightCollapsed
|
||||
? 'auto'
|
||||
: layout.rightWidth + 'px'};"
|
||||
>
|
||||
<aside class="side left" class:collapsed={layout.leftCollapsed}>
|
||||
{#if layout.leftCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={() => {
|
||||
layout.leftCollapsed = false;
|
||||
layout.persist();
|
||||
}}
|
||||
title={t("app.show_meetings")}
|
||||
aria-label={t("app.show_meetings")}
|
||||
>
|
||||
<PanelLeftOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={() => {
|
||||
layout.leftCollapsed = true;
|
||||
layout.persist();
|
||||
}}
|
||||
title={t("app.hide_meetings")}
|
||||
aria-label={t("app.hide_meetings")}
|
||||
>
|
||||
<PanelLeftClose size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<div class="side-content"><MeetingsList /></div>
|
||||
{/if}
|
||||
</aside>
|
||||
{#if !layout.leftCollapsed}
|
||||
<Splitter
|
||||
label={t("app.resize_meetings")}
|
||||
onResize={(d) => (layout.leftWidth = clamp(layout.leftWidth + d, 200, 480))}
|
||||
onResizeEnd={() => layout.persist()}
|
||||
/>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<section class="center"><TranscriptNotes /></section>
|
||||
<aside class="right"><SummaryPanel /></aside>
|
||||
{#if !layout.rightCollapsed}
|
||||
<Splitter
|
||||
label={t("app.resize_summary")}
|
||||
onResize={(d) => (layout.rightWidth = clamp(layout.rightWidth - d, 240, 560))}
|
||||
onResizeEnd={() => layout.persist()}
|
||||
/>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<aside class="side right" class:collapsed={layout.rightCollapsed}>
|
||||
{#if layout.rightCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={() => {
|
||||
layout.rightCollapsed = false;
|
||||
layout.persist();
|
||||
}}
|
||||
title={t("app.show_summary")}
|
||||
aria-label={t("app.show_summary")}
|
||||
>
|
||||
<PanelRightOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={() => {
|
||||
layout.rightCollapsed = true;
|
||||
layout.persist();
|
||||
}}
|
||||
title={t("app.hide_summary")}
|
||||
aria-label={t("app.hide_summary")}
|
||||
>
|
||||
<PanelRightClose size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<div class="side-content"><SummaryPanel /></div>
|
||||
{/if}
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -430,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;
|
||||
}
|
||||
@@ -442,7 +630,8 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.record-btn,
|
||||
.stop-btn {
|
||||
.stop-btn,
|
||||
.import-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
@@ -457,9 +646,18 @@
|
||||
transform 100ms ease-out;
|
||||
}
|
||||
.record-btn:active,
|
||||
.stop-btn:active {
|
||||
.stop-btn:active,
|
||||
.import-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.import-btn {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--fg);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.import-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.record-btn {
|
||||
background: var(--danger);
|
||||
color: #ffffff;
|
||||
@@ -550,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;
|
||||
@@ -592,25 +810,56 @@
|
||||
}
|
||||
.panes {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr 320px;
|
||||
/* grid-template-columns set inline — depends on collapse/resize state (FR-UX-1). */
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.left,
|
||||
.right {
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-color: var(--border);
|
||||
overflow: auto;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.left {
|
||||
.side.left {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.right {
|
||||
.side.right {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.side.collapsed {
|
||||
align-items: center;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
.side-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.pane-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.pane-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.pane-toggle.inline {
|
||||
align-self: flex-end;
|
||||
margin: 0.3rem 0.3rem 0;
|
||||
}
|
||||
.center {
|
||||
overflow: auto;
|
||||
background: var(--bg);
|
||||
min-width: 0;
|
||||
}
|
||||
.vault-overlay {
|
||||
position: fixed;
|
||||
|
||||
+179
-13
@@ -56,8 +56,21 @@ 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|off (Phase 10a adds anthropic|openai)
|
||||
provider: string; // ollama|custom|anthropic|off (ADR-0011; "openai" not yet wired)
|
||||
reachable: boolean;
|
||||
isLocal: boolean;
|
||||
models: string[];
|
||||
@@ -69,10 +82,33 @@ export interface ModelInfo {
|
||||
size_mb: number;
|
||||
installed: boolean;
|
||||
active: boolean;
|
||||
/** `false` for `.en` (English-only) ggml variants; `true` for multilingual
|
||||
* ones — gates the Settings language picker (T8.7, FR-TRX-4, M4.2). */
|
||||
multilingual: boolean;
|
||||
}
|
||||
|
||||
// One selectable transcription language (T8.7, FR-TRX-4) — ISO-639-1 code
|
||||
// as accepted by whisper.cpp, plus a display label.
|
||||
export interface LanguageOption {
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -152,6 +188,9 @@ export interface Meeting {
|
||||
duration_secs: number | null;
|
||||
status: MeetingStatus;
|
||||
recorded: boolean;
|
||||
// Transcription language actually used/selected for this meeting (T8.7,
|
||||
// FR-TRX-4) — an ISO-639-1 code, or null if never resolved (e.g. no audio
|
||||
// was ever decoded).
|
||||
language: string | null;
|
||||
backend_used: string | null;
|
||||
model_used: string | null;
|
||||
@@ -159,6 +198,10 @@ export interface Meeting {
|
||||
speakers: SpeakerInfo[];
|
||||
notes_markdown: string;
|
||||
summary: SummaryFile | null;
|
||||
// Confirmed/edited action items (table-backed source of truth) — falls back
|
||||
// to summary.action_items drafts until anything is saved. Manage via
|
||||
// confirmActionItems (add/edit/delete).
|
||||
action_items: ActionItem[];
|
||||
calendar_event_id: string | null;
|
||||
tags: string[];
|
||||
template_id: string | null;
|
||||
@@ -196,6 +239,11 @@ export interface CalendarEventDetail {
|
||||
participants: Participant[];
|
||||
}
|
||||
|
||||
export interface CalendarCleanupResult {
|
||||
deleted: number;
|
||||
protected: number;
|
||||
}
|
||||
|
||||
export type SyncKind = "webdav" | "onedrive" | "dropbox" | "box";
|
||||
|
||||
// What the UI sees about a target — NEVER includes the secret (FR-SYNC-6).
|
||||
@@ -284,18 +332,42 @@ export interface AppSettings {
|
||||
} | null;
|
||||
preferred_backend: string;
|
||||
whisper_model: string;
|
||||
/** Default transcription language (T8.7, FR-TRX-4): `null` = auto-detect,
|
||||
* an ISO-639-1 code forces that language. Only takes effect with a
|
||||
* multilingual model — see `ModelInfo.multilingual`. */
|
||||
whisper_language: string | null;
|
||||
low_overhead: boolean;
|
||||
default_record: boolean;
|
||||
consent_acknowledged: boolean;
|
||||
/** One-time "data leaves your device" ack for hosted (non-local) AI
|
||||
* providers — Anthropic today (ADR-0011, T10.3). Independent of
|
||||
* consent_acknowledged (that one's about recording law). */
|
||||
hosted_ai_acknowledged: boolean;
|
||||
sync_enabled: boolean;
|
||||
mcp_enabled: boolean;
|
||||
mcp_transport: string; // http|stdio
|
||||
mcp_port: number;
|
||||
mcp_expose: string; // none|selected|all
|
||||
mcp_expose_recordings: boolean;
|
||||
retention_max_age_days: number | null;
|
||||
retention_max_size_gb: number | null;
|
||||
pst_last_path: string | null;
|
||||
pst_auto_sync: boolean;
|
||||
// Days of history to import (both a manual Import click and pst_auto_sync);
|
||||
// null = full mailbox history.
|
||||
pst_import_range_days: number | null;
|
||||
/** Auto-start recording when a calendar event begins while the app is open
|
||||
* (opt-in, off by default). Armed as a one-shot UI timer — nothing polls. */
|
||||
auto_record_calendar: boolean;
|
||||
audio_output_device: string | null;
|
||||
microphone_enabled: boolean;
|
||||
audio_input_device: string | null;
|
||||
/** 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).
|
||||
@@ -325,17 +397,29 @@ export interface McpAccessEntry {
|
||||
client: string | null;
|
||||
}
|
||||
|
||||
// mcp_status() response (FR-MCP-1/6). `endpoint` is empty while disabled.
|
||||
export interface McpStatus {
|
||||
enabled: boolean;
|
||||
transport: "http" | "stdio";
|
||||
endpoint: string;
|
||||
tokenSet: boolean;
|
||||
exposeScope: "none" | "selected" | "all";
|
||||
}
|
||||
|
||||
// ---- Commands ----
|
||||
export const api = {
|
||||
// `record` controls audio RETENTION (default false / off — ADR-0009).
|
||||
// `language` (T8.7, FR-TRX-4): omit/undefined falls back to
|
||||
// Settings.whisper_language; "auto" or omitted both mean auto-detect.
|
||||
startRecording: (
|
||||
meetingTitle?: string,
|
||||
calendarEventId?: string,
|
||||
record = false,
|
||||
templateId?: string,
|
||||
language?: string,
|
||||
) =>
|
||||
invoke<MeetingId>("start_recording", {
|
||||
args: { meetingTitle, calendarEventId, record, templateId },
|
||||
args: { meetingTitle, calendarEventId, record, templateId, language },
|
||||
}),
|
||||
listNoteTemplates: () => invoke<NoteTemplate[]>("list_note_templates"),
|
||||
stopRecording: (meetingId: MeetingId) => invoke<void>("stop_recording", { meetingId }),
|
||||
@@ -346,10 +430,22 @@ 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"),
|
||||
|
||||
// Live notes (Granola-style redesign): both live-session only, err once
|
||||
// the meeting is finalized — use updateNotes on the merged notes.md instead.
|
||||
updateLiveNotes: (meetingId: MeetingId, markdown: string) =>
|
||||
invoke<void>("update_live_notes", { meetingId, markdown }),
|
||||
// anchorMs: the clicked segment's start_ms (not its id). text: "" clears it.
|
||||
setSegmentNote: (meetingId: MeetingId, anchorMs: number, text: string) =>
|
||||
invoke<void>("set_segment_note", { meetingId, anchorMs, text }),
|
||||
|
||||
appInfo: () => invoke<AppInfo>("app_info"),
|
||||
openUrl: (url: string) => invoke<void>("open_url", { url }),
|
||||
hardwareStatus: () => invoke<HardwareStatus>("hardware_status"),
|
||||
@@ -357,14 +453,37 @@ 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"),
|
||||
downloadModel: (id: string, kind: "whisper" = "whisper") =>
|
||||
// The fixed segmentation+embedding pair that speaker diarization needs
|
||||
// installed before it can separate speakers (T4.7, FR-MODEL-1). Same
|
||||
// ModelInfo shape as whisper models; download via `downloadModel` with the
|
||||
// `diar-seg`/`diar-emb` kind, remove via the shared `removeModel`.
|
||||
listDiarizationModels: () => invoke<ModelInfo[]>("list_diarization_models"),
|
||||
// T8.7/FR-TRX-4: static catalog of whisper.cpp-recognized language codes
|
||||
// for the Settings dropdown; "Auto-detect" is a frontend-only addition.
|
||||
listWhisperLanguages: () => invoke<LanguageOption[]>("list_whisper_languages"),
|
||||
downloadModel: (id: string, kind: "whisper" | "diar-seg" | "diar-emb" = "whisper") =>
|
||||
invoke<void>("download_model", { args: { kind, id } }),
|
||||
removeModel: (id: string) => invoke<void>("remove_model", { id }),
|
||||
reprocessTranscript: (meetingId: MeetingId, model: string) =>
|
||||
invoke<void>("reprocess_transcript", { meetingId, model }),
|
||||
// `language` (T8.7): omit/undefined reuses whatever language the meeting
|
||||
// already had rather than resetting it to auto.
|
||||
reprocessTranscript: (meetingId: MeetingId, model: string, language?: string) =>
|
||||
invoke<void>("reprocess_transcript", { meetingId, model, language }),
|
||||
// Manually add a meeting from an existing recording — a local audio/video
|
||||
// file path or a URL (YouTube/streaming page or direct media URL). Requires
|
||||
// ffmpeg (and yt-dlp for URLs) on PATH; neither is bundled. `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) =>
|
||||
@@ -384,9 +503,17 @@ export const api = {
|
||||
deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }),
|
||||
updateNotes: (meetingId: MeetingId, markdown: string) =>
|
||||
invoke<void>("update_notes", { meetingId, markdown }),
|
||||
// dest is a file path for md/pdf/docx, a folder for bundle.
|
||||
exportMeeting: (meetingId: MeetingId, dest: string, format: "md" | "pdf" | "docx" | "bundle") =>
|
||||
invoke<string>("export_meeting", { meetingId, dest, format }),
|
||||
// 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: (
|
||||
meetingId: MeetingId,
|
||||
dest: string,
|
||||
format: "md" | "pdf" | "docx" | "bundle" | "obsidian",
|
||||
) => invoke<string>("export_meeting", { meetingId, dest, format }),
|
||||
// Every meeting matching tag/date filters, one file (or bundle folder) per
|
||||
// meeting under destDir. Returns the count actually exported (T8.5, FR-STORE-4).
|
||||
bulkExportMeetings: (
|
||||
@@ -401,6 +528,10 @@ export const api = {
|
||||
from: filter?.from,
|
||||
to: filter?.to,
|
||||
}),
|
||||
// Import bundle(s) exported with format "bundle" — dir is a single bundle
|
||||
// folder or a parent folder of them. Reconstructs each under a fresh id and
|
||||
// returns the count imported (FR-STORE-4).
|
||||
importMeetingBundle: (dir: string) => invoke<number>("import_meeting_bundle", { dir }),
|
||||
|
||||
llmStatus: () => invoke<LlmStatus>("llm_status"),
|
||||
// provider ∈ ollama|custom|anthropic|openai|off; apiKey (hosted) → OS credential store (ADR-0011).
|
||||
@@ -416,7 +547,15 @@ export const api = {
|
||||
invoke<void>("confirm_action_items", { meetingId, items }),
|
||||
generateTags: (meetingId: MeetingId) => invoke<string[]>("generate_tags", { meetingId }),
|
||||
|
||||
importPst: (path: string, password?: string) => invoke<number>("import_pst", { path, password }),
|
||||
// rangeDays: only import events starting within the last N days; omitted/undefined imports
|
||||
// the full mailbox history (a long-lived .pst otherwise re-imports years of recurring/holiday
|
||||
// entries on every launch when pst_auto_sync is on).
|
||||
importPst: (path: string, password?: string, rangeDays?: number) =>
|
||||
invoke<number>("import_pst", { path, password, rangeDays }),
|
||||
// olderThanDays: undefined deletes every unlinked event ("Delete all").
|
||||
// An event attached to a recorded meeting is always kept either way.
|
||||
cleanupCalendarEvents: (olderThanDays?: number) =>
|
||||
invoke<CalendarCleanupResult>("cleanup_calendar_events", { olderThanDays }),
|
||||
listCalendarEvents: (from?: number, to?: number) =>
|
||||
invoke<CalendarEvent[]>("list_calendar_events", { from, to }),
|
||||
getCalendarEvent: (eventId: string) =>
|
||||
@@ -455,7 +594,7 @@ export const api = {
|
||||
getFeatureBrief: (id: string) => invoke<FeatureBrief>("get_feature_brief", { id }),
|
||||
setBriefExposed: (id: string, exposed: boolean) =>
|
||||
invoke<void>("set_brief_exposed", { id, exposed }),
|
||||
mcpStatus: () => invoke("mcp_status"),
|
||||
mcpStatus: () => invoke<McpStatus>("mcp_status"),
|
||||
setMcpEnabled: (enabled: boolean, transport?: "http" | "stdio", port?: number) =>
|
||||
invoke<{ endpoint: string; token: string }>("set_mcp_enabled", { enabled, transport, port }),
|
||||
setMcpScope: (expose: "none" | "selected" | "all", exposeRecordings?: boolean) =>
|
||||
@@ -482,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) ----
|
||||
@@ -491,17 +635,28 @@ export const events = {
|
||||
onRetention: (cb: (p: { meetingId: string; record: boolean }) => void): Promise<UnlistenFn> =>
|
||||
listen("recording://retention", (e) => cb(e.payload as never)),
|
||||
onLevel: (
|
||||
cb: (p: { meetingId: string; rms: number; peak: number }) => void,
|
||||
cb: (p: { meetingId: string; rms: number; peak: number; mic: boolean }) => void,
|
||||
): Promise<UnlistenFn> => listen("recording://level", (e) => cb(e.payload as never)),
|
||||
onDeviceChanged: (
|
||||
cb: (p: { meetingId: string; recovered: boolean; message: string }) => void,
|
||||
): 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> =>
|
||||
@@ -512,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";
|
||||
@@ -538,8 +701,11 @@ export const events = {
|
||||
onSyncLinked: (
|
||||
cb: (p: { ok: boolean; kind: string; error?: string }) => void,
|
||||
): Promise<UnlistenFn> => listen("sync://linked", (e) => cb(e.payload as never)),
|
||||
onMcpAccess: (cb: (p: McpAccessEntry & { client?: string }) => void): Promise<UnlistenFn> =>
|
||||
listen("mcp://access", (e) => cb(e.payload as never)),
|
||||
// Live tail of the FR-MCP-5 audit log (camelCase on the wire, unlike the
|
||||
// snake_case McpAccessEntry rows `mcpAccessLog()` returns).
|
||||
onMcpAccess: (
|
||||
cb: (p: { at: number; tool: string; meetingId?: MeetingId; client?: string }) => void,
|
||||
): Promise<UnlistenFn> => listen("mcp://access", (e) => cb(e.payload as never)),
|
||||
onAgentProgress: (
|
||||
cb: (p: { briefId: string; tool: string; line: string }) => void,
|
||||
): Promise<UnlistenFn> => listen("agent://progress", (e) => cb(e.payload as never)),
|
||||
|
||||
@@ -3,21 +3,19 @@
|
||||
// Settings "Record by default" toggle and the mid-meeting retention toggle
|
||||
// so both gate on the same copy and the same acknowledgment call.
|
||||
import { ShieldAlert } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
let { onAccept, onCancel }: { onAccept: () => void; onCancel: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<div class="consent">
|
||||
<div class="heading">
|
||||
<ShieldAlert size={18} aria-hidden="true" />
|
||||
<strong>Before you record</strong>
|
||||
<strong>{t("consent.heading")}</strong>
|
||||
</div>
|
||||
<p>
|
||||
Recording conversations without the consent of participants may be illegal in your region. Check
|
||||
your local recording laws. This is a caution, not legal advice.
|
||||
</p>
|
||||
<p>{t("consent.body")}</p>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={onAccept}>I understand — enable recording</button>
|
||||
<button onclick={onCancel}>Cancel</button>
|
||||
<button class="primary" onclick={onAccept}>{t("consent.accept")}</button>
|
||||
<button onclick={onCancel}>{t("consent.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
// One-time third-party "data leaves your device" notice (ADR-0011, T10.3/
|
||||
// M3.3), shown before the first use of any hosted (non-local) AI provider —
|
||||
// Anthropic today, a hosted OpenAI-compatible gateway once wired the same
|
||||
// way. Same shared-copy/shared-acknowledgment pattern as ConsentNotice.svelte
|
||||
// (recording consent, ADR-0009): both gate a single Settings toggle AND a
|
||||
// second use-time trigger point on the same one-time flag.
|
||||
import { Globe } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
let {
|
||||
providerLabel,
|
||||
onAccept,
|
||||
onCancel,
|
||||
}: { providerLabel: string; onAccept: () => void; onCancel: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<div class="banner" role="alertdialog" aria-labelledby="hosted-ai-heading">
|
||||
<div class="heading">
|
||||
<Globe size={18} aria-hidden="true" />
|
||||
<strong id="hosted-ai-heading">{t("hosted.heading", { provider: providerLabel })}</strong>
|
||||
</div>
|
||||
<p>
|
||||
{t("hosted.body_1")} <strong>{providerLabel}</strong>
|
||||
{t("hosted.body_2")}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={onAccept}>{t("hosted.accept")}</button>
|
||||
<button class="ghost" onclick={onCancel}>{t("hosted.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.banner {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 1rem 1.1rem;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--fg);
|
||||
max-width: 420px;
|
||||
}
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--warning);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.heading strong {
|
||||
color: var(--fg);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
p strong {
|
||||
color: var(--fg);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid transparent;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
button.ghost {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg);
|
||||
}
|
||||
button.ghost:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,335 @@
|
||||
<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 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, 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,
|
||||
filters: [
|
||||
{
|
||||
name: t("import.filter_av"),
|
||||
extensions: [
|
||||
"mp3",
|
||||
"m4a",
|
||||
"wav",
|
||||
"aac",
|
||||
"ogg",
|
||||
"opus",
|
||||
"flac",
|
||||
"mp4",
|
||||
"mkv",
|
||||
"mov",
|
||||
"webm",
|
||||
"avi",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (typeof path === "string") {
|
||||
source = path;
|
||||
error = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
if (!source.trim() || busy) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
const id = await api.importMedia(source.trim(), title.trim() || undefined, model || undefined);
|
||||
onImported(id);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
error = errorMessage(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("import.dialog_label")}
|
||||
tabindex="-1"
|
||||
use:trapFocus
|
||||
>
|
||||
<div class="panel">
|
||||
<header>
|
||||
<strong>{t("import.title")}</strong>
|
||||
<button
|
||||
class="close"
|
||||
onclick={onClose}
|
||||
aria-label={t("import.close")}
|
||||
title={t("import.close_title")}
|
||||
>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="muted">{t("import.body")}</p>
|
||||
|
||||
<label class="wide">
|
||||
{t("import.file_or_url")}
|
||||
<div class="row">
|
||||
<input
|
||||
class="grow"
|
||||
bind:value={source}
|
||||
placeholder={t("import.source_placeholder")}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button onclick={browse} disabled={busy} title={t("import.choose_file")}>
|
||||
<FileUp size={14} aria-hidden="true" />
|
||||
{t("import.browse")}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="grid">
|
||||
<label>
|
||||
{t("import.model_label")}
|
||||
<select bind:value={model} disabled={busy || models.length === 0}>
|
||||
{#each models as m (m.id)}
|
||||
<option value={m.id}>{m.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="hint">{t("import.model_hint")}</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t("import.title_label")} <em>({t("import.optional")})</em>
|
||||
<input bind:value={title} placeholder={t("import.title_placeholder")} disabled={busy} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
padding: 1rem;
|
||||
}
|
||||
.panel {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
width: min(540px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
header strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.close {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
label em {
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
input:focus-visible,
|
||||
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;
|
||||
align-items: center;
|
||||
}
|
||||
.row .grow {
|
||||
flex: 1;
|
||||
}
|
||||
.row button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
white-space: nowrap;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.4rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger, #d33);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.actions .note {
|
||||
flex: 1;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.actions .primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg, #fff);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.actions .primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.actions .link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -1,25 +1,46 @@
|
||||
<script lang="ts">
|
||||
// Live input level meter while recording (T7.3, FR-CAP-5). A simple bar
|
||||
// (rms fill + peak marker) rather than a full scrolling waveform — either
|
||||
// satisfies the requirement, and this is far less UI/state to get right.
|
||||
let { rms, peak }: { rms: number; peak: number } = $props();
|
||||
// Live input level meter while recording (T7.3, FR-CAP-5/7). One bar with the
|
||||
// system/loopback level (green) and — when the mic is enabled — the microphone
|
||||
// level overlaid in the accent colour, so both sides of the call are visible
|
||||
// at a glance. Each stream shows an rms fill + a peak marker.
|
||||
import { t } from "../i18n/index.svelte";
|
||||
let {
|
||||
rms,
|
||||
peak,
|
||||
micRms = 0,
|
||||
micPeak = 0,
|
||||
showMic = false,
|
||||
}: {
|
||||
rms: number;
|
||||
peak: number;
|
||||
micRms?: number;
|
||||
micPeak?: number;
|
||||
showMic?: boolean;
|
||||
} = $props();
|
||||
|
||||
// Perceptual loudness isn't linear; sqrt gives a meter that "looks right"
|
||||
// for typical speech levels instead of sitting near-empty most of the time.
|
||||
let rmsPct = $derived(Math.min(1, Math.sqrt(Math.max(0, rms))) * 100);
|
||||
let peakPct = $derived(Math.min(1, Math.sqrt(Math.max(0, peak))) * 100);
|
||||
const pct = (v: number) => Math.min(1, Math.sqrt(Math.max(0, v))) * 100;
|
||||
let rmsPct = $derived(pct(rms));
|
||||
let peakPct = $derived(pct(peak));
|
||||
let micRmsPct = $derived(pct(micRms));
|
||||
let micPeakPct = $derived(pct(micPeak));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="meter"
|
||||
role="meter"
|
||||
aria-label="Input level"
|
||||
aria-valuenow={Math.round(rmsPct)}
|
||||
aria-label={showMic ? t("levelmeter.system_mic") : t("levelmeter.system")}
|
||||
aria-valuenow={Math.round(Math.max(rmsPct, showMic ? micRmsPct : 0))}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div class="fill" style="width: {rmsPct}%"></div>
|
||||
<div class="peak" style="left: {peakPct}%"></div>
|
||||
<div class="fill system" style="width: {rmsPct}%"></div>
|
||||
<div class="peak system" style="left: {peakPct}%"></div>
|
||||
{#if showMic}
|
||||
<div class="fill mic" style="width: {micRmsPct}%"></div>
|
||||
<div class="peak mic" style="left: {micPeakPct}%"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -34,16 +55,28 @@
|
||||
.fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: var(--success);
|
||||
transition: width 60ms linear;
|
||||
/* Overlap is visible because the mic layer is translucent. */
|
||||
opacity: 0.7;
|
||||
}
|
||||
.fill.system {
|
||||
background: var(--success);
|
||||
}
|
||||
.fill.mic {
|
||||
background: var(--accent);
|
||||
}
|
||||
.peak {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--fg);
|
||||
opacity: 0.6;
|
||||
transition: left 60ms linear;
|
||||
}
|
||||
.peak.system {
|
||||
background: var(--fg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.peak.mic {
|
||||
background: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
// Draggable divider between two panes (FR-UX-1). Reports a delta in
|
||||
// pixels via onResize as the pointer moves; the parent owns the actual
|
||||
// size state and clamping. onResizeEnd fires once per drag (and per arrow
|
||||
// keypress) so the parent can persist without writing on every pixel.
|
||||
let {
|
||||
orientation = "vertical",
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
label,
|
||||
}: {
|
||||
orientation?: "vertical" | "horizontal";
|
||||
onResize: (deltaPx: number) => void;
|
||||
onResizeEnd?: () => void;
|
||||
label: string;
|
||||
} = $props();
|
||||
|
||||
let dragging = $state(false);
|
||||
let lastPos = 0;
|
||||
|
||||
function posOf(e: PointerEvent): number {
|
||||
return orientation === "vertical" ? e.clientX : e.clientY;
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
dragging = true;
|
||||
lastPos = posOf(e);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragging) return;
|
||||
const pos = posOf(e);
|
||||
onResize(pos - lastPos);
|
||||
lastPos = pos;
|
||||
}
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
onResizeEnd?.();
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const step = e.shiftKey ? 40 : 12;
|
||||
const negKey = orientation === "vertical" ? "ArrowLeft" : "ArrowUp";
|
||||
const posKey = orientation === "vertical" ? "ArrowRight" : "ArrowDown";
|
||||
if (e.key === negKey) onResize(-step);
|
||||
else if (e.key === posKey) onResize(step);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
onResizeEnd?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- WAI-ARIA "window splitter" pattern: a focusable, keyboard-operable
|
||||
role="separator" is the correct/standard shape for a resize handle —
|
||||
the a11y linter's generic "non-interactive element" rule doesn't know
|
||||
about this pattern specifically. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="splitter {orientation}"
|
||||
class:dragging
|
||||
role="separator"
|
||||
aria-orientation={orientation}
|
||||
aria-label={label}
|
||||
tabindex="0"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onkeydown={onKeydown}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
.splitter {
|
||||
flex: none;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
}
|
||||
.splitter.vertical {
|
||||
width: 5px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
.splitter.horizontal {
|
||||
height: 5px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
/* Wider invisible hit-area than the visible line — a 5px target is too
|
||||
thin to reliably grab (touch-target-size / no-precision-required). */
|
||||
.splitter::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
}
|
||||
.splitter.vertical::after {
|
||||
inset: 0 -4px;
|
||||
}
|
||||
.splitter.horizontal::after {
|
||||
inset: -4px 0;
|
||||
}
|
||||
.splitter:hover,
|
||||
.splitter:focus-visible,
|
||||
.splitter.dragging {
|
||||
background: var(--accent-soft);
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
// list it's rendered in (not a global delete — the caller decides what
|
||||
// "remove" means).
|
||||
import { X } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
|
||||
interface Props {
|
||||
tag: string;
|
||||
@@ -20,12 +21,17 @@
|
||||
class="label"
|
||||
onclick={onClick}
|
||||
disabled={!onClick}
|
||||
title={onClick ? `Filter meetings tagged "${tag}"` : undefined}
|
||||
title={onClick ? t("tagchip.filter", { tag }) : undefined}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
{#if removable}
|
||||
<button type="button" class="remove" onclick={onRemove} aria-label={`Remove tag ${tag}`}>
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
onclick={onRemove}
|
||||
aria-label={t("tagchip.remove", { tag })}
|
||||
>
|
||||
<X size={10} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// plain <select> — three icon buttons instead of a text dropdown, defaults
|
||||
// to "system" so the app follows the OS until the user picks an override.
|
||||
import { Monitor, Sun, Moon } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
|
||||
interface Props {
|
||||
value: "system" | "light" | "dark";
|
||||
@@ -10,21 +11,23 @@
|
||||
}
|
||||
let { value, onchange }: Props = $props();
|
||||
|
||||
// labelKey resolves through t() in the template so a language switch relabels
|
||||
// the buttons (a t() call in this module-level const would evaluate once).
|
||||
const options = [
|
||||
{ id: "system", label: "Match system", Icon: Monitor },
|
||||
{ id: "light", label: "Light", Icon: Sun },
|
||||
{ id: "dark", label: "Dark", Icon: Moon },
|
||||
{ id: "system", labelKey: "theme.system", Icon: Monitor },
|
||||
{ id: "light", labelKey: "theme.light", Icon: Sun },
|
||||
{ id: "dark", labelKey: "theme.dark", Icon: Moon },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<div class="toggle" role="radiogroup" aria-label="Theme">
|
||||
<div class="toggle" role="radiogroup" aria-label={t("theme.group")}>
|
||||
{#each options as opt (opt.id)}
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={value === opt.id}
|
||||
class:active={value === opt.id}
|
||||
title={opt.label}
|
||||
aria-label={opt.label}
|
||||
title={t(opt.labelKey)}
|
||||
aria-label={t(opt.labelKey)}
|
||||
onclick={() => onchange(opt.id)}
|
||||
>
|
||||
<opt.Icon size={15} strokeWidth={2} aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"nav.recording": "التسجيل",
|
||||
"nav.hardware": "العتاد",
|
||||
"nav.storage": "التخزين",
|
||||
"nav.calendar": "التقويم",
|
||||
"nav.sync": "المزامنة",
|
||||
"nav.ai": "الذكاء الاصطناعي",
|
||||
"nav.mcp": "خادم MCP",
|
||||
"nav.privacy": "الخصوصية",
|
||||
"nav.about": "حول",
|
||||
"nav.language": "اللغة",
|
||||
|
||||
"settings.language.title": "اللغة",
|
||||
"settings.language.display": "لغة العرض",
|
||||
"settings.language.display_hint": "لغة واجهة التطبيق. إضافة لغة بسيطة مثل إدراج ملف ترجمة واحد.",
|
||||
|
||||
"settings.transcription.title": "لغة النسخ",
|
||||
"settings.transcription.label": "اللغة",
|
||||
"settings.transcription.auto": "الكشف التلقائي",
|
||||
"settings.transcription.applies_hint": "ينطبق على التسجيل التالي. تُعرض اللغة المستخدمة فعليًا بعد ذلك في كل اجتماع.",
|
||||
"settings.transcription.english_only": "الإنجليزية فقط",
|
||||
"settings.transcription.model_english_only": "«{model}» بالإنجليزية فقط.",
|
||||
"settings.transcription.no_model": "لم يتم اختيار نموذج.",
|
||||
"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": "الخلفية المفضلة",
|
||||
"settings.hardware.auto_backend": "تلقائي (الأفضل المتاح)",
|
||||
"settings.hardware.not_available": "غير متاح",
|
||||
"settings.hardware.fallback_hint": "يتراجع تلقائيًا (NPU → NVIDIA → AMD → Intel → CPU) إذا فشل تحميل الخلفية المختارة.",
|
||||
"settings.hardware.audio_devices": "أجهزة الصوت",
|
||||
"settings.hardware.recording_device": "جهاز التسجيل",
|
||||
"settings.hardware.default_system_audio": "صوت النظام الافتراضي",
|
||||
"settings.hardware.recording_device_hint": "يسجل WhispAssist ما يشغّله هذا الجهاز (استرجاع حلقي) — الطرف الآخر من المكالمة. اختر مخرجًا محددًا إذا كنت لا تريده أن يتبع افتراضي نظام Windows.",
|
||||
"settings.hardware.microphone": "الميكروفون",
|
||||
"settings.hardware.mic_off": "إيقاف — لا تلتقط ميكروفوني",
|
||||
"settings.hardware.default_mic": "الميكروفون الافتراضي",
|
||||
"settings.hardware.mic_hint": "يضيف صوتك إلى النسخ المباشر بحيث يُلتقط طرفا الاجتماع كلاهما. يبقى على جهازك — لا يُرفع أي شيء. اختر «إيقاف» لنسخ صوت النظام أعلاه فقط.",
|
||||
"settings.hardware.npu_detected": "تم اكتشاف مسرّع NPU",
|
||||
"settings.hardware.ready": "جاهز",
|
||||
"settings.hardware.downloading": "جارٍ التنزيل…",
|
||||
"settings.hardware.package_needed": "الحزمة مطلوبة",
|
||||
"settings.hardware.npu_hint": "يحتاج NPU الخاص بك إلى حزمة تسريع لمرة واحدة (نموذج Whisper ONNX{extra}). يتم تنزيلها في الخلفية؛ يمكنك أيضًا بدؤها الآن.",
|
||||
"settings.hardware.plus_openvino": " + بيئة تشغيل OpenVINO",
|
||||
"settings.hardware.download_npu": "تنزيل حزمة NPU",
|
||||
"settings.hardware.dml_title": "تسريع GPU (DirectML)",
|
||||
"settings.hardware.dml_hint": "يمكن تسريع GPU الخاص بك دون Vulkan عبر DirectML — حزمة لمرة واحدة (نموذج Whisper ONNX{extra}).",
|
||||
"settings.hardware.plus_directml": " + بيئة تشغيل DirectML",
|
||||
"settings.hardware.download_dml": "تنزيل حزمة DirectML",
|
||||
"settings.hardware.detection_unavailable": "اكتشاف العتاد غير متاح.",
|
||||
"settings.hardware.low_overhead": "إعداد مسبق منخفض الحمل",
|
||||
"settings.hardware.low_overhead_note": "المعالج + أصغر نموذج، لاستخدام البطارية/الخلفية",
|
||||
"settings.hardware.models": "النماذج",
|
||||
"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": "لا يمكن إزالة النموذج النشط",
|
||||
"settings.hardware.download": "تنزيل",
|
||||
"settings.hardware.diarization": "فصل المتحدثين",
|
||||
"settings.hardware.diarization_hint": "ثبّت كلا النموذجين لفصل التسجيلات المكتملة حسب المتحدث (المتحدث 1، المتحدث 2، …) بدلًا من نسخ متواصل واحد. يعمل دون اتصال تمامًا، مرة واحدة لكل اجتماع بعد توقفه. بدونهما، يُنسب كل سطر إلى متحدث واحد.",
|
||||
|
||||
"settings.storage.title": "التخزين",
|
||||
"settings.storage.location_label": "الموقع",
|
||||
"settings.storage.location_hint": "تغيير موقع التخزين غير مدعوم بعد — هنا تقيم حاليًا الاجتماعات والنماذج وقاعدة البيانات.",
|
||||
"settings.storage.retention_title": "الاحتفاظ",
|
||||
"settings.storage.retention_hint": "يحذف تلقائيًا أقدم الاجتماعات التي تتجاوز حدًا معينًا. يُفحص مرة واحدة عند بدء التشغيل؛ الفارغ يعني بلا حد. لا يمس أبدًا اجتماعًا يجري تسجيله حاليًا.",
|
||||
"settings.storage.max_age": "الحد الأقصى للعمر (أيام)",
|
||||
"settings.storage.max_size": "الحد الأقصى للحجم (غيغابايت)",
|
||||
"settings.storage.no_limit": "بلا حد",
|
||||
"settings.storage.export_import_title": "التصدير والاستيراد",
|
||||
"settings.storage.export_hint_1": "انقل التسجيلات بين أجهزة الكمبيوتر كمجلدات حزم محمولة (صوت، نسخ، ملاحظات، ملخص و",
|
||||
"settings.storage.export_hint_2": "بيان). صدّر إلى أي مجلد — قرص متزامن، أو ذاكرة USB، أو نقطة تحميل محلية لهدف مزامنة — ثم استورده على الجهاز الآخر. تحصل الاجتماعات المستوردة على معرّف جديد، لذا لا تكتب إعادة الاستيراد فوق أي شيء أبدًا.",
|
||||
"settings.storage.export_dialog_title": "تصدير كل الاجتماعات إلى…",
|
||||
"settings.storage.import_dialog_title": "استيراد حزمة (أو مجلد حزم)…",
|
||||
"settings.storage.exporting": "جارٍ التصدير…",
|
||||
"settings.storage.export_all": "تصدير كل الاجتماعات…",
|
||||
"settings.storage.importing": "جارٍ الاستيراد…",
|
||||
"settings.storage.import_all": "استيراد اجتماعات…",
|
||||
"settings.storage.exported_one": "تم تصدير {n} اجتماع.",
|
||||
"settings.storage.exported_many": "تم تصدير {n} اجتماعًا.",
|
||||
"settings.storage.imported_one": "تم استيراد {n} اجتماع.",
|
||||
"settings.storage.imported_many": "تم استيراد {n} اجتماعًا.",
|
||||
|
||||
"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": "استعراض…",
|
||||
"settings.calendar.password": "كلمة المرور",
|
||||
"settings.calendar.password_note": "نادرًا ما تكون مطلوبة",
|
||||
"settings.calendar.optional": "اختياري",
|
||||
"settings.calendar.import_range": "نطاق الاستيراد",
|
||||
"settings.calendar.range_30": "آخر 30 يومًا",
|
||||
"settings.calendar.range_90": "آخر 90 يومًا",
|
||||
"settings.calendar.range_180": "آخر 6 أشهر",
|
||||
"settings.calendar.range_365": "آخر سنة",
|
||||
"settings.calendar.range_all": "كل الأوقات",
|
||||
"settings.calendar.range_hint": "قد يحتوي صندوق بريد طويل العمر على سنوات من الإدخالات المتكررة/الأعياد — يبقي تضييق النطاق التقويم المستورد على ما هو ذو صلة فعلًا. ينطبق على زر الاستيراد هذا وعلى إعادة الاستيراد التلقائي عند التشغيل.",
|
||||
"settings.calendar.importing": "جارٍ الاستيراد…",
|
||||
"settings.calendar.import": "استيراد",
|
||||
"settings.calendar.import_progress": "{processed}/{total} حدثًا",
|
||||
"settings.calendar.auto_reimport": "أعد استيراد هذا الملف تلقائيًا عند التشغيل",
|
||||
"settings.calendar.auto_reimport_hint": "يعمل مرة واحدة عند بدء التشغيل، لا على مؤقّت — إعادة الاستيراد آمنة للتكرار (تُطابَق الأحداث الموجودة وتُحدَّث، لا تُكرَّر).",
|
||||
"settings.calendar.import_failed": "فشل الاستيراد: {error} — الملف نفسه لم يُمس؛ تحقق من المسار وحاول مجددًا.",
|
||||
"settings.calendar.cleanup_title": "تنظيف",
|
||||
"settings.calendar.cleanup_hint": "تُحفظ دائمًا الأحداث المرتبطة بالفعل باجتماع مسجّل، بغض النظر عن الخيار المختار أدناه.",
|
||||
"settings.calendar.cleanup_30": "أقدم من 30 يومًا",
|
||||
"settings.calendar.cleanup_90": "أقدم من 90 يومًا",
|
||||
"settings.calendar.cleanup_180": "أقدم من 6 أشهر",
|
||||
"settings.calendar.cleanup_365": "أقدم من سنة",
|
||||
"settings.calendar.cleanup_all": "حذف الكل",
|
||||
"settings.calendar.cleaning": "جارٍ التنظيف…",
|
||||
"settings.calendar.cleanup_btn": "تنظيف التقويم",
|
||||
"settings.calendar.cleanup_result": "تم حذف {deleted}{extra}.",
|
||||
"settings.calendar.cleanup_kept": "، مع الاحتفاظ بـ {n} (مرتبطة باجتماع)",
|
||||
"settings.calendar.cleanup_failed": "فشل التنظيف: {error}",
|
||||
"settings.calendar.imported_events": "الأحداث المستوردة",
|
||||
"settings.calendar.no_events": "لم تُستورد أحداث بعد.",
|
||||
"settings.calendar.search_title": "البحث في العنوان",
|
||||
"settings.calendar.search_placeholder": "اسم الاجتماع…",
|
||||
"settings.calendar.date_label": "التاريخ",
|
||||
"settings.calendar.events_count": "{shown} من {total} حدثًا",
|
||||
"settings.calendar.untitled": "(بلا عنوان)",
|
||||
|
||||
"settings.dialog_title": "الإعدادات",
|
||||
"settings.close": "إغلاق",
|
||||
"settings.close_title": "إغلاق (Esc)",
|
||||
|
||||
"settings.sync.stub": "وضع المعاينة — خلفية المزامنة غير منفّذة بعد (المرحلة 9). تُحفظ التغييرات في الواجهة فقط.",
|
||||
"settings.sync.title": "المزامنة والرفع",
|
||||
"settings.sync.enable_label": "تمكين رفع عناصر الاجتماع إلى الأهداف المُهيّأة",
|
||||
"settings.sync.enable_hint": "متوقف افتراضيًا. لا يُرفع أي شيء ما لم يكن هذا مفعّلًا وهدف ممكّنًا. تُبقي الأهداف المستضافة ذاتيًا البيانات على خادمك الخاص؛ سُحُب الأطراف الثالثة مُعلّمة بوضوح.",
|
||||
"settings.sync.targets_title": "الأهداف",
|
||||
"settings.sync.no_targets": "لا أهداف بعد. أضف واحدًا أدناه.",
|
||||
"settings.sync.third_party": "طرف ثالث",
|
||||
"settings.sync.your_server": "خادمك",
|
||||
"settings.sync.edit": "تحرير",
|
||||
"settings.sync.remove": "إزالة",
|
||||
"settings.sync.edit_target": "تحرير الهدف",
|
||||
"settings.sync.add_target": "إضافة هدف",
|
||||
"settings.sync.webdav_hint": "يغطي WebDAV كلًا من Nextcloud وownCloud وCloudreve وSeafile وSynology. (Seafile: فعّل SeafDAV من جهة الخادم.)",
|
||||
"settings.sync.name": "الاسم",
|
||||
"settings.sync.name_placeholder": "Nextcloud المنزلي",
|
||||
"settings.sync.provider": "المزوّد",
|
||||
"settings.sync.server_url": "عنوان URL للخادم",
|
||||
"settings.sync.server_url_note_1": "عنوان URL لخادمك فقط — يضيف WhispAssist",
|
||||
"settings.sync.server_url_note_2": "تلقائيًا.",
|
||||
"settings.sync.remote_folder": "المجلد البعيد",
|
||||
"settings.sync.username": "اسم المستخدم",
|
||||
"settings.sync.app_password": "كلمة مرور التطبيق",
|
||||
"settings.sync.pw_keep": "اتركها فارغة للاحتفاظ بكلمة المرور الحالية",
|
||||
"settings.sync.pw_store": "مخزّنة في مخزن بيانات اعتماد نظام التشغيل",
|
||||
"settings.sync.upload_legend": "رفع",
|
||||
"settings.sync.artifact_transcript": "النسخ",
|
||||
"settings.sync.artifact_notes": "الملاحظات",
|
||||
"settings.sync.artifact_summary": "الملخص",
|
||||
"settings.sync.artifact_recording": "التسجيل (.wav، إن احتُفظ به)",
|
||||
"settings.sync.allow_plaintext": "السماح بـ http نصي عادي لعنوان LAN (غير موصى به)",
|
||||
"settings.sync.encrypt_before": "التشفير قبل الرفع (تخزّن الوجهة النص المشفّر فقط)",
|
||||
"settings.sync.requires_vault": "— يتطلب خزنة مفتوحة",
|
||||
"settings.sync.test_connection": "اختبار الاتصال",
|
||||
"settings.sync.save_changes": "حفظ التغييرات",
|
||||
"settings.sync.add_target_btn": "إضافة هدف",
|
||||
"settings.sync.cancel": "إلغاء",
|
||||
"settings.sync.third_party_banner": "{kind} سحابة طرف ثالث — يرسل الرفع بياناتك خارج جهازك إلى {kind}.",
|
||||
"settings.sync.link_account": "ربط حساب {kind}…",
|
||||
"settings.sync.oauth_hint": "يفتح تسجيل دخول OAuth (إعادة توجيه حلقية)؛ يُخزَّن الرمز المميز في مخزن بيانات اعتماد نظام التشغيل.",
|
||||
"settings.sync.footnote": "لا تُكتب بيانات الاعتماد أبدًا في الإعدادات أو قاعدة البيانات — فقط في مخزن بيانات اعتماد نظام التشغيل. TLS مطلوب للأهداف خارج LAN.",
|
||||
|
||||
"settings.ai.title": "مزوّد ملخصات الذكاء الاصطناعي",
|
||||
"settings.ai.intro_1": "تعمل الملخصات على نموذج LLM محلي افتراضيًا. وجّه هذا إلى Ollama على هذا الكمبيوتر أو جهاز آخر في شبكتك المحلية (مثل",
|
||||
"settings.ai.intro_2": "— كلاهما يُحسب محليًا، لذا لا يغادر أي شيء شبكتك. المزوّدون المستضافون (Anthropic) اختياريون، متوقفون افتراضيًا، ويرسلون النسخ إلى طرف ثالث بمجرد تشغيل أحدهم.",
|
||||
"settings.ai.provider": "المزوّد",
|
||||
"settings.ai.off": "إيقاف",
|
||||
"settings.ai.provider_ollama": "Ollama (محلي / LAN)",
|
||||
"settings.ai.provider_custom": "مخصص (متوافق مع OpenAI)",
|
||||
"settings.ai.provider_anthropic": "Anthropic (Claude) — مستضاف، يغادر هذا الجهاز",
|
||||
"settings.ai.endpoint": "نقطة النهاية",
|
||||
"settings.ai.model": "النموذج",
|
||||
"settings.ai.api_key": "مفتاح API",
|
||||
"settings.ai.api_key_set_placeholder": "•••••••••••••••• (مضبوط بالفعل — اتركه فارغًا للاحتفاظ به)",
|
||||
"settings.ai.show_api_key": "إظهار مفتاح API",
|
||||
"settings.ai.hide_api_key": "إخفاء مفتاح API",
|
||||
"settings.ai.anthropic_banner": "Anthropic خدمة مستضافة من طرف ثالث — يغادر نسخ هذا الاجتماع جهازك عندما تُنشئ ملخصًا.",
|
||||
"settings.ai.endpoint_banner": "نقطة النهاية هذه ليست على جهازك أو شبكتك المحلية — سيغادر نسخك شبكتك.",
|
||||
"settings.ai.saving": "جارٍ الحفظ…",
|
||||
"settings.ai.save": "حفظ",
|
||||
"settings.ai.test_connection": "اختبار الاتصال",
|
||||
"settings.ai.this_hosted_provider": "هذا المزوّد المستضاف",
|
||||
"settings.ai.reachable": "يمكن الوصول إليه",
|
||||
"settings.ai.unreachable": "لا يمكن الوصول إليه",
|
||||
"settings.ai.model_count_one": "· {n} نموذج",
|
||||
"settings.ai.model_count_many": "· {n} نموذجًا",
|
||||
"settings.ai.on_lan": "· على جهازك / LAN",
|
||||
"settings.ai.leaves_network": "· يغادر شبكتك",
|
||||
"settings.ai.advanced_title": "تهيئة Ollama المتقدمة",
|
||||
"settings.ai.changed": "تم تغيير {n}",
|
||||
"settings.ai.reset_all": "إعادة تعيين الكل إلى الافتراضيات",
|
||||
"settings.ai.system_prompt": "موجّه النظام",
|
||||
"settings.ai.system_prompt_placeholder": "مثال: أنت مُلخِّص اجتماعات موجز.",
|
||||
"settings.ai.system_prompt_hint": "يُضاف قبل تنسيق الإخراج المطلوب من WhispAssist، بحيث لا تستطيع تعليماتك كسر تحليل الملخص/عناصر الإجراء.",
|
||||
"settings.ai.think": "التفكير",
|
||||
"settings.ai.think_help": "جهد الاستدلال (نماذج الاستدلال فقط).",
|
||||
"settings.ai.keep_alive": "الإبقاء نشطًا",
|
||||
"settings.ai.keep_alive_help": "مدة بقاء النموذج في الذاكرة. مثال: 5m، 1h، 0 (إلغاء التحميل)، -1 (للأبد).",
|
||||
"settings.ai.runtime_hardware": "وقت التشغيل والعتاد",
|
||||
"settings.ai.rarely_needed": "— نادرًا ما يكون مطلوبًا",
|
||||
"settings.ai.save_advanced": "حفظ المتقدم",
|
||||
"settings.ai.saved": "تم الحفظ",
|
||||
"settings.ai.turn_off": "إيقاف",
|
||||
"settings.ai.default_value": "الافتراضي {value}.",
|
||||
"settings.ai.reset_default": "إعادة التعيين إلى الافتراضي",
|
||||
"settings.ai.reset_field": "إعادة تعيين {name}",
|
||||
|
||||
"settings.mcp.title": "خادم MCP",
|
||||
"settings.mcp.banner_1": "يتيح هذا لوكيل البرمجة الخاص بك (Claude Code، Codex، Copilot، OpenCode، …) سحب سياق الاجتماع على جهازك المحلي. بمجرد الاتصال،",
|
||||
"settings.mcp.banner_agent": "قد يعيد ذلك الوكيل",
|
||||
"settings.mcp.banner_2": "توجيه ما يقرؤه إلى سحابة مزوّد نموذجه الخاص — خارج سيطرة WhispAssist. لا يرسل WhispAssist نفسه هذه البيانات إلى أي مكان أبدًا؛ يستمع الخادم على هذا الجهاز فقط (",
|
||||
"settings.mcp.banner_3": ")، وتُسجَّل كل قراءة أدناه.",
|
||||
"settings.mcp.enable": "تمكين خادم MCP",
|
||||
"settings.mcp.enable_hint": "متوقف افتراضيًا. حلقي فقط، محمي برمز مميز — لا شيء يمكن الوصول إليه من الشبكة.",
|
||||
"settings.mcp.transport": "النقل",
|
||||
"settings.mcp.transport_http": "HTTP متدفق",
|
||||
"settings.mcp.transport_stdio": "stdio (ينشئ الوكيل عملية)",
|
||||
"settings.mcp.port": "المنفذ",
|
||||
"settings.mcp.transport_change_hint": "لتغيير النقل/المنفذ، أوقف الخادم أولًا ثم أعد تشغيله.",
|
||||
"settings.mcp.scope_title": "النطاق",
|
||||
"settings.mcp.expose": "الكشف",
|
||||
"settings.mcp.scope_none": "لا شيء — لا يُشارَك شيء",
|
||||
"settings.mcp.scope_selected": "المحدد — فقط ملخصات الميزات التي وسمتها كمُشارَكة",
|
||||
"settings.mcp.scope_all": "الكل — الاجتماعات والنسخ وعناصر الإجراء والملخصات المشتركة",
|
||||
"settings.mcp.expose_recordings": "السماح أيضًا بالاجتماعات ذات التسجيل المحفوظ (متوقف افتراضيًا — يُحجب نسخ الاجتماع المسجّل حتى في نطاق «الكل» إلى أن يُفعَّل هذا)",
|
||||
"settings.mcp.token_title": "رمز مصادقة جديد — يُعرض مرة واحدة، انسخه الآن",
|
||||
"settings.mcp.token_hint": "لن يُعرض هذا مجددًا. يُخزَّن في مخزن بيانات اعتماد نظام التشغيل؛ إذا فقدته، أوقف الخادم وأعد تشغيله لإنشاء واحد جديد.",
|
||||
"settings.mcp.copied": "تم النسخ",
|
||||
"settings.mcp.copy": "نسخ",
|
||||
"settings.mcp.endpoint": "نقطة النهاية",
|
||||
"settings.mcp.endpoint_hint": "وجّه تهيئة عميل MCP لوكيلك إلى {kind}، مع الرمز المميز أعلاه كبيانات اعتماد bearer.",
|
||||
"settings.mcp.kind_command": "أمر",
|
||||
"settings.mcp.kind_url": "عنوان URL",
|
||||
"settings.mcp.access_log": "سجل الوصول",
|
||||
"settings.mcp.access_log_hint": "كل قراءة أداة يقوم بها وكيل، مسموحة أو مرفوضة (FR-MCP-5).",
|
||||
"settings.mcp.no_reads": "لم يقرأ أي وكيل أي شيء بعد.",
|
||||
"settings.mcp.log_meeting": "اجتماع {id}",
|
||||
"settings.mcp.refresh": "تحديث",
|
||||
|
||||
"settings.privacy.title": "الخصوصية",
|
||||
"settings.privacy.intro": "ما يُسمح لـ WhispAssist فعليًا بإرساله خارج هذا الجهاز الآن. مع إيقاف كل شيء (الافتراضي)، لا يغادر أي شيء الجهاز على الإطلاق.",
|
||||
"settings.privacy.llm_endpoint": "نقطة نهاية LLM",
|
||||
"settings.privacy.off": "إيقاف",
|
||||
"settings.privacy.local_only": "محلي فقط",
|
||||
"settings.privacy.leaves_device": "يغادر هذا الجهاز",
|
||||
"settings.privacy.sync_label": "المزامنة",
|
||||
"settings.privacy.enabled": "مفعّلة",
|
||||
"settings.privacy.mcp_label": "خادم MCP",
|
||||
"settings.privacy.mcp_on": "تشغيل · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "وارد على الحلقة فقط — لا يضيف شيئًا إلى قائمة الصادر أعلاه. قد يظل وكيل متصل يعيد توجيه ما يقرؤه إلى مزوّد نموذجه الخاص؛ راجع علامة تبويب خادم MCP.",
|
||||
"settings.privacy.egress_title": "قائمة السماح للصادر",
|
||||
"settings.privacy.no_egress": "لا مضيفات في قائمة السماح — لا يجري WA أي إرسال للمحتوى.",
|
||||
"settings.privacy.sync_targets_title": "أهداف المزامنة",
|
||||
"settings.privacy.third_party": "طرف ثالث",
|
||||
"settings.privacy.your_server": "خادمك",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "بلا TLS",
|
||||
"settings.privacy.refresh": "تحديث",
|
||||
"settings.privacy.unavailable": "فحص الخصوصية الذاتي غير متاح.",
|
||||
"settings.privacy.vault_title": "خزنة التشفير",
|
||||
"settings.privacy.vault_intro": "شفّر الملاحظات والنسخ والملخصات أثناء السكون بكلمة مرور. (ملفات الصوت غير مشفّرة بعد.)",
|
||||
"settings.privacy.vault_password": "كلمة مرور الخزنة",
|
||||
"settings.privacy.enable_vault": "تمكين الخزنة",
|
||||
"settings.privacy.vault_pw_hint": "استخدم 8 أحرف على الأقل. إذا نسيتها، لا يمكن استرداد المحتوى المشفّر.",
|
||||
"settings.privacy.vault_locked_1": "الخزنة ",
|
||||
"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": "مفتوحة",
|
||||
"settings.privacy.vault_unlocked_2": ". تُشفَّر الملاحظات والنسخ والملخصات الجديدة أثناء السكون.",
|
||||
"settings.privacy.lock_now": "أقفل الآن",
|
||||
"settings.privacy.change_password": "تغيير كلمة المرور",
|
||||
"settings.privacy.current_password": "كلمة المرور الحالية",
|
||||
"settings.privacy.new_password": "كلمة المرور الجديدة",
|
||||
|
||||
"settings.about.title": "حول",
|
||||
"settings.about.tagline": "مساعد اجتماعات محلي بالكامل، مفتوح المصدر، أصلي لنظام Windows.",
|
||||
"settings.about.build_commit": "التزام البناء",
|
||||
|
||||
"consent.heading": "قبل أن تسجّل",
|
||||
"consent.body": "قد يكون تسجيل المحادثات دون موافقة المشاركين غير قانوني في منطقتك. تحقق من قوانين التسجيل المحلية لديك. هذا تنبيه، وليس استشارة قانونية.",
|
||||
"consent.accept": "أفهم — تمكين التسجيل",
|
||||
"consent.cancel": "إلغاء",
|
||||
|
||||
"hosted.heading": "قبل استخدام {provider}",
|
||||
"hosted.body_1": "الإنشاء باستخدام",
|
||||
"hosted.body_2": "يرسل نسخ هذا الاجتماع إلى خوادمهم — يغادر هذا الجهاز ويخضع لسياسة الخصوصية الخاصة بهم. ليس لدى WhispAssist أي سيطرة على معالجة البيانات بمجرد مغادرتها جهازك. هذا متوقف افتراضيًا؛ أنت تختاره الآن.",
|
||||
"hosted.accept": "أفهم — متابعة",
|
||||
"hosted.cancel": "إلغاء",
|
||||
|
||||
"theme.group": "السمة",
|
||||
"theme.system": "مطابقة النظام",
|
||||
"theme.light": "فاتح",
|
||||
"theme.dark": "داكن",
|
||||
|
||||
"import.dialog_label": "إضافة اجتماع من ملف أو عنوان URL",
|
||||
"import.title": "إضافة اجتماع",
|
||||
"import.close": "إغلاق",
|
||||
"import.close_title": "إغلاق (Esc)",
|
||||
"import.body": "استورد تسجيلًا موجودًا — ملف صوت/فيديو محلي، أو رابط (YouTube، أو صفحة بث، أو عنوان URL وسائط مباشر). يُنسخ ويُفصل حسب المتحدث تمامًا كتسجيل مباشر.",
|
||||
"import.file_or_url": "ملف أو عنوان URL",
|
||||
"import.source_placeholder": "الصق عنوان URL، أو استعرض بحثًا عن ملف…",
|
||||
"import.choose_file": "اختر ملفًا محليًا",
|
||||
"import.browse": "استعراض…",
|
||||
"import.title_label": "العنوان",
|
||||
"import.optional": "اختياري",
|
||||
"import.title_placeholder": "افتراضيًا اسم الملف",
|
||||
"import.filter_av": "صوت / فيديو",
|
||||
"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}",
|
||||
|
||||
"levelmeter.system_mic": "مستوى إدخال النظام والميكروفون",
|
||||
"levelmeter.system": "مستوى إدخال النظام",
|
||||
|
||||
"app.tagline": "محلي · خاص",
|
||||
"app.note_template": "قالب الملاحظات",
|
||||
"app.no_template": "بلا قالب",
|
||||
"app.add_meeting": "إضافة اجتماع",
|
||||
"app.add_meeting_title": "إضافة اجتماع من ملف أو عنوان URL",
|
||||
"app.record": "تسجيل",
|
||||
"app.record_title": "بدء التسجيل (Ctrl+Shift+R)",
|
||||
"app.stop": "إيقاف",
|
||||
"app.stop_title": "إيقاف التسجيل (Ctrl+Shift+R)",
|
||||
"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": "جارٍ الحفظ",
|
||||
"app.not_saved": "غير محفوظ",
|
||||
"app.reconnecting": "جارٍ إعادة توصيل جهاز الصوت…",
|
||||
"app.settings": "الإعدادات",
|
||||
"app.settings_title": "الإعدادات (Ctrl+,)",
|
||||
"app.discard_confirm": "تجاهل هذا التسجيل؟ سيُحذف صوته ونسخه.",
|
||||
"app.announce_started": "بدأ التسجيل",
|
||||
"app.announce_paused": "توقف التسجيل مؤقتًا",
|
||||
"app.announce_stopped": "توقف التسجيل",
|
||||
"app.consent_dialog": "موافقة التسجيل",
|
||||
"app.vault_dialog": "فتح خزنة التشفير",
|
||||
"app.vault_title": "فتح خزنة التشفير",
|
||||
"app.vault_desc": "اجتماعاتك مشفّرة أثناء السكون. أدخل كلمة مرورك لقراءتها.",
|
||||
"app.vault_password": "كلمة مرور الخزنة",
|
||||
"app.vault_incorrect": "كلمة مرور غير صحيحة",
|
||||
"app.unlock": "فتح القفل",
|
||||
"app.continue_locked": "المتابعة مقفلًا",
|
||||
"app.show_meetings": "إظهار قائمة الاجتماعات",
|
||||
"app.hide_meetings": "إخفاء قائمة الاجتماعات",
|
||||
"app.show_summary": "إظهار لوحة الملخص",
|
||||
"app.hide_summary": "إخفاء لوحة الملخص",
|
||||
"app.resize_meetings": "تغيير حجم قائمة الاجتماعات",
|
||||
"app.resize_summary": "تغيير حجم لوحة الملخص",
|
||||
|
||||
"meetings.search_placeholder": "البحث في الاجتماعات…",
|
||||
"meetings.search_aria": "البحث في الاجتماعات",
|
||||
"meetings.filter_tag_aria": "التصفية حسب الوسم",
|
||||
"meetings.all_tags": "كل الوسوم",
|
||||
"meetings.from_date_aria": "من تاريخ",
|
||||
"meetings.to_date_aria": "إلى تاريخ",
|
||||
"meetings.bulk_format_aria": "تنسيق التصدير الجماعي",
|
||||
"meetings.bulk_export": "تصدير جماعي",
|
||||
"meetings.bulk_export_title": "تصدير كل اجتماع يطابق مرشحات الوسم/التاريخ أعلاه",
|
||||
"meetings.exporting": "جارٍ التصدير…",
|
||||
"meetings.exported_one": "تم تصدير {n} اجتماع.",
|
||||
"meetings.exported_many": "تم تصدير {n} اجتماعًا.",
|
||||
"meetings.searching": "جارٍ البحث…",
|
||||
"meetings.loading": "جارٍ تحميل الاجتماعات…",
|
||||
"meetings.no_matches": "لا نتائج مطابقة.",
|
||||
"meetings.empty": "لا اجتماعات بعد — انقر على تسجيل أعلاه للبدء.",
|
||||
"meetings.status.recording": "جارٍ التسجيل",
|
||||
"meetings.status.transcribing": "جارٍ النسخ",
|
||||
"meetings.status.recovering": "جارٍ الاسترداد",
|
||||
"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": "إظهار النسخ",
|
||||
"transcript.hide": "إخفاء النسخ",
|
||||
"transcript.empty": "لا يوجد نسخ لهذا الاجتماع.",
|
||||
"transcript.will_appear": "سيظهر النسخ هنا أثناء تسجيلك.",
|
||||
"transcript.play_from_here": "التشغيل من هنا",
|
||||
"transcript.resize": "تغيير حجم النسخ والملاحظات",
|
||||
"transcript.reprocess_placeholder": "إعادة النسخ باستخدام…",
|
||||
"transcript.reprocess_lang_aria": "لغة إعادة المعالجة",
|
||||
"transcript.reprocess_keep_lang": "الاحتفاظ باللغة الحالية",
|
||||
"transcript.reprocess_go": "تنفيذ",
|
||||
"transcript.retranscribing": "جارٍ إعادة النسخ…",
|
||||
"transcript.has_note": "به ملاحظة",
|
||||
"transcript.note_placeholder": "أضف ملاحظة لهذه اللحظة…",
|
||||
"transcript.note_aria": "ملاحظة لسطر النسخ هذا",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "مستند Word",
|
||||
"transcript.filter_obsidian": "ملاحظة Obsidian",
|
||||
|
||||
"notes.heading": "الملاحظات",
|
||||
"notes.show": "إظهار الملاحظات",
|
||||
"notes.hide": "إخفاء الملاحظات",
|
||||
"notes.placeholder": "ملاحظات…",
|
||||
"notes.live_placeholder": "اكتب ملاحظات أثناء حديثك…",
|
||||
"notes.toolbar_aria": "تنسيق الملاحظات",
|
||||
"notes.bold": "غامق",
|
||||
"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": "المحرر",
|
||||
"notes.preview": "معاينة",
|
||||
"notes.export_md_title": "تصدير الملاحظات كـ .md",
|
||||
"notes.export_pdf_title": "تصدير الملاحظات كـ .pdf",
|
||||
"notes.export_docx_title": "تصدير الملاحظات كـ .docx",
|
||||
"notes.export_bundle_title": "تصدير الصوت + النسخ + الملاحظات إلى مجلد",
|
||||
"notes.export_obsidian_title": "تصدير ملاحظة Obsidian واحدة (ملاحظات + ملخص + نسخ، بلا صوت)",
|
||||
|
||||
"summary.recording_heading": "التسجيل",
|
||||
"summary.loading_recording": "جارٍ تحميل التسجيل…",
|
||||
"summary.sync_heading": "المزامنة",
|
||||
"summary.uploading": "جارٍ الرفع…",
|
||||
"summary.upload_now": "الرفع الآن",
|
||||
"summary.no_uploads": "لا عمليات رفع لهذا الاجتماع بعد.",
|
||||
"summary.retry": "إعادة المحاولة",
|
||||
"summary.tags_heading": "الوسوم",
|
||||
"summary.tags_select": "اختر اجتماعًا لوسمه.",
|
||||
"summary.tag_add_placeholder": "إضافة وسم…",
|
||||
"summary.tag_first_placeholder": "مشروع، عميل، موضوع…",
|
||||
"summary.generating": "جارٍ الإنشاء…",
|
||||
"summary.generate_tags": "إنشاء وسوم",
|
||||
"summary.saving": "جارٍ الحفظ…",
|
||||
"summary.save_tags": "حفظ الوسوم",
|
||||
"summary.summary_heading": "الملخص",
|
||||
"summary.ai_provider": "مزوّد الذكاء الاصطناعي",
|
||||
"summary.provider_off": "إيقاف",
|
||||
"summary.provider_custom": "مخصص",
|
||||
"summary.local": "محلي",
|
||||
"summary.leaves_device": "يغادر هذا الجهاز",
|
||||
"summary.this_hosted_provider": "هذا المزوّد المستضاف",
|
||||
"summary.select_generate": "اختر اجتماعًا لإنشاء ملخص.",
|
||||
"summary.decisions_heading": "القرارات",
|
||||
"summary.regenerate": "إعادة الإنشاء",
|
||||
"summary.generated_hint": "أُنشئ محليًا بعد الاجتماع (يتطلب مزوّد LLM محلي).",
|
||||
"summary.no_provider": "لا مزوّد ذكاء اصطناعي مُهيّأ — فعّل واحدًا في الإعدادات.",
|
||||
"summary.generate_summary": "إنشاء ملخص",
|
||||
"summary.briefs_heading": "ملخصات الميزات",
|
||||
"summary.briefs_select": "اختر اجتماعًا لإنشاء ملخصات ميزات أو عرضها.",
|
||||
"summary.briefs_desc": "يقطّر هذا الاجتماع إلى مواصفة جاهزة للوكيل (يتطلب مزوّد LLM محلي) — سلّمها إلى وكيل برمجة، أو قدّمها عبر خادم MCP بمجرد تشغيله.",
|
||||
"summary.brief_repo_placeholder": "المستودع الهدف (اختياري)، مثل acme/reporting-web",
|
||||
"summary.distilling": "جارٍ التقطير…",
|
||||
"summary.create_brief": "إنشاء ملخص ميزة",
|
||||
"summary.brief_mcp_title": "متاح عندما يكون خادم MCP قيد التشغيل",
|
||||
"summary.copied": "تم النسخ",
|
||||
"summary.copy_md": "نسخ كـ Markdown",
|
||||
"summary.copy_json": "نسخ كـ JSON",
|
||||
"summary.brief_problem": "المشكلة",
|
||||
"summary.brief_outcome": "النتيجة المرجوة",
|
||||
"summary.brief_criteria": "معايير القبول",
|
||||
"summary.brief_none": "لم يُلتقط شيء.",
|
||||
"summary.brief_context": "السياق",
|
||||
"summary.action_items_heading": "عناصر الإجراء",
|
||||
"summary.ai_select": "اختر اجتماعًا لرؤية عناصر الإجراء الخاصة به.",
|
||||
"summary.ai_empty": "لا شيء بعد — أضف واحدًا أدناه، أو أنشئ ملخصًا لاستخراجها تلقائيًا.",
|
||||
"summary.confirmed": "مؤكَّد",
|
||||
"summary.ai_text_placeholder": "عنصر إجراء…",
|
||||
"summary.ai_text_aria": "نص عنصر الإجراء",
|
||||
"summary.owner": "المسؤول",
|
||||
"summary.due_date": "تاريخ الاستحقاق",
|
||||
"summary.reminder_title": "جدولة تذكير محلي لتاريخ الاستحقاق هذا",
|
||||
"summary.ai_delete_title": "حذف عنصر الإجراء هذا",
|
||||
"summary.ai_delete_aria": "حذف عنصر الإجراء",
|
||||
"summary.add_action_item": "إضافة عنصر إجراء",
|
||||
"summary.save_action_items": "حفظ عناصر الإجراء",
|
||||
"summary.calendar_heading": "حدث التقويم",
|
||||
"summary.cal_select": "اختر اجتماعًا لربطه بحدث تقويم.",
|
||||
"summary.event_search_placeholder": "البحث في عنوان الحدث…",
|
||||
"summary.linked_event": "الحدث المرتبط",
|
||||
"summary.change_event": "تغيير الحدث…",
|
||||
"summary.link_event": "ربط حدث…",
|
||||
"summary.untitled_event": "(بلا عنوان)",
|
||||
"summary.no_events_1": "لم تُستورد أحداث بعد — استورد",
|
||||
"summary.no_events_2": "من الإعدادات → التقويم.",
|
||||
"summary.no_events_match": "لا حدث يطابق هذا البحث/التاريخ — حاول مسح أحدهما.",
|
||||
"summary.organizer_label": "المنظّم: {name}",
|
||||
"summary.participants_heading": "المشاركون",
|
||||
"summary.participants_hint": "يُملأ من حدث التقويم المرتبط.",
|
||||
"summary.speakers_heading": "المتحدثون",
|
||||
"summary.speakers_select": "اختر اجتماعًا لتسمية المتحدثين فيه.",
|
||||
"summary.no_speakers": "لم يُكتشف متحدثون بعد.",
|
||||
"summary.speaker_name_placeholder": "اسم المتحدث",
|
||||
"summary.save": "حفظ",
|
||||
"summary.cancel": "إلغاء",
|
||||
"summary.name_speaker": "تسمية هذا المتحدث…",
|
||||
"summary.add_new_name": "+ إضافة اسم جديد…",
|
||||
|
||||
"settings.recording.title": "التسجيل",
|
||||
"settings.recording.record_default": "تسجيل الاجتماعات افتراضيًا",
|
||||
"settings.recording.save_audio_as": "حفظ الصوت كـ",
|
||||
"settings.recording.default_hint": "متوقف افتراضيًا. عند الإيقاف، يُستخدم الصوت فقط لإنتاج النسخ ويُحذف عند إنهاء الاجتماع. يمكنك أيضًا تبديل التسجيل لكل اجتماع.",
|
||||
"settings.recording.consent_label": "الموافقة: {status}.",
|
||||
"settings.recording.consent_ack": "مُقرّة",
|
||||
"settings.recording.consent_not": "لم تُقرّ بعد",
|
||||
"settings.recording.auto_label": "بدء التسجيل تلقائيًا عند بدء حدث تقويم",
|
||||
"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 يعمل في الخلفية بدلاً من الخروج. أعد فتحه من أيقونة علبة النظام؛ ولإنهائه تماماً استخدم «خروج» من قائمة العلبة. مفعّل افتراضياً."
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"nav.recording": "রেকর্ডিং",
|
||||
"nav.hardware": "হার্ডওয়্যার",
|
||||
"nav.storage": "স্টোরেজ",
|
||||
"nav.calendar": "ক্যালেন্ডার",
|
||||
"nav.sync": "সিঙ্ক",
|
||||
"nav.ai": "এআই",
|
||||
"nav.mcp": "MCP সার্ভার",
|
||||
"nav.privacy": "গোপনীয়তা",
|
||||
"nav.about": "সম্পর্কে",
|
||||
"nav.language": "ভাষা",
|
||||
|
||||
"settings.language.title": "ভাষা",
|
||||
"settings.language.display": "প্রদর্শন ভাষা",
|
||||
"settings.language.display_hint": "অ্যাপ ইন্টারফেসের ভাষা। একটি ভাষা যোগ করা একটি অনুবাদ ফাইল রাখার মতোই সহজ।",
|
||||
|
||||
"settings.transcription.title": "ট্রান্সক্রিপশন ভাষা",
|
||||
"settings.transcription.label": "ভাষা",
|
||||
"settings.transcription.auto": "স্বয়ংক্রিয়ভাবে শনাক্ত করুন",
|
||||
"settings.transcription.applies_hint": "পরবর্তী রেকর্ডিংয়ে প্রযোজ্য। প্রকৃতপক্ষে ব্যবহৃত ভাষা পরে প্রতিটি মিটিংয়ে দেখানো হয়।",
|
||||
"settings.transcription.english_only": "শুধু ইংরেজি",
|
||||
"settings.transcription.model_english_only": "“{model}” শুধুমাত্র ইংরেজিতে।",
|
||||
"settings.transcription.no_model": "কোনো মডেল নির্বাচিত নেই।",
|
||||
"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": "পছন্দসই ব্যাকএন্ড",
|
||||
"settings.hardware.auto_backend": "স্বয়ংক্রিয় (সেরা উপলব্ধ)",
|
||||
"settings.hardware.not_available": "উপলব্ধ নয়",
|
||||
"settings.hardware.fallback_hint": "নির্বাচিত ব্যাকএন্ড লোড না হলে স্বয়ংক্রিয়ভাবে ফলব্যাক করে (NPU → NVIDIA → AMD → Intel → CPU)।",
|
||||
"settings.hardware.audio_devices": "অডিও ডিভাইস",
|
||||
"settings.hardware.recording_device": "রেকর্ডিং ডিভাইস",
|
||||
"settings.hardware.default_system_audio": "ডিফল্ট সিস্টেম অডিও",
|
||||
"settings.hardware.recording_device_hint": "WhispAssist এই ডিভাইসটি যা বাজায় তা রেকর্ড করে (লুপব্যাক) — কলের অপর প্রান্ত। আপনি যদি চান না এটি Windows-এর সিস্টেম ডিফল্ট অনুসরণ করুক, তবে একটি নির্দিষ্ট আউটপুট বেছে নিন।",
|
||||
"settings.hardware.microphone": "মাইক্রোফোন",
|
||||
"settings.hardware.mic_off": "বন্ধ — আমার মাইক্রোফোন ক্যাপচার করবেন না",
|
||||
"settings.hardware.default_mic": "ডিফল্ট মাইক্রোফোন",
|
||||
"settings.hardware.mic_hint": "লাইভ ট্রান্সক্রিপ্টে আপনার নিজের কণ্ঠস্বর যোগ করে যাতে মিটিংয়ের উভয় পক্ষ ক্যাপচার হয়। আপনার ডিভাইসে থাকে — কিছুই আপলোড হয় না। শুধু উপরের সিস্টেম অডিও ট্রান্সক্রাইব করতে “বন্ধ” বেছে নিন।",
|
||||
"settings.hardware.npu_detected": "NPU অ্যাক্সিলারেটর শনাক্ত হয়েছে",
|
||||
"settings.hardware.ready": "প্রস্তুত",
|
||||
"settings.hardware.downloading": "ডাউনলোড হচ্ছে…",
|
||||
"settings.hardware.package_needed": "প্যাকেজ প্রয়োজন",
|
||||
"settings.hardware.npu_hint": "আপনার NPU-এর একটি এককালীন অ্যাক্সিলারেশন প্যাকেজ (Whisper ONNX মডেল{extra}) প্রয়োজন। এটি পটভূমিতে ডাউনলোড হয়; আপনি এখনই এটি শুরুও করতে পারেন।",
|
||||
"settings.hardware.plus_openvino": " + OpenVINO রানটাইম",
|
||||
"settings.hardware.download_npu": "NPU প্যাকেজ ডাউনলোড করুন",
|
||||
"settings.hardware.dml_title": "GPU অ্যাক্সিলারেশন (DirectML)",
|
||||
"settings.hardware.dml_hint": "আপনার GPU Vulkan ছাড়াই DirectML-এর মাধ্যমে অ্যাক্সিলারেট করা যায় — একটি এককালীন প্যাকেজ (Whisper ONNX মডেল{extra})।",
|
||||
"settings.hardware.plus_directml": " + DirectML রানটাইম",
|
||||
"settings.hardware.download_dml": "DirectML প্যাকেজ ডাউনলোড করুন",
|
||||
"settings.hardware.detection_unavailable": "হার্ডওয়্যার শনাক্তকরণ উপলব্ধ নয়।",
|
||||
"settings.hardware.low_overhead": "কম ওভারহেড প্রিসেট",
|
||||
"settings.hardware.low_overhead_note": "CPU + সবচেয়ে ছোট মডেল, ব্যাটারি/পটভূমি ব্যবহারের জন্য",
|
||||
"settings.hardware.models": "মডেল",
|
||||
"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": "সক্রিয় মডেল সরানো যায় না",
|
||||
"settings.hardware.download": "ডাউনলোড করুন",
|
||||
"settings.hardware.diarization": "বক্তা ডায়ারাইজেশন",
|
||||
"settings.hardware.diarization_hint": "উভয় মডেল ইনস্টল করুন যাতে সম্পূর্ণ রেকর্ডিং একটি চলমান ট্রান্সক্রিপ্টের পরিবর্তে বক্তা অনুযায়ী (বক্তা ১, বক্তা ২, …) আলাদা হয়। সম্পূর্ণ অফলাইনে চলে, থামার পর প্রতি মিটিংয়ে একবার। এগুলি ছাড়া, প্রতিটি লাইন একক বক্তাকে দেওয়া হয়।",
|
||||
|
||||
"settings.storage.title": "স্টোরেজ",
|
||||
"settings.storage.location_label": "অবস্থান",
|
||||
"settings.storage.location_hint": "স্টোরেজ অবস্থান পরিবর্তন এখনও সমর্থিত নয় — বর্তমানে মিটিং, মডেল এবং ডেটাবেস এখানেই থাকে।",
|
||||
"settings.storage.retention_title": "ধারণ",
|
||||
"settings.storage.retention_hint": "একটি সীমার বাইরে থাকা সবচেয়ে পুরনো মিটিং স্বয়ংক্রিয়ভাবে মুছে ফেলে। স্টার্টআপে একবার পরীক্ষা করা হয়; খালি মানে কোনো সীমা নেই। বর্তমানে রেকর্ড হচ্ছে এমন মিটিং কখনও স্পর্শ করে না।",
|
||||
"settings.storage.max_age": "সর্বোচ্চ বয়স (দিন)",
|
||||
"settings.storage.max_size": "সর্বোচ্চ আকার (GB)",
|
||||
"settings.storage.no_limit": "কোনো সীমা নেই",
|
||||
"settings.storage.export_import_title": "রপ্তানি ও আমদানি",
|
||||
"settings.storage.export_hint_1": "রেকর্ডিংকে বহনযোগ্য বান্ডল ফোল্ডার হিসেবে কম্পিউটারের মধ্যে সরান (অডিও, ট্রান্সক্রিপ্ট, নোট, সারাংশ এবং একটি",
|
||||
"settings.storage.export_hint_2": "ম্যানিফেস্ট)। যেকোনো ফোল্ডারে রপ্তানি করুন — একটি সিঙ্ক করা ড্রাইভ, একটি USB স্টিক, বা কোনো সিঙ্ক লক্ষ্যের স্থানীয় মাউন্টে — তারপর অন্য মেশিনে এটি আমদানি করুন। আমদানি করা মিটিং একটি নতুন id পায়, তাই পুনরায় আমদানি কখনও কিছু ওভাররাইট করে না।",
|
||||
"settings.storage.export_dialog_title": "সব মিটিং এতে রপ্তানি করুন…",
|
||||
"settings.storage.import_dialog_title": "একটি বান্ডল (বা বান্ডলের একটি ফোল্ডার) আমদানি করুন…",
|
||||
"settings.storage.exporting": "রপ্তানি হচ্ছে…",
|
||||
"settings.storage.export_all": "সব মিটিং রপ্তানি করুন…",
|
||||
"settings.storage.importing": "আমদানি হচ্ছে…",
|
||||
"settings.storage.import_all": "মিটিং আমদানি করুন…",
|
||||
"settings.storage.exported_one": "{n}টি মিটিং রপ্তানি করা হয়েছে।",
|
||||
"settings.storage.exported_many": "{n}টি মিটিং রপ্তানি করা হয়েছে।",
|
||||
"settings.storage.imported_one": "{n}টি মিটিং আমদানি করা হয়েছে।",
|
||||
"settings.storage.imported_many": "{n}টি মিটিং আমদানি করা হয়েছে।",
|
||||
|
||||
"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": "ব্রাউজ করুন…",
|
||||
"settings.calendar.password": "পাসওয়ার্ড",
|
||||
"settings.calendar.password_note": "খুব কমই প্রয়োজন",
|
||||
"settings.calendar.optional": "ঐচ্ছিক",
|
||||
"settings.calendar.import_range": "আমদানি পরিসর",
|
||||
"settings.calendar.range_30": "গত ৩০ দিন",
|
||||
"settings.calendar.range_90": "গত ৯০ দিন",
|
||||
"settings.calendar.range_180": "গত ৬ মাস",
|
||||
"settings.calendar.range_365": "গত ১ বছর",
|
||||
"settings.calendar.range_all": "সব সময়",
|
||||
"settings.calendar.range_hint": "একটি দীর্ঘস্থায়ী মেইলবক্সে বছরের পর বছর পুনরাবৃত্ত/ছুটির এন্ট্রি থাকতে পারে — পরিসর সংকীর্ণ করলে আমদানি করা ক্যালেন্ডার প্রকৃতপক্ষে প্রাসঙ্গিক বিষয়েই সীমাবদ্ধ থাকে। এটি এই আমদানি বোতাম এবং চালুর সময় স্বয়ংক্রিয় পুনরায় আমদানি উভয়ে প্রযোজ্য।",
|
||||
"settings.calendar.importing": "আমদানি হচ্ছে…",
|
||||
"settings.calendar.import": "আমদানি করুন",
|
||||
"settings.calendar.import_progress": "{processed}/{total} ইভেন্ট",
|
||||
"settings.calendar.auto_reimport": "চালুর সময় এই ফাইলটি স্বয়ংক্রিয়ভাবে পুনরায় আমদানি করুন",
|
||||
"settings.calendar.auto_reimport_hint": "স্টার্টআপে একবার চলে, টাইমারে নয় — পুনরায় আমদানি নিরাপদে পুনরাবৃত্তি করা যায় (বিদ্যমান ইভেন্ট মিলিয়ে আপডেট করা হয়, ডুপ্লিকেট হয় না)।",
|
||||
"settings.calendar.import_failed": "আমদানি ব্যর্থ: {error} — ফাইলটি নিজে অক্ষত থাকে; পথ পরীক্ষা করে আবার চেষ্টা করুন।",
|
||||
"settings.calendar.cleanup_title": "পরিষ্কার করুন",
|
||||
"settings.calendar.cleanup_hint": "কোনো রেকর্ড করা মিটিংয়ের সাথে ইতিমধ্যে সংযুক্ত ইভেন্ট সবসময় রাখা হয়, নিচে যেকোনো বিকল্প বেছে নিলেও।",
|
||||
"settings.calendar.cleanup_30": "৩০ দিনের চেয়ে পুরনো",
|
||||
"settings.calendar.cleanup_90": "৯০ দিনের চেয়ে পুরনো",
|
||||
"settings.calendar.cleanup_180": "৬ মাসের চেয়ে পুরনো",
|
||||
"settings.calendar.cleanup_365": "১ বছরের চেয়ে পুরনো",
|
||||
"settings.calendar.cleanup_all": "সব মুছুন",
|
||||
"settings.calendar.cleaning": "পরিষ্কার করা হচ্ছে…",
|
||||
"settings.calendar.cleanup_btn": "ক্যালেন্ডার পরিষ্কার করুন",
|
||||
"settings.calendar.cleanup_result": "{deleted}{extra} মোছা হয়েছে।",
|
||||
"settings.calendar.cleanup_kept": ", {n}টি রাখা হয়েছে (একটি মিটিংয়ের সাথে সংযুক্ত)",
|
||||
"settings.calendar.cleanup_failed": "পরিষ্কার করা ব্যর্থ: {error}",
|
||||
"settings.calendar.imported_events": "আমদানি করা ইভেন্ট",
|
||||
"settings.calendar.no_events": "এখনও কোনো ইভেন্ট আমদানি করা হয়নি।",
|
||||
"settings.calendar.search_title": "শিরোনাম অনুসন্ধান করুন",
|
||||
"settings.calendar.search_placeholder": "মিটিংয়ের নাম…",
|
||||
"settings.calendar.date_label": "তারিখ",
|
||||
"settings.calendar.events_count": "{total}টির মধ্যে {shown}টি ইভেন্ট",
|
||||
"settings.calendar.untitled": "(শিরোনামহীন)",
|
||||
|
||||
"settings.dialog_title": "সেটিংস",
|
||||
"settings.close": "বন্ধ করুন",
|
||||
"settings.close_title": "বন্ধ করুন (Esc)",
|
||||
|
||||
"settings.sync.stub": "প্রিভিউ মোড — সিঙ্ক ব্যাকএন্ড এখনও বাস্তবায়িত হয়নি (পর্যায় ৯)। পরিবর্তনগুলি শুধু ইন্টারফেসে রাখা হয়।",
|
||||
"settings.sync.title": "সিঙ্ক ও আপলোড",
|
||||
"settings.sync.enable_label": "কনফিগার করা লক্ষ্যে মিটিং আর্টিফ্যাক্ট আপলোড সক্ষম করুন",
|
||||
"settings.sync.enable_hint": "ডিফল্টভাবে বন্ধ। এটি চালু না থাকলে এবং একটি লক্ষ্য সক্ষম না থাকলে কিছুই আপলোড হয় না। স্ব-হোস্ট করা লক্ষ্য ডেটা আপনার নিজের সার্ভারে রাখে; তৃতীয়-পক্ষের ক্লাউড স্পষ্টভাবে চিহ্নিত।",
|
||||
"settings.sync.targets_title": "লক্ষ্য",
|
||||
"settings.sync.no_targets": "এখনও কোনো লক্ষ্য নেই। নিচে একটি যোগ করুন।",
|
||||
"settings.sync.third_party": "তৃতীয়-পক্ষ",
|
||||
"settings.sync.your_server": "আপনার সার্ভার",
|
||||
"settings.sync.edit": "সম্পাদনা করুন",
|
||||
"settings.sync.remove": "সরান",
|
||||
"settings.sync.edit_target": "লক্ষ্য সম্পাদনা করুন",
|
||||
"settings.sync.add_target": "একটি লক্ষ্য যোগ করুন",
|
||||
"settings.sync.webdav_hint": "WebDAV Nextcloud, ownCloud, Cloudreve, Seafile এবং Synology কভার করে। (Seafile: সার্ভার-সাইডে SeafDAV সক্ষম করুন।)",
|
||||
"settings.sync.name": "নাম",
|
||||
"settings.sync.name_placeholder": "হোম Nextcloud",
|
||||
"settings.sync.provider": "প্রদানকারী",
|
||||
"settings.sync.server_url": "সার্ভার URL",
|
||||
"settings.sync.server_url_note_1": "শুধু আপনার সার্ভার URL — WhispAssist স্বয়ংক্রিয়ভাবে",
|
||||
"settings.sync.server_url_note_2": "যোগ করে।",
|
||||
"settings.sync.remote_folder": "দূরবর্তী ফোল্ডার",
|
||||
"settings.sync.username": "ব্যবহারকারীর নাম",
|
||||
"settings.sync.app_password": "অ্যাপ পাসওয়ার্ড",
|
||||
"settings.sync.pw_keep": "বর্তমান পাসওয়ার্ড রাখতে খালি রাখুন",
|
||||
"settings.sync.pw_store": "OS ক্রেডেনশিয়াল স্টোরে সংরক্ষিত",
|
||||
"settings.sync.upload_legend": "আপলোড",
|
||||
"settings.sync.artifact_transcript": "ট্রান্সক্রিপ্ট",
|
||||
"settings.sync.artifact_notes": "নোট",
|
||||
"settings.sync.artifact_summary": "সারাংশ",
|
||||
"settings.sync.artifact_recording": "রেকর্ডিং (.wav, যদি রাখা হয়)",
|
||||
"settings.sync.allow_plaintext": "একটি LAN ঠিকানার জন্য প্লেইনটেক্সট http অনুমতি দিন (প্রস্তাবিত নয়)",
|
||||
"settings.sync.encrypt_before": "আপলোডের আগে এনক্রিপ্ট করুন (গন্তব্য শুধু সাইফারটেক্সট সংরক্ষণ করে)",
|
||||
"settings.sync.requires_vault": "— একটি আনলক করা ভল্ট প্রয়োজন",
|
||||
"settings.sync.test_connection": "সংযোগ পরীক্ষা করুন",
|
||||
"settings.sync.save_changes": "পরিবর্তন সংরক্ষণ করুন",
|
||||
"settings.sync.add_target_btn": "লক্ষ্য যোগ করুন",
|
||||
"settings.sync.cancel": "বাতিল করুন",
|
||||
"settings.sync.third_party_banner": "{kind} একটি তৃতীয়-পক্ষের ক্লাউড — আপলোড করলে আপনার ডেটা আপনার ডিভাইসের বাইরে {kind}-এ পাঠানো হয়।",
|
||||
"settings.sync.link_account": "{kind} অ্যাকাউন্ট লিঙ্ক করুন…",
|
||||
"settings.sync.oauth_hint": "একটি OAuth সাইন-ইন খোলে (লুপব্যাক রিডাইরেক্ট); টোকেন আপনার OS ক্রেডেনশিয়াল স্টোরে সংরক্ষিত হয়।",
|
||||
"settings.sync.footnote": "ক্রেডেনশিয়াল কখনও সেটিংস বা ডেটাবেসে লেখা হয় না — শুধু OS ক্রেডেনশিয়াল স্টোরে। LAN-বহির্ভূত লক্ষ্যের জন্য TLS প্রয়োজন।",
|
||||
|
||||
"settings.ai.title": "এআই সারাংশ প্রদানকারী",
|
||||
"settings.ai.intro_1": "সারাংশ ডিফল্টভাবে একটি স্থানীয় LLM-এ চলে। এটিকে এই PC বা আপনার LAN-এর অন্য একটি মেশিনে Ollama-এর দিকে নির্দেশ করুন (যেমন",
|
||||
"settings.ai.intro_2": "— উভয়ই স্থানীয় হিসেবে গণ্য হয়, তাই আপনার নেটওয়ার্ক থেকে কিছুই বেরোয় না। হোস্ট করা প্রদানকারী (Anthropic) ঐচ্ছিক, ডিফল্টভাবে বন্ধ, এবং আপনি একটি চালু করামাত্র ট্রান্সক্রিপ্ট একটি তৃতীয় পক্ষে পাঠায়।",
|
||||
"settings.ai.provider": "প্রদানকারী",
|
||||
"settings.ai.off": "বন্ধ",
|
||||
"settings.ai.provider_ollama": "Ollama (স্থানীয় / LAN)",
|
||||
"settings.ai.provider_custom": "কাস্টম (OpenAI-সামঞ্জস্যপূর্ণ)",
|
||||
"settings.ai.provider_anthropic": "Anthropic (Claude) — হোস্ট করা, এই ডিভাইস ছেড়ে যায়",
|
||||
"settings.ai.endpoint": "এন্ডপয়েন্ট",
|
||||
"settings.ai.model": "মডেল",
|
||||
"settings.ai.api_key": "API কী",
|
||||
"settings.ai.api_key_set_placeholder": "•••••••••••••••• (ইতিমধ্যে সেট — রাখতে খালি রাখুন)",
|
||||
"settings.ai.show_api_key": "API কী দেখান",
|
||||
"settings.ai.hide_api_key": "API কী লুকান",
|
||||
"settings.ai.anthropic_banner": "Anthropic একটি হোস্ট করা তৃতীয়-পক্ষের সেবা — আপনি সারাংশ তৈরি করলে এই মিটিংয়ের ট্রান্সক্রিপ্ট আপনার ডিভাইস ছেড়ে যায়।",
|
||||
"settings.ai.endpoint_banner": "এই এন্ডপয়েন্টটি আপনার মেশিন বা LAN-এ নেই — আপনার ট্রান্সক্রিপ্ট আপনার নেটওয়ার্ক ছেড়ে যাবে।",
|
||||
"settings.ai.saving": "সংরক্ষণ হচ্ছে…",
|
||||
"settings.ai.save": "সংরক্ষণ করুন",
|
||||
"settings.ai.test_connection": "সংযোগ পরীক্ষা করুন",
|
||||
"settings.ai.this_hosted_provider": "এই হোস্ট করা প্রদানকারী",
|
||||
"settings.ai.reachable": "পৌঁছানো যায়",
|
||||
"settings.ai.unreachable": "পৌঁছানো যায় না",
|
||||
"settings.ai.model_count_one": "· {n}টি মডেল",
|
||||
"settings.ai.model_count_many": "· {n}টি মডেল",
|
||||
"settings.ai.on_lan": "· আপনার মেশিন / LAN-এ",
|
||||
"settings.ai.leaves_network": "· আপনার নেটওয়ার্ক ছেড়ে যায়",
|
||||
"settings.ai.advanced_title": "উন্নত Ollama কনফিগারেশন",
|
||||
"settings.ai.changed": "{n}টি পরিবর্তিত",
|
||||
"settings.ai.reset_all": "সব ডিফল্টে রিসেট করুন",
|
||||
"settings.ai.system_prompt": "সিস্টেম প্রম্পট",
|
||||
"settings.ai.system_prompt_placeholder": "যেমন আপনি একজন সংক্ষিপ্ত মিটিং সারাংশকারী।",
|
||||
"settings.ai.system_prompt_hint": "WhispAssist-এর প্রয়োজনীয় আউটপুট বিন্যাসের আগে যোগ করা হয়, যাতে আপনার নির্দেশনা সারাংশ/অ্যাকশন-আইটেম পার্সিং ভাঙতে না পারে।",
|
||||
"settings.ai.think": "চিন্তা করুন",
|
||||
"settings.ai.think_help": "যুক্তি প্রচেষ্টা (শুধু যুক্তি মডেল)।",
|
||||
"settings.ai.keep_alive": "সক্রিয় রাখুন",
|
||||
"settings.ai.keep_alive_help": "মডেল কতক্ষণ RAM-এ থাকে। যেমন 5m, 1h, 0 (আনলোড), -1 (চিরকাল)।",
|
||||
"settings.ai.runtime_hardware": "রানটাইম ও হার্ডওয়্যার",
|
||||
"settings.ai.rarely_needed": "— খুব কমই প্রয়োজন",
|
||||
"settings.ai.save_advanced": "উন্নত সংরক্ষণ করুন",
|
||||
"settings.ai.saved": "সংরক্ষিত",
|
||||
"settings.ai.turn_off": "বন্ধ করুন",
|
||||
"settings.ai.default_value": "ডিফল্ট {value}।",
|
||||
"settings.ai.reset_default": "ডিফল্টে রিসেট করুন",
|
||||
"settings.ai.reset_field": "{name} রিসেট করুন",
|
||||
|
||||
"settings.mcp.title": "MCP সার্ভার",
|
||||
"settings.mcp.banner_1": "এটি আপনার নিজের কোডিং এজেন্টকে (Claude Code, Codex, Copilot, OpenCode, …) আপনার স্থানীয় মেশিনে মিটিং প্রসঙ্গ টানতে দেয়। সংযুক্ত হওয়ার পর,",
|
||||
"settings.mcp.banner_agent": "সেই এজেন্ট",
|
||||
"settings.mcp.banner_2": "যা পড়ে তা তার নিজস্ব মডেল প্রদানকারীর ক্লাউডে ফরোয়ার্ড করতে পারে — WhispAssist-এর নিয়ন্ত্রণের বাইরে। WhispAssist নিজে কখনও এই ডেটা কোথাও পাঠায় না; সার্ভার শুধু এই ডিভাইসে শোনে (",
|
||||
"settings.mcp.banner_3": ") এবং প্রতিটি রিড নিচে লগ করা হয়।",
|
||||
"settings.mcp.enable": "MCP সার্ভার সক্ষম করুন",
|
||||
"settings.mcp.enable_hint": "ডিফল্টভাবে বন্ধ। শুধু লুপব্যাক, টোকেন-সুরক্ষিত — নেটওয়ার্ক থেকে কিছুই পৌঁছানো যায় না।",
|
||||
"settings.mcp.transport": "ট্রান্সপোর্ট",
|
||||
"settings.mcp.transport_http": "স্ট্রিমিং HTTP",
|
||||
"settings.mcp.transport_stdio": "stdio (এজেন্ট একটি প্রক্রিয়া তৈরি করে)",
|
||||
"settings.mcp.port": "পোর্ট",
|
||||
"settings.mcp.transport_change_hint": "ট্রান্সপোর্ট/পোর্ট পরিবর্তন করতে, প্রথমে সার্ভার বন্ধ করুন, তারপর আবার চালু করুন।",
|
||||
"settings.mcp.scope_title": "পরিধি",
|
||||
"settings.mcp.expose": "উন্মুক্ত করুন",
|
||||
"settings.mcp.scope_none": "কিছু নয় — কিছুই ভাগ করা হয় না",
|
||||
"settings.mcp.scope_selected": "নির্বাচিত — শুধু সেই ফিচার ব্রিফ যা আপনি ভাগ করা হিসেবে চিহ্নিত করেছেন",
|
||||
"settings.mcp.scope_all": "সব — মিটিং, ট্রান্সক্রিপ্ট, অ্যাকশন-আইটেম এবং ভাগ করা ব্রিফ",
|
||||
"settings.mcp.expose_recordings": "সংরক্ষিত রেকর্ডিং সহ মিটিংও অনুমতি দিন (ডিফল্টভাবে বন্ধ — এটি চালু না হওয়া পর্যন্ত একটি রেকর্ড করা মিটিংয়ের ট্রান্সক্রিপ্ট “সব” পরিধিতেও আটকে রাখা হয়)",
|
||||
"settings.mcp.token_title": "নতুন প্রমাণীকরণ টোকেন — একবার দেখানো হয়, এখনই এটি কপি করুন",
|
||||
"settings.mcp.token_hint": "এটি আর দেখানো হবে না। এটি আপনার OS ক্রেডেনশিয়াল স্টোরে সংরক্ষিত; আপনি হারালে, একটি নতুন তৈরি করতে সার্ভার বন্ধ করে আবার চালু করুন।",
|
||||
"settings.mcp.copied": "কপি করা হয়েছে",
|
||||
"settings.mcp.copy": "কপি করুন",
|
||||
"settings.mcp.endpoint": "এন্ডপয়েন্ট",
|
||||
"settings.mcp.endpoint_hint": "আপনার এজেন্টের MCP ক্লায়েন্ট কনফিগারেশন {kind}-এর দিকে নির্দেশ করুন, উপরের টোকেনটিকে bearer ক্রেডেনশিয়াল হিসেবে ব্যবহার করে।",
|
||||
"settings.mcp.kind_command": "কমান্ড",
|
||||
"settings.mcp.kind_url": "URL",
|
||||
"settings.mcp.access_log": "অ্যাক্সেস লগ",
|
||||
"settings.mcp.access_log_hint": "একজন এজেন্টের করা প্রতিটি টুল রিড, অনুমোদিত বা প্রত্যাখ্যাত (FR-MCP-5)।",
|
||||
"settings.mcp.no_reads": "এখনও কোনো এজেন্ট কিছু পড়েনি।",
|
||||
"settings.mcp.log_meeting": "মিটিং {id}",
|
||||
"settings.mcp.refresh": "রিফ্রেশ করুন",
|
||||
|
||||
"settings.privacy.title": "গোপনীয়তা",
|
||||
"settings.privacy.intro": "WhispAssist-কে এখন প্রকৃতপক্ষে এই ডিভাইস থেকে বাইরে কী পাঠানোর অনুমতি আছে। সব বন্ধ থাকলে (ডিফল্ট), ডিভাইস থেকে কিছুই বেরোয় না।",
|
||||
"settings.privacy.llm_endpoint": "LLM এন্ডপয়েন্ট",
|
||||
"settings.privacy.off": "বন্ধ",
|
||||
"settings.privacy.local_only": "শুধু স্থানীয়",
|
||||
"settings.privacy.leaves_device": "এই ডিভাইস ছেড়ে যায়",
|
||||
"settings.privacy.sync_label": "সিঙ্ক",
|
||||
"settings.privacy.enabled": "সক্ষম",
|
||||
"settings.privacy.mcp_label": "MCP সার্ভার",
|
||||
"settings.privacy.mcp_on": "চালু · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "শুধু লুপব্যাকে ইনবাউন্ড — এটি উপরের আউটগোয়িং তালিকায় কিছু যোগ করে না। একটি সংযুক্ত এজেন্ট এখনও যা পড়ে তা তার নিজস্ব মডেল প্রদানকারীকে ফরোয়ার্ড করতে পারে; MCP সার্ভার ট্যাব দেখুন।",
|
||||
"settings.privacy.egress_title": "আউটগোয়িং অনুমতি তালিকা",
|
||||
"settings.privacy.no_egress": "কোনো হোস্ট অনুমতি তালিকায় নেই — WA কোনো কনটেন্ট আউটগোয়িং করে না।",
|
||||
"settings.privacy.sync_targets_title": "সিঙ্ক লক্ষ্য",
|
||||
"settings.privacy.third_party": "তৃতীয়-পক্ষ",
|
||||
"settings.privacy.your_server": "আপনার সার্ভার",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "কোনো TLS নেই",
|
||||
"settings.privacy.refresh": "রিফ্রেশ করুন",
|
||||
"settings.privacy.unavailable": "গোপনীয়তা স্ব-পরীক্ষা উপলব্ধ নয়।",
|
||||
"settings.privacy.vault_title": "এনক্রিপশন ভল্ট",
|
||||
"settings.privacy.vault_intro": "একটি পাসওয়ার্ড দিয়ে নোট, ট্রান্সক্রিপ্ট এবং সারাংশ বিশ্রামাবস্থায় এনক্রিপ্ট করুন। (অডিও ফাইল এখনও এনক্রিপ্ট করা হয় না।)",
|
||||
"settings.privacy.vault_password": "ভল্ট পাসওয়ার্ড",
|
||||
"settings.privacy.enable_vault": "ভল্ট সক্ষম করুন",
|
||||
"settings.privacy.vault_pw_hint": "কমপক্ষে ৮টি অক্ষর ব্যবহার করুন। ভুলে গেলে, এনক্রিপ্ট করা কনটেন্ট পুনরুদ্ধার করা যায় না।",
|
||||
"settings.privacy.vault_locked_1": "ভল্ট ",
|
||||
"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": "আনলক করা",
|
||||
"settings.privacy.vault_unlocked_2": "। নতুন নোট, ট্রান্সক্রিপ্ট এবং সারাংশ বিশ্রামাবস্থায় এনক্রিপ্ট করা হয়।",
|
||||
"settings.privacy.lock_now": "এখনই লক করুন",
|
||||
"settings.privacy.change_password": "পাসওয়ার্ড পরিবর্তন করুন",
|
||||
"settings.privacy.current_password": "বর্তমান পাসওয়ার্ড",
|
||||
"settings.privacy.new_password": "নতুন পাসওয়ার্ড",
|
||||
|
||||
"settings.about.title": "সম্পর্কে",
|
||||
"settings.about.tagline": "একটি সম্পূর্ণ স্থানীয়, ওপেন-সোর্স, Windows-নেটিভ মিটিং সহকারী।",
|
||||
"settings.about.build_commit": "বিল্ড কমিট",
|
||||
|
||||
"consent.heading": "রেকর্ড করার আগে",
|
||||
"consent.body": "অংশগ্রহণকারীদের সম্মতি ছাড়া কথোপকথন রেকর্ড করা আপনার অঞ্চলে বেআইনি হতে পারে। আপনার স্থানীয় রেকর্ডিং আইন পরীক্ষা করুন। এটি একটি সতর্কতা, আইনি পরামর্শ নয়।",
|
||||
"consent.accept": "আমি বুঝেছি — রেকর্ডিং সক্ষম করুন",
|
||||
"consent.cancel": "বাতিল করুন",
|
||||
|
||||
"hosted.heading": "{provider} ব্যবহারের আগে",
|
||||
"hosted.body_1": "দিয়ে তৈরি করা",
|
||||
"hosted.body_2": "এই মিটিংয়ের ট্রান্সক্রিপ্ট তাদের সার্ভারে পাঠায় — এটি এই ডিভাইস ছেড়ে যায় এবং তাদের গোপনীয়তা নীতির অধীন। ডেটা আপনার ডিভাইস ছাড়ার পর WhispAssist-এর ডেটা হ্যান্ডলিংয়ের উপর কোনো নিয়ন্ত্রণ নেই। এটি ডিফল্টভাবে বন্ধ; আপনি এখন এটি বেছে নিচ্ছেন।",
|
||||
"hosted.accept": "আমি বুঝেছি — চালিয়ে যান",
|
||||
"hosted.cancel": "বাতিল করুন",
|
||||
|
||||
"theme.group": "থিম",
|
||||
"theme.system": "সিস্টেমের সাথে মেলান",
|
||||
"theme.light": "হালকা",
|
||||
"theme.dark": "গাঢ়",
|
||||
|
||||
"import.dialog_label": "একটি ফাইল বা URL থেকে মিটিং যোগ করুন",
|
||||
"import.title": "মিটিং যোগ করুন",
|
||||
"import.close": "বন্ধ করুন",
|
||||
"import.close_title": "বন্ধ করুন (Esc)",
|
||||
"import.body": "একটি বিদ্যমান রেকর্ডিং আমদানি করুন — একটি স্থানীয় অডিও/ভিডিও ফাইল, বা একটি লিঙ্ক (YouTube, একটি স্ট্রিমিং পৃষ্ঠা, বা একটি সরাসরি মিডিয়া URL)। এটি লাইভ রেকর্ডিংয়ের মতোই ট্রান্সক্রাইব ও ডায়ারাইজ করা হয়।",
|
||||
"import.file_or_url": "ফাইল বা URL",
|
||||
"import.source_placeholder": "একটি URL পেস্ট করুন, বা একটি ফাইলের জন্য ব্রাউজ করুন…",
|
||||
"import.choose_file": "একটি স্থানীয় ফাইল বেছে নিন",
|
||||
"import.browse": "ব্রাউজ করুন…",
|
||||
"import.title_label": "শিরোনাম",
|
||||
"import.optional": "ঐচ্ছিক",
|
||||
"import.title_placeholder": "ডিফল্টভাবে ফাইলের নাম",
|
||||
"import.filter_av": "অডিও / ভিডিও",
|
||||
"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} সরান",
|
||||
|
||||
"levelmeter.system_mic": "সিস্টেম ও মাইক্রোফোন ইনপুট স্তর",
|
||||
"levelmeter.system": "সিস্টেম ইনপুট স্তর",
|
||||
|
||||
"app.tagline": "স্থানীয় · ব্যক্তিগত",
|
||||
"app.note_template": "নোট টেমপ্লেট",
|
||||
"app.no_template": "কোনো টেমপ্লেট নেই",
|
||||
"app.add_meeting": "মিটিং যোগ করুন",
|
||||
"app.add_meeting_title": "একটি ফাইল বা URL থেকে মিটিং যোগ করুন",
|
||||
"app.record": "রেকর্ড করুন",
|
||||
"app.record_title": "রেকর্ডিং শুরু করুন (Ctrl+Shift+R)",
|
||||
"app.stop": "থামান",
|
||||
"app.stop_title": "রেকর্ডিং থামান (Ctrl+Shift+R)",
|
||||
"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": "সংরক্ষণ হচ্ছে",
|
||||
"app.not_saved": "সংরক্ষিত নয়",
|
||||
"app.reconnecting": "অডিও ডিভাইস পুনরায় সংযুক্ত হচ্ছে…",
|
||||
"app.settings": "সেটিংস",
|
||||
"app.settings_title": "সেটিংস (Ctrl+,)",
|
||||
"app.discard_confirm": "এই রেকর্ডিং বাতিল করবেন? এর অডিও ও ট্রান্সক্রিপ্ট মুছে ফেলা হবে।",
|
||||
"app.announce_started": "রেকর্ডিং শুরু হয়েছে",
|
||||
"app.announce_paused": "রেকর্ডিং বিরতি দেওয়া হয়েছে",
|
||||
"app.announce_stopped": "রেকর্ডিং থামানো হয়েছে",
|
||||
"app.consent_dialog": "রেকর্ডিং সম্মতি",
|
||||
"app.vault_dialog": "এনক্রিপশন ভল্ট আনলক করুন",
|
||||
"app.vault_title": "এনক্রিপশন ভল্ট আনলক করুন",
|
||||
"app.vault_desc": "আপনার মিটিং বিশ্রামাবস্থায় এনক্রিপ্ট করা। সেগুলি পড়তে আপনার পাসওয়ার্ড দিন।",
|
||||
"app.vault_password": "ভল্ট পাসওয়ার্ড",
|
||||
"app.vault_incorrect": "ভুল পাসওয়ার্ড",
|
||||
"app.unlock": "আনলক করুন",
|
||||
"app.continue_locked": "লক অবস্থায় চালিয়ে যান",
|
||||
"app.show_meetings": "মিটিং তালিকা দেখান",
|
||||
"app.hide_meetings": "মিটিং তালিকা লুকান",
|
||||
"app.show_summary": "সারাংশ প্যানেল দেখান",
|
||||
"app.hide_summary": "সারাংশ প্যানেল লুকান",
|
||||
"app.resize_meetings": "মিটিং তালিকার আকার পরিবর্তন করুন",
|
||||
"app.resize_summary": "সারাংশ প্যানেলের আকার পরিবর্তন করুন",
|
||||
|
||||
"meetings.search_placeholder": "মিটিং অনুসন্ধান করুন…",
|
||||
"meetings.search_aria": "মিটিং অনুসন্ধান করুন",
|
||||
"meetings.filter_tag_aria": "ট্যাগ অনুসারে ফিল্টার করুন",
|
||||
"meetings.all_tags": "সব ট্যাগ",
|
||||
"meetings.from_date_aria": "তারিখ থেকে",
|
||||
"meetings.to_date_aria": "তারিখ পর্যন্ত",
|
||||
"meetings.bulk_format_aria": "বাল্ক রপ্তানি বিন্যাস",
|
||||
"meetings.bulk_export": "বাল্ক রপ্তানি",
|
||||
"meetings.bulk_export_title": "উপরের ট্যাগ/তারিখ ফিল্টারের সাথে মিলে যাওয়া প্রতিটি মিটিং রপ্তানি করুন",
|
||||
"meetings.exporting": "রপ্তানি হচ্ছে…",
|
||||
"meetings.exported_one": "{n}টি মিটিং রপ্তানি করা হয়েছে।",
|
||||
"meetings.exported_many": "{n}টি মিটিং রপ্তানি করা হয়েছে।",
|
||||
"meetings.searching": "অনুসন্ধান হচ্ছে…",
|
||||
"meetings.loading": "মিটিং লোড হচ্ছে…",
|
||||
"meetings.no_matches": "কোনো মিল নেই।",
|
||||
"meetings.empty": "এখনও কোনো মিটিং নেই — শুরু করতে উপরে রেকর্ড ক্লিক করুন।",
|
||||
"meetings.status.recording": "রেকর্ড হচ্ছে",
|
||||
"meetings.status.transcribing": "ট্রান্সক্রাইব হচ্ছে",
|
||||
"meetings.status.recovering": "পুনরুদ্ধার হচ্ছে",
|
||||
"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": "ট্রান্সক্রিপ্ট দেখান",
|
||||
"transcript.hide": "ট্রান্সক্রিপ্ট লুকান",
|
||||
"transcript.empty": "এই মিটিংয়ের জন্য কোনো ট্রান্সক্রিপ্ট নেই।",
|
||||
"transcript.will_appear": "আপনি রেকর্ড করার সাথে সাথে ট্রান্সক্রিপ্ট এখানে দেখা যাবে।",
|
||||
"transcript.play_from_here": "এখান থেকে চালান",
|
||||
"transcript.resize": "ট্রান্সক্রিপ্ট ও নোটের আকার পরিবর্তন করুন",
|
||||
"transcript.reprocess_placeholder": "এটি দিয়ে পুনরায় ট্রান্সক্রাইব করুন…",
|
||||
"transcript.reprocess_lang_aria": "পুনঃপ্রক্রিয়াকরণ ভাষা",
|
||||
"transcript.reprocess_keep_lang": "বর্তমান ভাষা রাখুন",
|
||||
"transcript.reprocess_go": "চালান",
|
||||
"transcript.retranscribing": "পুনরায় ট্রান্সক্রাইব হচ্ছে…",
|
||||
"transcript.has_note": "একটি নোট আছে",
|
||||
"transcript.note_placeholder": "এই মুহূর্তের জন্য একটি নোট যোগ করুন…",
|
||||
"transcript.note_aria": "এই ট্রান্সক্রিপ্ট লাইনের জন্য নোট",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "Word নথি",
|
||||
"transcript.filter_obsidian": "Obsidian নোট",
|
||||
|
||||
"notes.heading": "নোট",
|
||||
"notes.show": "নোট দেখান",
|
||||
"notes.hide": "নোট লুকান",
|
||||
"notes.placeholder": "নোট…",
|
||||
"notes.live_placeholder": "কথা বলার সময় নোট টাইপ করুন…",
|
||||
"notes.toolbar_aria": "নোট বিন্যাস",
|
||||
"notes.bold": "গাঢ়",
|
||||
"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": "সম্পাদক",
|
||||
"notes.preview": "প্রিভিউ",
|
||||
"notes.export_md_title": "নোট .md হিসেবে রপ্তানি করুন",
|
||||
"notes.export_pdf_title": "নোট .pdf হিসেবে রপ্তানি করুন",
|
||||
"notes.export_docx_title": "নোট .docx হিসেবে রপ্তানি করুন",
|
||||
"notes.export_bundle_title": "অডিও + ট্রান্সক্রিপ্ট + নোট একটি ফোল্ডারে রপ্তানি করুন",
|
||||
"notes.export_obsidian_title": "একটি Obsidian নোট রপ্তানি করুন (নোট + সারাংশ + ট্রান্সক্রিপ্ট, কোনো অডিও নয়)",
|
||||
|
||||
"summary.recording_heading": "রেকর্ডিং",
|
||||
"summary.loading_recording": "রেকর্ডিং লোড হচ্ছে…",
|
||||
"summary.sync_heading": "সিঙ্ক",
|
||||
"summary.uploading": "আপলোড হচ্ছে…",
|
||||
"summary.upload_now": "এখন আপলোড করুন",
|
||||
"summary.no_uploads": "এই মিটিংয়ের জন্য এখনও কোনো আপলোড নেই।",
|
||||
"summary.retry": "আবার চেষ্টা করুন",
|
||||
"summary.tags_heading": "ট্যাগ",
|
||||
"summary.tags_select": "একটি মিটিং ট্যাগ করতে সেটি নির্বাচন করুন।",
|
||||
"summary.tag_add_placeholder": "ট্যাগ যোগ করুন…",
|
||||
"summary.tag_first_placeholder": "প্রকল্প, ক্লায়েন্ট, বিষয়…",
|
||||
"summary.generating": "তৈরি হচ্ছে…",
|
||||
"summary.generate_tags": "ট্যাগ তৈরি করুন",
|
||||
"summary.saving": "সংরক্ষণ হচ্ছে…",
|
||||
"summary.save_tags": "ট্যাগ সংরক্ষণ করুন",
|
||||
"summary.summary_heading": "সারাংশ",
|
||||
"summary.ai_provider": "এআই প্রদানকারী",
|
||||
"summary.provider_off": "বন্ধ",
|
||||
"summary.provider_custom": "কাস্টম",
|
||||
"summary.local": "স্থানীয়",
|
||||
"summary.leaves_device": "এই ডিভাইস ছেড়ে যায়",
|
||||
"summary.this_hosted_provider": "এই হোস্ট করা প্রদানকারী",
|
||||
"summary.select_generate": "একটি সারাংশ তৈরি করতে একটি মিটিং নির্বাচন করুন।",
|
||||
"summary.decisions_heading": "সিদ্ধান্ত",
|
||||
"summary.regenerate": "পুনরায় তৈরি করুন",
|
||||
"summary.generated_hint": "মিটিংয়ের পর স্থানীয়ভাবে তৈরি (একটি স্থানীয় LLM প্রদানকারী প্রয়োজন)।",
|
||||
"summary.no_provider": "কোনো এআই প্রদানকারী কনফিগার করা নেই — সেটিংসে একটি সক্ষম করুন।",
|
||||
"summary.generate_summary": "সারাংশ তৈরি করুন",
|
||||
"summary.briefs_heading": "ফিচার ব্রিফ",
|
||||
"summary.briefs_select": "ফিচার ব্রিফ তৈরি বা দেখতে একটি মিটিং নির্বাচন করুন।",
|
||||
"summary.briefs_desc": "এই মিটিংকে একটি এজেন্ট-প্রস্তুত স্পেসিফিকেশনে পরিশ্রুত করে (একটি স্থানীয় LLM প্রদানকারী প্রয়োজন) — এটি একটি কোডিং এজেন্টকে দিন, বা MCP সার্ভার চালু হলে এর মাধ্যমে পরিবেশন করুন।",
|
||||
"summary.brief_repo_placeholder": "লক্ষ্য রেপো (ঐচ্ছিক), যেমন acme/reporting-web",
|
||||
"summary.distilling": "পরিশ্রুত হচ্ছে…",
|
||||
"summary.create_brief": "ফিচার ব্রিফ তৈরি করুন",
|
||||
"summary.brief_mcp_title": "MCP সার্ভার চালু থাকলে উপলব্ধ",
|
||||
"summary.copied": "কপি করা হয়েছে",
|
||||
"summary.copy_md": "Markdown হিসেবে কপি করুন",
|
||||
"summary.copy_json": "JSON হিসেবে কপি করুন",
|
||||
"summary.brief_problem": "সমস্যা",
|
||||
"summary.brief_outcome": "কাঙ্ক্ষিত ফলাফল",
|
||||
"summary.brief_criteria": "গ্রহণযোগ্যতার মানদণ্ড",
|
||||
"summary.brief_none": "কিছু ক্যাপচার করা হয়নি।",
|
||||
"summary.brief_context": "প্রসঙ্গ",
|
||||
"summary.action_items_heading": "অ্যাকশন-আইটেম",
|
||||
"summary.ai_select": "এর অ্যাকশন-আইটেম দেখতে একটি মিটিং নির্বাচন করুন।",
|
||||
"summary.ai_empty": "এখনও কোনোটি নেই — নিচে একটি যোগ করুন, বা সেগুলি স্বয়ংক্রিয়ভাবে বের করতে একটি সারাংশ তৈরি করুন।",
|
||||
"summary.confirmed": "নিশ্চিত",
|
||||
"summary.ai_text_placeholder": "অ্যাকশন-আইটেম…",
|
||||
"summary.ai_text_aria": "অ্যাকশন-আইটেম টেক্সট",
|
||||
"summary.owner": "মালিক",
|
||||
"summary.due_date": "নির্ধারিত তারিখ",
|
||||
"summary.reminder_title": "এই নির্ধারিত তারিখের জন্য একটি স্থানীয় অনুস্মারক নির্ধারণ করুন",
|
||||
"summary.ai_delete_title": "এই অ্যাকশন-আইটেম মুছুন",
|
||||
"summary.ai_delete_aria": "অ্যাকশন-আইটেম মুছুন",
|
||||
"summary.add_action_item": "অ্যাকশন-আইটেম যোগ করুন",
|
||||
"summary.save_action_items": "অ্যাকশন-আইটেম সংরক্ষণ করুন",
|
||||
"summary.calendar_heading": "ক্যালেন্ডার ইভেন্ট",
|
||||
"summary.cal_select": "একটি মিটিংকে ক্যালেন্ডার ইভেন্টের সাথে লিঙ্ক করতে সেটি নির্বাচন করুন।",
|
||||
"summary.event_search_placeholder": "ইভেন্ট শিরোনাম অনুসন্ধান করুন…",
|
||||
"summary.linked_event": "লিঙ্ক করা ইভেন্ট",
|
||||
"summary.change_event": "ইভেন্ট পরিবর্তন করুন…",
|
||||
"summary.link_event": "একটি ইভেন্ট লিঙ্ক করুন…",
|
||||
"summary.untitled_event": "(শিরোনামহীন)",
|
||||
"summary.no_events_1": "এখনও কোনো ইভেন্ট আমদানি করা হয়নি — একটি",
|
||||
"summary.no_events_2": "সেটিংস → ক্যালেন্ডার থেকে আমদানি করুন।",
|
||||
"summary.no_events_match": "এই অনুসন্ধান/তারিখের সাথে কোনো ইভেন্ট মেলে না — একটি সাফ করার চেষ্টা করুন।",
|
||||
"summary.organizer_label": "আয়োজক: {name}",
|
||||
"summary.participants_heading": "অংশগ্রহণকারী",
|
||||
"summary.participants_hint": "লিঙ্ক করা ক্যালেন্ডার ইভেন্ট থেকে পূরণ করা হয়।",
|
||||
"summary.speakers_heading": "বক্তা",
|
||||
"summary.speakers_select": "এর বক্তাদের নাম দিতে একটি মিটিং নির্বাচন করুন।",
|
||||
"summary.no_speakers": "এখনও কোনো বক্তা শনাক্ত হয়নি।",
|
||||
"summary.speaker_name_placeholder": "বক্তার নাম",
|
||||
"summary.save": "সংরক্ষণ করুন",
|
||||
"summary.cancel": "বাতিল করুন",
|
||||
"summary.name_speaker": "এই বক্তার নাম দিন…",
|
||||
"summary.add_new_name": "+ নতুন নাম যোগ করুন…",
|
||||
|
||||
"settings.recording.title": "রেকর্ডিং",
|
||||
"settings.recording.record_default": "ডিফল্টভাবে মিটিং রেকর্ড করুন",
|
||||
"settings.recording.save_audio_as": "অডিও এই হিসেবে সংরক্ষণ করুন",
|
||||
"settings.recording.default_hint": "ডিফল্টভাবে বন্ধ। বন্ধ থাকলে, অডিও শুধু ট্রান্সক্রিপ্ট তৈরিতে ব্যবহৃত হয় এবং মিটিং চূড়ান্ত হলে মুছে ফেলা হয়। আপনি প্রতি মিটিংয়ে রেকর্ডিং টগলও করতে পারেন।",
|
||||
"settings.recording.consent_label": "সম্মতি: {status}।",
|
||||
"settings.recording.consent_ack": "স্বীকৃত",
|
||||
"settings.recording.consent_not": "এখনও স্বীকৃত নয়",
|
||||
"settings.recording.auto_label": "কোনো ক্যালেন্ডার ইভেন্ট শুরু হলে স্বয়ংক্রিয়ভাবে রেকর্ডিং শুরু করুন",
|
||||
"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,588 @@
|
||||
{
|
||||
"nav.recording": "Aufnahme",
|
||||
"nav.hardware": "Hardware",
|
||||
"nav.storage": "Speicher",
|
||||
"nav.calendar": "Kalender",
|
||||
"nav.sync": "Synchronisierung",
|
||||
"nav.ai": "KI",
|
||||
"nav.mcp": "MCP-Server",
|
||||
"nav.privacy": "Datenschutz",
|
||||
"nav.about": "Über",
|
||||
"nav.language": "Sprache",
|
||||
|
||||
"settings.language.title": "Sprache",
|
||||
"settings.language.display": "Anzeigesprache",
|
||||
"settings.language.display_hint": "Die Sprache der App-Oberfläche. Eine Sprache hinzuzufügen ist so einfach wie eine einzige Übersetzungsdatei abzulegen.",
|
||||
|
||||
"settings.transcription.title": "Transkriptionssprache",
|
||||
"settings.transcription.label": "Sprache",
|
||||
"settings.transcription.auto": "Automatisch erkennen",
|
||||
"settings.transcription.applies_hint": "Gilt für die nächste Aufnahme. Die tatsächlich verwendete Sprache wird danach bei jedem Meeting angezeigt.",
|
||||
"settings.transcription.english_only": "Nur Englisch",
|
||||
"settings.transcription.model_english_only": "„{model}“ ist nur auf Englisch verfügbar.",
|
||||
"settings.transcription.no_model": "Kein Modell ausgewählt.",
|
||||
"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",
|
||||
"settings.hardware.auto_backend": "Automatisch (bestes verfügbares)",
|
||||
"settings.hardware.not_available": "nicht verfügbar",
|
||||
"settings.hardware.fallback_hint": "Fällt automatisch zurück (NPU → NVIDIA → AMD → Intel → CPU), wenn das gewählte Backend nicht geladen werden kann.",
|
||||
"settings.hardware.audio_devices": "Audiogeräte",
|
||||
"settings.hardware.recording_device": "Aufnahmegerät",
|
||||
"settings.hardware.default_system_audio": "Standard-Systemaudio",
|
||||
"settings.hardware.recording_device_hint": "WhispAssist nimmt auf, was dieses Gerät wiedergibt (Loopback) — die Gegenseite des Gesprächs. Wählen Sie eine bestimmte Ausgabe, wenn sie nicht dem Windows-Systemstandard folgen soll.",
|
||||
"settings.hardware.microphone": "Mikrofon",
|
||||
"settings.hardware.mic_off": "Aus — mein Mikrofon nicht erfassen",
|
||||
"settings.hardware.default_mic": "Standardmikrofon",
|
||||
"settings.hardware.mic_hint": "Fügt Ihre eigene Stimme zum Live-Transkript hinzu, damit beide Seiten des Meetings erfasst werden. Bleibt auf Ihrem Gerät — nichts wird hochgeladen. Wählen Sie „Aus“, um nur das obige Systemaudio zu transkribieren.",
|
||||
"settings.hardware.npu_detected": "NPU-Beschleuniger erkannt",
|
||||
"settings.hardware.ready": "Bereit",
|
||||
"settings.hardware.downloading": "Wird heruntergeladen…",
|
||||
"settings.hardware.package_needed": "Paket erforderlich",
|
||||
"settings.hardware.npu_hint": "Ihre NPU benötigt ein einmaliges Beschleunigungspaket (Whisper-ONNX-Modell{extra}). Es wird im Hintergrund heruntergeladen; Sie können es auch jetzt starten.",
|
||||
"settings.hardware.plus_openvino": " + OpenVINO-Laufzeit",
|
||||
"settings.hardware.download_npu": "NPU-Paket herunterladen",
|
||||
"settings.hardware.dml_title": "GPU-Beschleunigung (DirectML)",
|
||||
"settings.hardware.dml_hint": "Ihre GPU kann ohne Vulkan über DirectML beschleunigt werden — ein einmaliges Paket (Whisper-ONNX-Modell{extra}).",
|
||||
"settings.hardware.plus_directml": " + DirectML-Laufzeit",
|
||||
"settings.hardware.download_dml": "DirectML-Paket herunterladen",
|
||||
"settings.hardware.detection_unavailable": "Hardware-Erkennung nicht verfügbar.",
|
||||
"settings.hardware.low_overhead": "Sparsam-Voreinstellung",
|
||||
"settings.hardware.low_overhead_note": "CPU + kleinstes Modell, für Akku-/Hintergrundnutzung",
|
||||
"settings.hardware.models": "Modelle",
|
||||
"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",
|
||||
"settings.hardware.download": "Herunterladen",
|
||||
"settings.hardware.diarization": "Sprechertrennung",
|
||||
"settings.hardware.diarization_hint": "Installieren Sie beide Modelle, damit fertige Aufnahmen nach Sprecher getrennt werden (Sprecher 1, Sprecher 2, …) statt eines durchgehenden Transkripts. Läuft vollständig offline, einmal pro Meeting nach dem Stopp. Ohne sie wird jede Zeile einem einzigen Sprecher zugeordnet.",
|
||||
|
||||
"settings.storage.title": "Speicher",
|
||||
"settings.storage.location_label": "Speicherort",
|
||||
"settings.storage.location_hint": "Das Ändern des Speicherorts wird noch nicht unterstützt — hier befinden sich derzeit Meetings, Modelle und die Datenbank.",
|
||||
"settings.storage.retention_title": "Aufbewahrung",
|
||||
"settings.storage.retention_hint": "Löscht automatisch die ältesten Meetings über einem Limit. Wird einmal beim Start geprüft; leer bedeutet kein Limit. Betrifft nie ein Meeting, das gerade aufgenommen wird.",
|
||||
"settings.storage.max_age": "Höchstalter (Tage)",
|
||||
"settings.storage.max_size": "Maximale Größe (GB)",
|
||||
"settings.storage.no_limit": "kein Limit",
|
||||
"settings.storage.export_import_title": "Export & Import",
|
||||
"settings.storage.export_hint_1": "Verschieben Sie Aufnahmen zwischen Computern als portable Bündelordner (Audio, Transkript, Notizen, Zusammenfassung und ein",
|
||||
"settings.storage.export_hint_2": "Manifest). Exportieren Sie in einen beliebigen Ordner — ein synchronisiertes Laufwerk, einen USB-Stick oder den lokalen Einhängepunkt eines Sync-Ziels — und importieren Sie es dann auf dem anderen Rechner. Importierte Meetings erhalten eine neue ID, sodass ein erneuter Import nie etwas überschreibt.",
|
||||
"settings.storage.export_dialog_title": "Alle Meetings exportieren nach…",
|
||||
"settings.storage.import_dialog_title": "Ein Bündel (oder einen Ordner mit Bündeln) importieren…",
|
||||
"settings.storage.exporting": "Wird exportiert…",
|
||||
"settings.storage.export_all": "Alle Meetings exportieren…",
|
||||
"settings.storage.importing": "Wird importiert…",
|
||||
"settings.storage.import_all": "Meetings importieren…",
|
||||
"settings.storage.exported_one": "{n} Meeting exportiert.",
|
||||
"settings.storage.exported_many": "{n} Meetings exportiert.",
|
||||
"settings.storage.imported_one": "{n} Meeting importiert.",
|
||||
"settings.storage.imported_many": "{n} Meetings importiert.",
|
||||
|
||||
"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…",
|
||||
"settings.calendar.password": "Passwort",
|
||||
"settings.calendar.password_note": "selten benötigt",
|
||||
"settings.calendar.optional": "optional",
|
||||
"settings.calendar.import_range": "Importzeitraum",
|
||||
"settings.calendar.range_30": "Letzte 30 Tage",
|
||||
"settings.calendar.range_90": "Letzte 90 Tage",
|
||||
"settings.calendar.range_180": "Letzte 6 Monate",
|
||||
"settings.calendar.range_365": "Letztes Jahr",
|
||||
"settings.calendar.range_all": "Gesamter Zeitraum",
|
||||
"settings.calendar.range_hint": "Ein langlebiges Postfach kann Jahre an wiederkehrenden/Feiertagseinträgen enthalten — ein engerer Zeitraum beschränkt den importierten Kalender auf das tatsächlich Relevante. Gilt sowohl für diese Import-Schaltfläche als auch für den automatischen erneuten Import beim Start.",
|
||||
"settings.calendar.importing": "Wird importiert…",
|
||||
"settings.calendar.import": "Importieren",
|
||||
"settings.calendar.import_progress": "{processed}/{total} Termine",
|
||||
"settings.calendar.auto_reimport": "Diese Datei beim Start automatisch erneut importieren",
|
||||
"settings.calendar.auto_reimport_hint": "Läuft einmal beim Start, nicht per Timer — der erneute Import kann gefahrlos wiederholt werden (bestehende Termine werden abgeglichen und aktualisiert, nicht dupliziert).",
|
||||
"settings.calendar.import_failed": "Import fehlgeschlagen: {error} — die Datei selbst bleibt unberührt; prüfen Sie den Pfad und versuchen Sie es erneut.",
|
||||
"settings.calendar.cleanup_title": "Aufräumen",
|
||||
"settings.calendar.cleanup_hint": "Termine, die bereits an ein aufgenommenes Meeting angehängt sind, werden immer behalten, unabhängig von der unten gewählten Option.",
|
||||
"settings.calendar.cleanup_30": "Älter als 30 Tage",
|
||||
"settings.calendar.cleanup_90": "Älter als 90 Tage",
|
||||
"settings.calendar.cleanup_180": "Älter als 6 Monate",
|
||||
"settings.calendar.cleanup_365": "Älter als 1 Jahr",
|
||||
"settings.calendar.cleanup_all": "Alle löschen",
|
||||
"settings.calendar.cleaning": "Wird aufgeräumt…",
|
||||
"settings.calendar.cleanup_btn": "Kalender aufräumen",
|
||||
"settings.calendar.cleanup_result": "{deleted}{extra} gelöscht.",
|
||||
"settings.calendar.cleanup_kept": ", {n} behalten (mit einem Meeting verknüpft)",
|
||||
"settings.calendar.cleanup_failed": "Aufräumen fehlgeschlagen: {error}",
|
||||
"settings.calendar.imported_events": "Importierte Termine",
|
||||
"settings.calendar.no_events": "Noch keine Termine importiert.",
|
||||
"settings.calendar.search_title": "Titel durchsuchen",
|
||||
"settings.calendar.search_placeholder": "Meetingname…",
|
||||
"settings.calendar.date_label": "Datum",
|
||||
"settings.calendar.events_count": "{shown} von {total} Terminen",
|
||||
"settings.calendar.untitled": "(ohne Titel)",
|
||||
|
||||
"settings.dialog_title": "Einstellungen",
|
||||
"settings.close": "Schließen",
|
||||
"settings.close_title": "Schließen (Esc)",
|
||||
|
||||
"settings.sync.stub": "Vorschaumodus — das Sync-Backend ist noch nicht implementiert (Phase 9). Änderungen werden nur in der Oberfläche gehalten.",
|
||||
"settings.sync.title": "Synchronisierung & Upload",
|
||||
"settings.sync.enable_label": "Hochladen von Meeting-Artefakten zu konfigurierten Zielen aktivieren",
|
||||
"settings.sync.enable_hint": "Standardmäßig aus. Es wird nichts hochgeladen, sofern dies nicht aktiviert und ein Ziel eingeschaltet ist. Selbst gehostete Ziele behalten Daten auf Ihrem eigenen Server; Drittanbieter-Clouds sind deutlich gekennzeichnet.",
|
||||
"settings.sync.targets_title": "Ziele",
|
||||
"settings.sync.no_targets": "Noch keine Ziele. Fügen Sie unten eines hinzu.",
|
||||
"settings.sync.third_party": "Drittanbieter",
|
||||
"settings.sync.your_server": "Ihr Server",
|
||||
"settings.sync.edit": "Bearbeiten",
|
||||
"settings.sync.remove": "Entfernen",
|
||||
"settings.sync.edit_target": "Ziel bearbeiten",
|
||||
"settings.sync.add_target": "Ein Ziel hinzufügen",
|
||||
"settings.sync.webdav_hint": "WebDAV deckt Nextcloud, ownCloud, Cloudreve, Seafile und Synology ab. (Seafile: SeafDAV serverseitig aktivieren.)",
|
||||
"settings.sync.name": "Name",
|
||||
"settings.sync.name_placeholder": "Heim-Nextcloud",
|
||||
"settings.sync.provider": "Anbieter",
|
||||
"settings.sync.server_url": "Server-URL",
|
||||
"settings.sync.server_url_note_1": "Nur Ihre Server-URL — WhispAssist ergänzt",
|
||||
"settings.sync.server_url_note_2": "automatisch.",
|
||||
"settings.sync.remote_folder": "Remote-Ordner",
|
||||
"settings.sync.username": "Benutzername",
|
||||
"settings.sync.app_password": "App-Passwort",
|
||||
"settings.sync.pw_keep": "leer lassen, um das aktuelle Passwort zu behalten",
|
||||
"settings.sync.pw_store": "im Anmeldedatenspeicher des Betriebssystems gespeichert",
|
||||
"settings.sync.upload_legend": "Upload",
|
||||
"settings.sync.artifact_transcript": "Transkript",
|
||||
"settings.sync.artifact_notes": "Notizen",
|
||||
"settings.sync.artifact_summary": "Zusammenfassung",
|
||||
"settings.sync.artifact_recording": "Aufnahme (.wav, falls beibehalten)",
|
||||
"settings.sync.allow_plaintext": "Klartext-http für eine LAN-Adresse zulassen (nicht empfohlen)",
|
||||
"settings.sync.encrypt_before": "Vor dem Upload verschlüsseln (Ziel speichert nur Chiffretext)",
|
||||
"settings.sync.requires_vault": "— erfordert einen entsperrten Tresor",
|
||||
"settings.sync.test_connection": "Verbindung testen",
|
||||
"settings.sync.save_changes": "Änderungen speichern",
|
||||
"settings.sync.add_target_btn": "Ziel hinzufügen",
|
||||
"settings.sync.cancel": "Abbrechen",
|
||||
"settings.sync.third_party_banner": "{kind} ist eine Drittanbieter-Cloud — durch das Hochladen werden Ihre Daten von Ihrem Gerät an {kind} gesendet.",
|
||||
"settings.sync.link_account": "{kind}-Konto verknüpfen…",
|
||||
"settings.sync.oauth_hint": "Öffnet eine OAuth-Anmeldung (Loopback-Weiterleitung); das Token wird im Anmeldedatenspeicher des Betriebssystems gespeichert.",
|
||||
"settings.sync.footnote": "Anmeldedaten werden nie in die Einstellungen oder die Datenbank geschrieben — nur in den Anmeldedatenspeicher des Betriebssystems. Für Nicht-LAN-Ziele ist TLS erforderlich.",
|
||||
|
||||
"settings.ai.title": "KI-Zusammenfassungsanbieter",
|
||||
"settings.ai.intro_1": "Zusammenfassungen laufen standardmäßig auf einem lokalen LLM. Richten Sie dies auf Ollama auf diesem PC oder einem anderen Rechner in Ihrem LAN (z. B.",
|
||||
"settings.ai.intro_2": "— beide gelten als lokal, sodass nichts Ihr Netzwerk verlässt. Gehostete Anbieter (Anthropic) sind optional, standardmäßig aus und senden das Transkript an einen Dritten, sobald Sie einen aktivieren.",
|
||||
"settings.ai.provider": "Anbieter",
|
||||
"settings.ai.off": "Aus",
|
||||
"settings.ai.provider_ollama": "Ollama (lokal / LAN)",
|
||||
"settings.ai.provider_custom": "Benutzerdefiniert (OpenAI-kompatibel)",
|
||||
"settings.ai.provider_anthropic": "Anthropic (Claude) — gehostet, verlässt dieses Gerät",
|
||||
"settings.ai.endpoint": "Endpunkt",
|
||||
"settings.ai.model": "Modell",
|
||||
"settings.ai.api_key": "API-Schlüssel",
|
||||
"settings.ai.api_key_set_placeholder": "•••••••••••••••• (bereits gesetzt — leer lassen, um ihn zu behalten)",
|
||||
"settings.ai.show_api_key": "API-Schlüssel anzeigen",
|
||||
"settings.ai.hide_api_key": "API-Schlüssel ausblenden",
|
||||
"settings.ai.anthropic_banner": "Anthropic ist ein gehosteter Drittanbieterdienst — das Transkript dieses Meetings verlässt Ihr Gerät, wenn Sie eine Zusammenfassung erstellen.",
|
||||
"settings.ai.endpoint_banner": "Dieser Endpunkt befindet sich nicht auf Ihrem Rechner oder LAN — Ihr Transkript würde Ihr Netzwerk verlassen.",
|
||||
"settings.ai.saving": "Wird gespeichert…",
|
||||
"settings.ai.save": "Speichern",
|
||||
"settings.ai.test_connection": "Verbindung testen",
|
||||
"settings.ai.this_hosted_provider": "dieser gehostete Anbieter",
|
||||
"settings.ai.reachable": "Erreichbar",
|
||||
"settings.ai.unreachable": "Nicht erreichbar",
|
||||
"settings.ai.model_count_one": "· {n} Modell",
|
||||
"settings.ai.model_count_many": "· {n} Modelle",
|
||||
"settings.ai.on_lan": "· auf Ihrem Rechner / LAN",
|
||||
"settings.ai.leaves_network": "· verlässt Ihr Netzwerk",
|
||||
"settings.ai.advanced_title": "Erweiterte Ollama-Konfiguration",
|
||||
"settings.ai.changed": "{n} geändert",
|
||||
"settings.ai.reset_all": "Alle auf Standard zurücksetzen",
|
||||
"settings.ai.system_prompt": "System-Prompt",
|
||||
"settings.ai.system_prompt_placeholder": "z. B. Du bist ein prägnanter Meeting-Zusammenfasser.",
|
||||
"settings.ai.system_prompt_hint": "Wird vor WhispAssists erforderlichem Ausgabeformat hinzugefügt, sodass Ihre Anweisungen das Parsen von Zusammenfassung/Aktionspunkten nicht stören können.",
|
||||
"settings.ai.think": "Nachdenken",
|
||||
"settings.ai.think_help": "Denkaufwand (nur bei Reasoning-Modellen).",
|
||||
"settings.ai.keep_alive": "Aktiv halten",
|
||||
"settings.ai.keep_alive_help": "Wie lange das Modell im RAM bleibt. z. B. 5m, 1h, 0 (entladen), -1 (dauerhaft).",
|
||||
"settings.ai.runtime_hardware": "Laufzeit & Hardware",
|
||||
"settings.ai.rarely_needed": "— selten benötigt",
|
||||
"settings.ai.save_advanced": "Erweitert speichern",
|
||||
"settings.ai.saved": "Gespeichert",
|
||||
"settings.ai.turn_off": "Ausschalten",
|
||||
"settings.ai.default_value": "Standard {value}.",
|
||||
"settings.ai.reset_default": "Auf Standard zurücksetzen",
|
||||
"settings.ai.reset_field": "{name} zurücksetzen",
|
||||
|
||||
"settings.mcp.title": "MCP-Server",
|
||||
"settings.mcp.banner_1": "Dies lässt Ihren eigenen Coding-Agenten (Claude Code, Codex, Copilot, OpenCode, …) Meeting-Kontext auf Ihrem lokalen Rechner abrufen. Nach der Verbindung",
|
||||
"settings.mcp.banner_agent": "kann dieser Agent",
|
||||
"settings.mcp.banner_2": "das Gelesene an die Cloud seines eigenen Modellanbieters weiterleiten — außerhalb der Kontrolle von WhispAssist. WhispAssist selbst sendet diese Daten nirgendwohin; der Server lauscht nur auf diesem Gerät (",
|
||||
"settings.mcp.banner_3": ") und jeder Lesevorgang wird unten protokolliert.",
|
||||
"settings.mcp.enable": "Den MCP-Server aktivieren",
|
||||
"settings.mcp.enable_hint": "Standardmäßig aus. Nur Loopback, token-gesichert — vom Netzwerk aus ist nichts erreichbar.",
|
||||
"settings.mcp.transport": "Transport",
|
||||
"settings.mcp.transport_http": "Streamable HTTP",
|
||||
"settings.mcp.transport_stdio": "stdio (Agent startet einen Prozess)",
|
||||
"settings.mcp.port": "Port",
|
||||
"settings.mcp.transport_change_hint": "Um Transport/Port zu ändern, schalten Sie den Server zuerst aus und dann wieder ein.",
|
||||
"settings.mcp.scope_title": "Umfang",
|
||||
"settings.mcp.expose": "Freigeben",
|
||||
"settings.mcp.scope_none": "Keiner — nichts wird geteilt",
|
||||
"settings.mcp.scope_selected": "Ausgewählt — nur Feature-Briefs, die Sie als geteilt markiert haben",
|
||||
"settings.mcp.scope_all": "Alle — Meetings, Transkripte, Aktionspunkte und geteilte Briefs",
|
||||
"settings.mcp.expose_recordings": "Auch Meetings mit gespeicherter Aufnahme zulassen (standardmäßig aus — das Transkript eines aufgenommenen Meetings wird selbst im „Alle“-Umfang zurückgehalten, bis dies aktiviert ist)",
|
||||
"settings.mcp.token_title": "Neues Auth-Token — einmalig angezeigt, kopieren Sie es jetzt",
|
||||
"settings.mcp.token_hint": "Dies wird nicht erneut angezeigt. Es wird im Anmeldedatenspeicher des Betriebssystems gespeichert; falls Sie es verlieren, schalten Sie den Server aus und wieder ein, um ein neues zu erzeugen.",
|
||||
"settings.mcp.copied": "Kopiert",
|
||||
"settings.mcp.copy": "Kopieren",
|
||||
"settings.mcp.endpoint": "Endpunkt",
|
||||
"settings.mcp.endpoint_hint": "Richten Sie die MCP-Client-Konfiguration Ihres Agenten auf {kind} aus, mit dem obigen Token als Bearer-Anmeldedatum.",
|
||||
"settings.mcp.kind_command": "Befehl",
|
||||
"settings.mcp.kind_url": "URL",
|
||||
"settings.mcp.access_log": "Zugriffsprotokoll",
|
||||
"settings.mcp.access_log_hint": "Jeder Tool-Lesevorgang eines Agenten, erlaubt oder verweigert (FR-MCP-5).",
|
||||
"settings.mcp.no_reads": "Noch hat kein Agent etwas gelesen.",
|
||||
"settings.mcp.log_meeting": "Meeting {id}",
|
||||
"settings.mcp.refresh": "Aktualisieren",
|
||||
|
||||
"settings.privacy.title": "Datenschutz",
|
||||
"settings.privacy.intro": "Was WhispAssist derzeit tatsächlich von diesem Gerät senden darf. Wenn alles aus ist (Standard), verlässt nichts das Gerät.",
|
||||
"settings.privacy.llm_endpoint": "LLM-Endpunkt",
|
||||
"settings.privacy.off": "aus",
|
||||
"settings.privacy.local_only": "nur lokal",
|
||||
"settings.privacy.leaves_device": "verlässt dieses Gerät",
|
||||
"settings.privacy.sync_label": "Synchronisierung",
|
||||
"settings.privacy.enabled": "aktiviert",
|
||||
"settings.privacy.mcp_label": "MCP-Server",
|
||||
"settings.privacy.mcp_on": "an · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "Eingehend nur auf Loopback — es fügt der obigen Egress-Liste nichts hinzu. Ein verbundener Agent kann das Gelesene dennoch an seinen eigenen Modellanbieter weiterleiten; siehe den MCP-Server-Tab.",
|
||||
"settings.privacy.egress_title": "Egress-Zulassungsliste",
|
||||
"settings.privacy.no_egress": "Keine Hosts auf der Zulassungsliste — WA macht keinen Inhalts-Egress.",
|
||||
"settings.privacy.sync_targets_title": "Sync-Ziele",
|
||||
"settings.privacy.third_party": "Drittanbieter",
|
||||
"settings.privacy.your_server": "Ihr Server",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "kein TLS",
|
||||
"settings.privacy.refresh": "Aktualisieren",
|
||||
"settings.privacy.unavailable": "Datenschutz-Selbstprüfung nicht verfügbar.",
|
||||
"settings.privacy.vault_title": "Verschlüsselungstresor",
|
||||
"settings.privacy.vault_intro": "Verschlüsseln Sie Notizen, Transkripte und Zusammenfassungen im Ruhezustand mit einem Passwort. (Audiodateien sind noch nicht verschlüsselt.)",
|
||||
"settings.privacy.vault_password": "Tresor-Passwort",
|
||||
"settings.privacy.enable_vault": "Tresor aktivieren",
|
||||
"settings.privacy.vault_pw_hint": "Verwenden Sie mindestens 8 Zeichen. Wenn Sie es vergessen, können verschlüsselte Inhalte nicht wiederhergestellt werden.",
|
||||
"settings.privacy.vault_locked_1": "Der Tresor ist ",
|
||||
"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",
|
||||
"settings.privacy.vault_unlocked_2": ". Neue Notizen, Transkripte und Zusammenfassungen werden im Ruhezustand verschlüsselt.",
|
||||
"settings.privacy.lock_now": "Jetzt sperren",
|
||||
"settings.privacy.change_password": "Passwort ändern",
|
||||
"settings.privacy.current_password": "Aktuelles Passwort",
|
||||
"settings.privacy.new_password": "Neues Passwort",
|
||||
|
||||
"settings.about.title": "Über",
|
||||
"settings.about.tagline": "Ein vollständig lokaler, quelloffener, Windows-nativer Meeting-Assistent.",
|
||||
"settings.about.build_commit": "Build-Commit",
|
||||
|
||||
"consent.heading": "Bevor Sie aufnehmen",
|
||||
"consent.body": "Das Aufzeichnen von Gesprächen ohne Einwilligung der Teilnehmer kann in Ihrer Region illegal sein. Prüfen Sie Ihre lokalen Aufnahmegesetze. Dies ist ein Hinweis, keine Rechtsberatung.",
|
||||
"consent.accept": "Ich verstehe — Aufnahme aktivieren",
|
||||
"consent.cancel": "Abbrechen",
|
||||
|
||||
"hosted.heading": "Bevor Sie {provider} verwenden",
|
||||
"hosted.body_1": "Das Generieren mit",
|
||||
"hosted.body_2": "sendet das Transkript dieses Meetings an deren Server — es verlässt dieses Gerät und unterliegt deren Datenschutzrichtlinie. WhispAssist hat keine Kontrolle über die Datenverarbeitung, sobald sie Ihr Gerät verlassen. Dies ist standardmäßig aus; Sie wählen es jetzt.",
|
||||
"hosted.accept": "Ich verstehe — fortfahren",
|
||||
"hosted.cancel": "Abbrechen",
|
||||
|
||||
"theme.group": "Design",
|
||||
"theme.system": "System anpassen",
|
||||
"theme.light": "Hell",
|
||||
"theme.dark": "Dunkel",
|
||||
|
||||
"import.dialog_label": "Ein Meeting aus einer Datei oder URL hinzufügen",
|
||||
"import.title": "Ein Meeting hinzufügen",
|
||||
"import.close": "Schließen",
|
||||
"import.close_title": "Schließen (Esc)",
|
||||
"import.body": "Importieren Sie eine vorhandene Aufnahme — eine lokale Audio-/Videodatei oder einen Link (YouTube, eine Streaming-Seite oder eine direkte Medien-URL). Sie wird wie eine Live-Aufnahme transkribiert und nach Sprechern getrennt.",
|
||||
"import.file_or_url": "Datei oder URL",
|
||||
"import.source_placeholder": "Fügen Sie eine URL ein oder suchen Sie nach einer Datei…",
|
||||
"import.choose_file": "Eine lokale Datei wählen",
|
||||
"import.browse": "Durchsuchen…",
|
||||
"import.title_label": "Titel",
|
||||
"import.optional": "optional",
|
||||
"import.title_placeholder": "Standardmäßig der Dateiname",
|
||||
"import.filter_av": "Audio / Video",
|
||||
"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",
|
||||
|
||||
"levelmeter.system_mic": "System- und Mikrofoneingangspegel",
|
||||
"levelmeter.system": "Systemeingangspegel",
|
||||
|
||||
"app.tagline": "lokal · privat",
|
||||
"app.note_template": "Notizvorlage",
|
||||
"app.no_template": "Keine Vorlage",
|
||||
"app.add_meeting": "Meeting hinzufügen",
|
||||
"app.add_meeting_title": "Ein Meeting aus einer Datei oder URL hinzufügen",
|
||||
"app.record": "Aufnehmen",
|
||||
"app.record_title": "Aufnahme starten (Strg+Umschalt+R)",
|
||||
"app.stop": "Stopp",
|
||||
"app.stop_title": "Aufnahme stoppen (Strg+Umschalt+R)",
|
||||
"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",
|
||||
"app.not_saved": "nicht gespeichert",
|
||||
"app.reconnecting": "Audiogerät wird neu verbunden…",
|
||||
"app.settings": "Einstellungen",
|
||||
"app.settings_title": "Einstellungen (Strg+,)",
|
||||
"app.discard_confirm": "Diese Aufnahme verwerfen? Ihr Audio und Transkript werden gelöscht.",
|
||||
"app.announce_started": "Aufnahme gestartet",
|
||||
"app.announce_paused": "Aufnahme pausiert",
|
||||
"app.announce_stopped": "Aufnahme gestoppt",
|
||||
"app.consent_dialog": "Aufnahme-Einwilligung",
|
||||
"app.vault_dialog": "Verschlüsselungstresor entsperren",
|
||||
"app.vault_title": "Verschlüsselungstresor entsperren",
|
||||
"app.vault_desc": "Ihre Meetings sind im Ruhezustand verschlüsselt. Geben Sie Ihr Passwort ein, um sie zu lesen.",
|
||||
"app.vault_password": "Tresor-Passwort",
|
||||
"app.vault_incorrect": "Falsches Passwort",
|
||||
"app.unlock": "Entsperren",
|
||||
"app.continue_locked": "Gesperrt fortfahren",
|
||||
"app.show_meetings": "Meetingliste anzeigen",
|
||||
"app.hide_meetings": "Meetingliste ausblenden",
|
||||
"app.show_summary": "Zusammenfassungsbereich anzeigen",
|
||||
"app.hide_summary": "Zusammenfassungsbereich ausblenden",
|
||||
"app.resize_meetings": "Meetingliste anpassen",
|
||||
"app.resize_summary": "Zusammenfassungsbereich anpassen",
|
||||
|
||||
"meetings.search_placeholder": "Meetings durchsuchen…",
|
||||
"meetings.search_aria": "Meetings durchsuchen",
|
||||
"meetings.filter_tag_aria": "Nach Tag filtern",
|
||||
"meetings.all_tags": "Alle Tags",
|
||||
"meetings.from_date_aria": "Von Datum",
|
||||
"meetings.to_date_aria": "Bis Datum",
|
||||
"meetings.bulk_format_aria": "Sammelexportformat",
|
||||
"meetings.bulk_export": "Sammelexport",
|
||||
"meetings.bulk_export_title": "Jedes Meeting exportieren, das den obigen Tag-/Datumsfiltern entspricht",
|
||||
"meetings.exporting": "Wird exportiert…",
|
||||
"meetings.exported_one": "{n} Meeting exportiert.",
|
||||
"meetings.exported_many": "{n} Meetings exportiert.",
|
||||
"meetings.searching": "Wird gesucht…",
|
||||
"meetings.loading": "Meetings werden geladen…",
|
||||
"meetings.no_matches": "Keine Treffer.",
|
||||
"meetings.empty": "Noch keine Meetings — klicken Sie oben auf Aufnehmen, um zu beginnen.",
|
||||
"meetings.status.recording": "Aufnahme",
|
||||
"meetings.status.transcribing": "wird transkribiert",
|
||||
"meetings.status.recovering": "Wiederherstellung",
|
||||
"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",
|
||||
"transcript.hide": "Transkript ausblenden",
|
||||
"transcript.empty": "Kein Transkript für dieses Meeting.",
|
||||
"transcript.will_appear": "Das Transkript erscheint hier, während Sie aufnehmen.",
|
||||
"transcript.play_from_here": "Ab hier abspielen",
|
||||
"transcript.resize": "Transkript und Notizen anpassen",
|
||||
"transcript.reprocess_placeholder": "Neu transkribieren mit…",
|
||||
"transcript.reprocess_lang_aria": "Sprache für Neuverarbeitung",
|
||||
"transcript.reprocess_keep_lang": "Aktuelle Sprache beibehalten",
|
||||
"transcript.reprocess_go": "Los",
|
||||
"transcript.retranscribing": "Wird neu transkribiert…",
|
||||
"transcript.has_note": "Hat eine Notiz",
|
||||
"transcript.note_placeholder": "Eine Notiz für diesen Moment hinzufügen…",
|
||||
"transcript.note_aria": "Notiz für diese Transkriptzeile",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "Word-Dokument",
|
||||
"transcript.filter_obsidian": "Obsidian-Notiz",
|
||||
|
||||
"notes.heading": "Notizen",
|
||||
"notes.show": "Notizen anzeigen",
|
||||
"notes.hide": "Notizen ausblenden",
|
||||
"notes.placeholder": "Notizen…",
|
||||
"notes.live_placeholder": "Tippen Sie Notizen, während Sie sprechen…",
|
||||
"notes.toolbar_aria": "Notizformatierung",
|
||||
"notes.bold": "Fett",
|
||||
"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",
|
||||
"notes.preview": "Vorschau",
|
||||
"notes.export_md_title": "Notizen als .md exportieren",
|
||||
"notes.export_pdf_title": "Notizen als .pdf exportieren",
|
||||
"notes.export_docx_title": "Notizen als .docx exportieren",
|
||||
"notes.export_bundle_title": "Audio + Transkript + Notizen in einen Ordner exportieren",
|
||||
"notes.export_obsidian_title": "Eine Obsidian-Notiz exportieren (Notizen + Zusammenfassung + Transkript, kein Audio)",
|
||||
|
||||
"summary.recording_heading": "Aufnahme",
|
||||
"summary.loading_recording": "Aufnahme wird geladen…",
|
||||
"summary.sync_heading": "Synchronisierung",
|
||||
"summary.uploading": "Wird hochgeladen…",
|
||||
"summary.upload_now": "Jetzt hochladen",
|
||||
"summary.no_uploads": "Noch keine Uploads für dieses Meeting.",
|
||||
"summary.retry": "erneut versuchen",
|
||||
"summary.tags_heading": "Tags",
|
||||
"summary.tags_select": "Wählen Sie ein Meeting, um es zu taggen.",
|
||||
"summary.tag_add_placeholder": "Tag hinzufügen…",
|
||||
"summary.tag_first_placeholder": "Projekt, Kunde, Thema…",
|
||||
"summary.generating": "Wird generiert…",
|
||||
"summary.generate_tags": "Tags generieren",
|
||||
"summary.saving": "Wird gespeichert…",
|
||||
"summary.save_tags": "Tags speichern",
|
||||
"summary.summary_heading": "Zusammenfassung",
|
||||
"summary.ai_provider": "KI-Anbieter",
|
||||
"summary.provider_off": "Aus",
|
||||
"summary.provider_custom": "Benutzerdefiniert",
|
||||
"summary.local": "lokal",
|
||||
"summary.leaves_device": "verlässt dieses Gerät",
|
||||
"summary.this_hosted_provider": "dieser gehostete Anbieter",
|
||||
"summary.select_generate": "Wählen Sie ein Meeting, um eine Zusammenfassung zu erstellen.",
|
||||
"summary.decisions_heading": "Entscheidungen",
|
||||
"summary.regenerate": "Neu generieren",
|
||||
"summary.generated_hint": "Lokal nach dem Meeting generiert (erfordert einen lokalen LLM-Anbieter).",
|
||||
"summary.no_provider": "Kein KI-Anbieter konfiguriert — aktivieren Sie einen in den Einstellungen.",
|
||||
"summary.generate_summary": "Zusammenfassung erstellen",
|
||||
"summary.briefs_heading": "Feature-Briefs",
|
||||
"summary.briefs_select": "Wählen Sie ein Meeting, um Feature-Briefs zu erstellen oder anzuzeigen.",
|
||||
"summary.briefs_desc": "Destilliert dieses Meeting zu einer agentenbereiten Spezifikation (erfordert einen lokalen LLM-Anbieter) — übergeben Sie sie einem Coding-Agenten oder stellen Sie sie über den MCP-Server bereit, sobald dieser an ist.",
|
||||
"summary.brief_repo_placeholder": "Ziel-Repo (optional), z. B. acme/reporting-web",
|
||||
"summary.distilling": "Wird destilliert…",
|
||||
"summary.create_brief": "Feature-Brief erstellen",
|
||||
"summary.brief_mcp_title": "Verfügbar, wenn der MCP-Server an ist",
|
||||
"summary.copied": "Kopiert",
|
||||
"summary.copy_md": "Als Markdown kopieren",
|
||||
"summary.copy_json": "Als JSON kopieren",
|
||||
"summary.brief_problem": "Problem",
|
||||
"summary.brief_outcome": "Gewünschtes Ergebnis",
|
||||
"summary.brief_criteria": "Akzeptanzkriterien",
|
||||
"summary.brief_none": "Nichts erfasst.",
|
||||
"summary.brief_context": "Kontext",
|
||||
"summary.action_items_heading": "Aktionspunkte",
|
||||
"summary.ai_select": "Wählen Sie ein Meeting, um seine Aktionspunkte zu sehen.",
|
||||
"summary.ai_empty": "Noch keine — fügen Sie unten einen hinzu oder erstellen Sie eine Zusammenfassung, um sie automatisch zu extrahieren.",
|
||||
"summary.confirmed": "Bestätigt",
|
||||
"summary.ai_text_placeholder": "Aktionspunkt…",
|
||||
"summary.ai_text_aria": "Aktionspunkttext",
|
||||
"summary.owner": "Verantwortlich",
|
||||
"summary.due_date": "Fälligkeitsdatum",
|
||||
"summary.reminder_title": "Eine lokale Erinnerung für dieses Fälligkeitsdatum planen",
|
||||
"summary.ai_delete_title": "Diesen Aktionspunkt löschen",
|
||||
"summary.ai_delete_aria": "Aktionspunkt löschen",
|
||||
"summary.add_action_item": "Aktionspunkt hinzufügen",
|
||||
"summary.save_action_items": "Aktionspunkte speichern",
|
||||
"summary.calendar_heading": "Kalendertermin",
|
||||
"summary.cal_select": "Wählen Sie ein Meeting, um es mit einem Kalendertermin zu verknüpfen.",
|
||||
"summary.event_search_placeholder": "Termintitel durchsuchen…",
|
||||
"summary.linked_event": "Verknüpfter Termin",
|
||||
"summary.change_event": "Termin ändern…",
|
||||
"summary.link_event": "Einen Termin verknüpfen…",
|
||||
"summary.untitled_event": "(ohne Titel)",
|
||||
"summary.no_events_1": "Noch keine Termine importiert — importieren Sie einen",
|
||||
"summary.no_events_2": "unter Einstellungen → Kalender.",
|
||||
"summary.no_events_match": "Keine Termine passen zu dieser Suche/diesem Datum — versuchen Sie, eines zu löschen.",
|
||||
"summary.organizer_label": "Organisator: {name}",
|
||||
"summary.participants_heading": "Teilnehmer",
|
||||
"summary.participants_hint": "Aus dem verknüpften Kalendertermin übernommen.",
|
||||
"summary.speakers_heading": "Sprecher",
|
||||
"summary.speakers_select": "Wählen Sie ein Meeting, um seine Sprecher zu benennen.",
|
||||
"summary.no_speakers": "Noch keine Sprecher erkannt.",
|
||||
"summary.speaker_name_placeholder": "Sprechername",
|
||||
"summary.save": "Speichern",
|
||||
"summary.cancel": "Abbrechen",
|
||||
"summary.name_speaker": "Diesen Sprecher benennen…",
|
||||
"summary.add_new_name": "+ Neuen Namen hinzufügen…",
|
||||
|
||||
"settings.recording.title": "Aufnahme",
|
||||
"settings.recording.record_default": "Meetings standardmäßig aufnehmen",
|
||||
"settings.recording.save_audio_as": "Audio speichern als",
|
||||
"settings.recording.default_hint": "Standardmäßig aus. Wenn aus, wird Audio nur zur Erstellung des Transkripts verwendet und beim Finalisieren des Meetings gelöscht. Sie können die Aufnahme auch pro Meeting umschalten.",
|
||||
"settings.recording.consent_label": "Einwilligung: {status}.",
|
||||
"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.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."
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"nav.recording": "Recording",
|
||||
"nav.hardware": "Hardware",
|
||||
"nav.storage": "Storage",
|
||||
"nav.calendar": "Calendar",
|
||||
"nav.sync": "Sync",
|
||||
"nav.ai": "AI",
|
||||
"nav.mcp": "MCP server",
|
||||
"nav.privacy": "Privacy",
|
||||
"nav.about": "About",
|
||||
"nav.language": "Language",
|
||||
|
||||
"settings.language.title": "Language",
|
||||
"settings.language.display": "Display language",
|
||||
"settings.language.display_hint": "The language of the app interface. Adding a language is as simple as dropping in one translation file.",
|
||||
|
||||
"settings.transcription.title": "Transcription Language",
|
||||
"settings.transcription.label": "Language",
|
||||
"settings.transcription.auto": "Auto-detect",
|
||||
"settings.transcription.applies_hint": "Applies to the next recording. The language actually used is shown on each meeting afterward.",
|
||||
"settings.transcription.english_only": "English only",
|
||||
"settings.transcription.model_english_only": "\"{model}\" is English-only.",
|
||||
"settings.transcription.no_model": "No model selected.",
|
||||
"settings.transcription.switch_multilingual": "Switch to a multilingual model above to choose a language.",
|
||||
|
||||
"settings.hardware.title": "Hardware",
|
||||
"settings.hardware.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",
|
||||
"settings.hardware.auto_backend": "Auto (best available)",
|
||||
"settings.hardware.not_available": "not available",
|
||||
"settings.hardware.fallback_hint": "Falls back automatically (NPU → NVIDIA → AMD → Intel → CPU) if the chosen backend fails to load.",
|
||||
"settings.hardware.audio_devices": "Audio Devices",
|
||||
"settings.hardware.recording_device": "Recording device",
|
||||
"settings.hardware.default_system_audio": "Default system audio",
|
||||
"settings.hardware.recording_device_hint": "WhispAssist records whatever this device plays (loopback) — the other side of the call. Pick a specific output if you don't want it following Windows' system default.",
|
||||
"settings.hardware.microphone": "Microphone",
|
||||
"settings.hardware.mic_off": "Off — don't capture my microphone",
|
||||
"settings.hardware.default_mic": "Default microphone",
|
||||
"settings.hardware.mic_hint": "Adds your own voice to the live transcript so both sides of the meeting are captured. Stays on your device — nothing is uploaded. Choose “Off” to transcribe only the system audio above.",
|
||||
"settings.hardware.npu_detected": "NPU accelerator detected",
|
||||
"settings.hardware.ready": "Ready",
|
||||
"settings.hardware.downloading": "Downloading…",
|
||||
"settings.hardware.package_needed": "Package needed",
|
||||
"settings.hardware.npu_hint": "Your NPU needs a one-time acceleration package (Whisper ONNX model{extra}). It downloads in the background; you can also start it now.",
|
||||
"settings.hardware.plus_openvino": " + OpenVINO runtime",
|
||||
"settings.hardware.download_npu": "Download NPU package",
|
||||
"settings.hardware.dml_title": "GPU acceleration (DirectML)",
|
||||
"settings.hardware.dml_hint": "Your GPU can be accelerated without Vulkan via DirectML — a one-time package (Whisper ONNX model{extra}).",
|
||||
"settings.hardware.plus_directml": " + DirectML runtime",
|
||||
"settings.hardware.download_dml": "Download DirectML package",
|
||||
"settings.hardware.detection_unavailable": "Hardware detection unavailable.",
|
||||
"settings.hardware.low_overhead": "Low overhead preset",
|
||||
"settings.hardware.low_overhead_note": "CPU + smallest model, for battery/background use",
|
||||
"settings.hardware.models": "Models",
|
||||
"settings.hardware.downloading_model": "Downloading {name}",
|
||||
"settings.hardware.active_badge": "Active",
|
||||
"settings.hardware.installed": "Installed",
|
||||
"settings.hardware.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",
|
||||
"settings.hardware.download": "Download",
|
||||
"settings.hardware.diarization": "Speaker diarization",
|
||||
"settings.hardware.diarization_hint": "Install both models to have finished recordings separated by speaker (Speaker 1, Speaker 2, …) instead of one running transcript. Runs fully offline, once per meeting after it stops. Without them, every line is attributed to a single speaker.",
|
||||
|
||||
"settings.storage.title": "Storage",
|
||||
"settings.storage.location_label": "Location",
|
||||
"settings.storage.location_hint": "Changing the storage location isn't supported yet — this is where meetings, models, and the database currently live.",
|
||||
"settings.storage.retention_title": "Retention",
|
||||
"settings.storage.retention_hint": "Automatically delete the oldest meetings past a limit. Checked once at startup; empty means no limit. Never touches a meeting that's currently recording.",
|
||||
"settings.storage.max_age": "Max age (days)",
|
||||
"settings.storage.max_size": "Max size (GB)",
|
||||
"settings.storage.no_limit": "no limit",
|
||||
"settings.storage.export_import_title": "Export & import",
|
||||
"settings.storage.export_hint_1": "Move recordings between computers as portable bundle folders (audio, transcript, notes, summary, and a",
|
||||
"settings.storage.export_hint_2": "manifest). Export to any folder — a synced drive, a USB stick, or a sync target's local mount — then import it on the other machine. Imported meetings get a fresh id, so re-importing never overwrites anything.",
|
||||
"settings.storage.export_dialog_title": "Export all meetings into…",
|
||||
"settings.storage.import_dialog_title": "Import a bundle (or a folder of bundles)…",
|
||||
"settings.storage.exporting": "Exporting…",
|
||||
"settings.storage.export_all": "Export all meetings…",
|
||||
"settings.storage.importing": "Importing…",
|
||||
"settings.storage.import_all": "Import meetings…",
|
||||
"settings.storage.exported_one": "Exported {n} meeting.",
|
||||
"settings.storage.exported_many": "Exported {n} meetings.",
|
||||
"settings.storage.imported_one": "Imported {n} meeting.",
|
||||
"settings.storage.imported_many": "Imported {n} meetings.",
|
||||
|
||||
"settings.calendar.title": "Calendar & Outlook .pst",
|
||||
"settings.calendar.intro_1": "Import events and attendees from a local Outlook",
|
||||
"settings.calendar.intro_2": "backup — read-only, nothing is written back to the file. Nothing leaves this device.",
|
||||
"settings.calendar.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…",
|
||||
"settings.calendar.password": "Password",
|
||||
"settings.calendar.password_note": "rarely needed",
|
||||
"settings.calendar.optional": "optional",
|
||||
"settings.calendar.import_range": "Import range",
|
||||
"settings.calendar.range_30": "Last 30 days",
|
||||
"settings.calendar.range_90": "Last 90 days",
|
||||
"settings.calendar.range_180": "Last 6 months",
|
||||
"settings.calendar.range_365": "Last 1 year",
|
||||
"settings.calendar.range_all": "All time",
|
||||
"settings.calendar.range_hint": "A long-lived mailbox can hold years of recurring/holiday entries — narrowing the range keeps the imported calendar to what's actually relevant. Applies to both this Import button and automatic re-import on launch.",
|
||||
"settings.calendar.importing": "Importing…",
|
||||
"settings.calendar.import": "Import",
|
||||
"settings.calendar.import_progress": "{processed}/{total} events",
|
||||
"settings.calendar.auto_reimport": "Re-import this file automatically on launch",
|
||||
"settings.calendar.auto_reimport_hint": "Runs once at startup, not on a timer — re-import is safe to repeat (existing events are matched and updated, not duplicated).",
|
||||
"settings.calendar.import_failed": "Import failed: {error} — the file itself is untouched; check the path and try again.",
|
||||
"settings.calendar.cleanup_title": "Clean up",
|
||||
"settings.calendar.cleanup_hint": "Events already attached to a recorded meeting are always kept, no matter which option below is picked.",
|
||||
"settings.calendar.cleanup_30": "Older than 30 days",
|
||||
"settings.calendar.cleanup_90": "Older than 90 days",
|
||||
"settings.calendar.cleanup_180": "Older than 6 months",
|
||||
"settings.calendar.cleanup_365": "Older than 1 year",
|
||||
"settings.calendar.cleanup_all": "Delete all",
|
||||
"settings.calendar.cleaning": "Cleaning up…",
|
||||
"settings.calendar.cleanup_btn": "Clean up calendar",
|
||||
"settings.calendar.cleanup_result": "Deleted {deleted}{extra}.",
|
||||
"settings.calendar.cleanup_kept": ", kept {n} (linked to a meeting)",
|
||||
"settings.calendar.cleanup_failed": "Clean up failed: {error}",
|
||||
"settings.calendar.imported_events": "Imported events",
|
||||
"settings.calendar.no_events": "No events imported yet.",
|
||||
"settings.calendar.search_title": "Search title",
|
||||
"settings.calendar.search_placeholder": "Meeting name…",
|
||||
"settings.calendar.date_label": "Date",
|
||||
"settings.calendar.events_count": "{shown} of {total} events",
|
||||
"settings.calendar.untitled": "(untitled)",
|
||||
|
||||
"settings.dialog_title": "Settings",
|
||||
"settings.close": "Close",
|
||||
"settings.close_title": "Close (Esc)",
|
||||
|
||||
"settings.sync.stub": "Preview mode — the sync backend isn't implemented yet (Phase 9). Changes are kept in the UI only.",
|
||||
"settings.sync.title": "Sync & upload",
|
||||
"settings.sync.enable_label": "Enable uploading meeting artifacts to configured targets",
|
||||
"settings.sync.enable_hint": "Off by default. Nothing is uploaded unless this is on and a target is enabled. Self-hosted targets keep data on your own server; third-party clouds are clearly labeled.",
|
||||
"settings.sync.targets_title": "Targets",
|
||||
"settings.sync.no_targets": "No targets yet. Add one below.",
|
||||
"settings.sync.third_party": "third-party",
|
||||
"settings.sync.your_server": "your server",
|
||||
"settings.sync.edit": "Edit",
|
||||
"settings.sync.remove": "Remove",
|
||||
"settings.sync.edit_target": "Edit target",
|
||||
"settings.sync.add_target": "Add a target",
|
||||
"settings.sync.webdav_hint": "WebDAV covers Nextcloud, ownCloud, Cloudreve, Seafile, and Synology. (Seafile: enable SeafDAV server-side.)",
|
||||
"settings.sync.name": "Name",
|
||||
"settings.sync.name_placeholder": "Home Nextcloud",
|
||||
"settings.sync.provider": "Provider",
|
||||
"settings.sync.server_url": "Server URL",
|
||||
"settings.sync.server_url_note_1": "Just your server URL — WhispAssist adds",
|
||||
"settings.sync.server_url_note_2": "automatically.",
|
||||
"settings.sync.remote_folder": "Remote folder",
|
||||
"settings.sync.username": "Username",
|
||||
"settings.sync.app_password": "App password",
|
||||
"settings.sync.pw_keep": "leave blank to keep current password",
|
||||
"settings.sync.pw_store": "stored in OS credential store",
|
||||
"settings.sync.upload_legend": "Upload",
|
||||
"settings.sync.artifact_transcript": "transcript",
|
||||
"settings.sync.artifact_notes": "notes",
|
||||
"settings.sync.artifact_summary": "summary",
|
||||
"settings.sync.artifact_recording": "recording (.wav, if retained)",
|
||||
"settings.sync.allow_plaintext": "Allow plaintext http for a LAN address (not recommended)",
|
||||
"settings.sync.encrypt_before": "Encrypt before upload (destination stores only ciphertext)",
|
||||
"settings.sync.requires_vault": "— requires an unlocked vault",
|
||||
"settings.sync.test_connection": "Test connection",
|
||||
"settings.sync.save_changes": "Save changes",
|
||||
"settings.sync.add_target_btn": "Add target",
|
||||
"settings.sync.cancel": "Cancel",
|
||||
"settings.sync.third_party_banner": "{kind} is a third-party cloud — uploading sends your data off your device to {kind}.",
|
||||
"settings.sync.link_account": "Link {kind} account…",
|
||||
"settings.sync.oauth_hint": "Opens an OAuth sign-in (loopback redirect); the token is stored in your OS credential store.",
|
||||
"settings.sync.footnote": "Credentials are never written to settings or the database — only the OS credential store. TLS is required for non-LAN targets.",
|
||||
|
||||
"settings.ai.title": "AI summary provider",
|
||||
"settings.ai.intro_1": "Summaries run on a local LLM by default. Point this at Ollama on this PC or another machine on your LAN (e.g.",
|
||||
"settings.ai.intro_2": "— both count as local, so nothing leaves your network. Hosted providers (Anthropic) are optional, off by default, and send the transcript to a third party once you turn one on.",
|
||||
"settings.ai.provider": "Provider",
|
||||
"settings.ai.off": "Off",
|
||||
"settings.ai.provider_ollama": "Ollama (local / LAN)",
|
||||
"settings.ai.provider_custom": "Custom (OpenAI-compatible)",
|
||||
"settings.ai.provider_anthropic": "Anthropic (Claude) — hosted, leaves this device",
|
||||
"settings.ai.endpoint": "Endpoint",
|
||||
"settings.ai.model": "Model",
|
||||
"settings.ai.api_key": "API key",
|
||||
"settings.ai.api_key_set_placeholder": "•••••••••••••••• (already set — leave blank to keep it)",
|
||||
"settings.ai.show_api_key": "Show API key",
|
||||
"settings.ai.hide_api_key": "Hide API key",
|
||||
"settings.ai.anthropic_banner": "Anthropic is a hosted, third-party service — this meeting's transcript leaves your device when you generate a summary.",
|
||||
"settings.ai.endpoint_banner": "This endpoint isn't on your machine or LAN — your transcript would leave your network.",
|
||||
"settings.ai.saving": "Saving…",
|
||||
"settings.ai.save": "Save",
|
||||
"settings.ai.test_connection": "Test connection",
|
||||
"settings.ai.this_hosted_provider": "this hosted provider",
|
||||
"settings.ai.reachable": "Reachable",
|
||||
"settings.ai.unreachable": "Unreachable",
|
||||
"settings.ai.model_count_one": "· {n} model",
|
||||
"settings.ai.model_count_many": "· {n} models",
|
||||
"settings.ai.on_lan": "· on your machine / LAN",
|
||||
"settings.ai.leaves_network": "· leaves your network",
|
||||
"settings.ai.advanced_title": "Advanced Ollama configuration",
|
||||
"settings.ai.changed": "{n} changed",
|
||||
"settings.ai.reset_all": "Reset all to defaults",
|
||||
"settings.ai.system_prompt": "System prompt",
|
||||
"settings.ai.system_prompt_placeholder": "e.g. You are a concise meeting summarizer.",
|
||||
"settings.ai.system_prompt_hint": "Added before WhispAssist's required output format, so your instructions can't break summary/action-item parsing.",
|
||||
"settings.ai.think": "Think",
|
||||
"settings.ai.think_help": "Reasoning effort (reasoning models only).",
|
||||
"settings.ai.keep_alive": "Keep alive",
|
||||
"settings.ai.keep_alive_help": "How long the model stays in RAM. e.g. 5m, 1h, 0 (unload), -1 (forever).",
|
||||
"settings.ai.runtime_hardware": "Runtime & hardware",
|
||||
"settings.ai.rarely_needed": "— rarely needed",
|
||||
"settings.ai.save_advanced": "Save advanced",
|
||||
"settings.ai.saved": "Saved",
|
||||
"settings.ai.turn_off": "Turn off",
|
||||
"settings.ai.default_value": "Default {value}.",
|
||||
"settings.ai.reset_default": "Reset to default",
|
||||
"settings.ai.reset_field": "Reset {name}",
|
||||
|
||||
"settings.mcp.title": "MCP server",
|
||||
"settings.mcp.banner_1": "This lets your own coding agent (Claude Code, Codex, Copilot, OpenCode, …) pull meeting context on your local machine. Once connected,",
|
||||
"settings.mcp.banner_agent": "that agent",
|
||||
"settings.mcp.banner_2": "may forward what it reads to its own model provider's cloud — outside WhispAssist's control. WhispAssist itself never sends this data anywhere; the server only listens on this device (",
|
||||
"settings.mcp.banner_3": ") and every read is logged below.",
|
||||
"settings.mcp.enable": "Enable the MCP server",
|
||||
"settings.mcp.enable_hint": "Off by default. Loopback-only, token-gated — nothing is reachable from the network.",
|
||||
"settings.mcp.transport": "Transport",
|
||||
"settings.mcp.transport_http": "Streamable HTTP",
|
||||
"settings.mcp.transport_stdio": "stdio (agent spawns a process)",
|
||||
"settings.mcp.port": "Port",
|
||||
"settings.mcp.transport_change_hint": "To change transport/port, turn the server off first, then back on.",
|
||||
"settings.mcp.scope_title": "Scope",
|
||||
"settings.mcp.expose": "Expose",
|
||||
"settings.mcp.scope_none": "None — nothing is shared",
|
||||
"settings.mcp.scope_selected": "Selected — only feature briefs you've marked shared",
|
||||
"settings.mcp.scope_all": "All — meetings, transcripts, action items, and shared briefs",
|
||||
"settings.mcp.expose_recordings": "Also allow meetings with a saved recording (off by default — a recorded meeting's transcript is withheld even in \"All\" scope until this is on)",
|
||||
"settings.mcp.token_title": "New auth token — shown once, copy it now",
|
||||
"settings.mcp.token_hint": "This won't be shown again. It's stored in your OS credential store; if you lose it, turn the server off and back on to mint a new one.",
|
||||
"settings.mcp.copied": "Copied",
|
||||
"settings.mcp.copy": "Copy",
|
||||
"settings.mcp.endpoint": "Endpoint",
|
||||
"settings.mcp.endpoint_hint": "Point your agent's MCP client config at this {kind}, with the token above as a bearer credential.",
|
||||
"settings.mcp.kind_command": "command",
|
||||
"settings.mcp.kind_url": "URL",
|
||||
"settings.mcp.access_log": "Access log",
|
||||
"settings.mcp.access_log_hint": "Every tool read an agent makes, allowed or denied (FR-MCP-5).",
|
||||
"settings.mcp.no_reads": "No agent has read anything yet.",
|
||||
"settings.mcp.log_meeting": "meeting {id}",
|
||||
"settings.mcp.refresh": "Refresh",
|
||||
|
||||
"settings.privacy.title": "Privacy",
|
||||
"settings.privacy.intro": "What WhispAssist is actually allowed to send off this device right now. With everything off (the default), nothing leaves the device at all.",
|
||||
"settings.privacy.llm_endpoint": "LLM endpoint",
|
||||
"settings.privacy.off": "off",
|
||||
"settings.privacy.local_only": "local-only",
|
||||
"settings.privacy.leaves_device": "leaves this device",
|
||||
"settings.privacy.sync_label": "Sync",
|
||||
"settings.privacy.enabled": "enabled",
|
||||
"settings.privacy.mcp_label": "MCP server",
|
||||
"settings.privacy.mcp_on": "on · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "Inbound on loopback only — it adds nothing to the egress list above. A connected agent may still forward what it reads to its own model provider; see the MCP server tab.",
|
||||
"settings.privacy.egress_title": "Egress allowlist",
|
||||
"settings.privacy.no_egress": "No hosts are allowlisted — WA makes no content egress.",
|
||||
"settings.privacy.sync_targets_title": "Sync targets",
|
||||
"settings.privacy.third_party": "third-party",
|
||||
"settings.privacy.your_server": "your server",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "no TLS",
|
||||
"settings.privacy.refresh": "Refresh",
|
||||
"settings.privacy.unavailable": "Privacy self-check unavailable.",
|
||||
"settings.privacy.vault_title": "Encryption vault",
|
||||
"settings.privacy.vault_intro": "Encrypt notes, transcripts, and summaries at rest with a password. (Audio files are not encrypted yet.)",
|
||||
"settings.privacy.vault_password": "Vault password",
|
||||
"settings.privacy.enable_vault": "Enable vault",
|
||||
"settings.privacy.vault_pw_hint": "Use at least 8 characters. If you forget it, encrypted content can't be recovered.",
|
||||
"settings.privacy.vault_locked_1": "Vault is ",
|
||||
"settings.privacy.locked_word": "locked",
|
||||
"settings.privacy.vault_locked_2": ". Unlock to read encrypted meetings.",
|
||||
"settings.privacy.password": "Password",
|
||||
"settings.privacy.show_password": "Show password",
|
||||
"settings.privacy.unlock": "Unlock",
|
||||
"settings.privacy.vault_unlocked_1": "Vault is ",
|
||||
"settings.privacy.unlocked_word": "unlocked",
|
||||
"settings.privacy.vault_unlocked_2": ". New notes, transcripts, and summaries are encrypted at rest.",
|
||||
"settings.privacy.lock_now": "Lock now",
|
||||
"settings.privacy.change_password": "Change password",
|
||||
"settings.privacy.current_password": "Current password",
|
||||
"settings.privacy.new_password": "New password",
|
||||
|
||||
"settings.about.title": "About",
|
||||
"settings.about.tagline": "A fully local, open-source, Windows-native meeting assistant.",
|
||||
"settings.about.build_commit": "Build commit",
|
||||
|
||||
"consent.heading": "Before you record",
|
||||
"consent.body": "Recording conversations without the consent of participants may be illegal in your region. Check your local recording laws. This is a caution, not legal advice.",
|
||||
"consent.accept": "I understand — enable recording",
|
||||
"consent.cancel": "Cancel",
|
||||
|
||||
"hosted.heading": "Before using {provider}",
|
||||
"hosted.body_1": "Generating with",
|
||||
"hosted.body_2": "sends this meeting's transcript to their servers — it leaves this device and is subject to their privacy policy. WhispAssist has no control over data handling once it leaves your device. This is off by default; you're choosing it now.",
|
||||
"hosted.accept": "I understand — continue",
|
||||
"hosted.cancel": "Cancel",
|
||||
|
||||
"theme.group": "Theme",
|
||||
"theme.system": "Match system",
|
||||
"theme.light": "Light",
|
||||
"theme.dark": "Dark",
|
||||
|
||||
"import.dialog_label": "Add a meeting from a file or URL",
|
||||
"import.title": "Add a meeting",
|
||||
"import.close": "Close",
|
||||
"import.close_title": "Close (Esc)",
|
||||
"import.body": "Import an existing recording — a local audio/video file, or a link (YouTube, a streaming page, or a direct media URL). It's transcribed and diarized just like a live recording.",
|
||||
"import.file_or_url": "File or URL",
|
||||
"import.source_placeholder": "Paste a URL, or browse for a file…",
|
||||
"import.choose_file": "Choose a local file",
|
||||
"import.browse": "Browse…",
|
||||
"import.title_label": "Title",
|
||||
"import.optional": "optional",
|
||||
"import.title_placeholder": "Defaults to the file name",
|
||||
"import.filter_av": "Audio / video",
|
||||
"import.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}",
|
||||
|
||||
"levelmeter.system_mic": "System and microphone input level",
|
||||
"levelmeter.system": "System input level",
|
||||
|
||||
"app.tagline": "local · private",
|
||||
"app.note_template": "Note template",
|
||||
"app.no_template": "No template",
|
||||
"app.add_meeting": "Add meeting",
|
||||
"app.add_meeting_title": "Add a meeting from a file or URL",
|
||||
"app.record": "Record",
|
||||
"app.record_title": "Start recording (Ctrl+Shift+R)",
|
||||
"app.stop": "Stop",
|
||||
"app.stop_title": "Stop recording (Ctrl+Shift+R)",
|
||||
"app.cancel": "Cancel",
|
||||
"app.cancel_title": "Discard this recording and delete it",
|
||||
"app.recording": "Recording…",
|
||||
"app.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",
|
||||
"app.not_saved": "not saved",
|
||||
"app.reconnecting": "reconnecting audio device…",
|
||||
"app.settings": "Settings",
|
||||
"app.settings_title": "Settings (Ctrl+,)",
|
||||
"app.discard_confirm": "Discard this recording? Its audio and transcript will be deleted.",
|
||||
"app.announce_started": "Recording started",
|
||||
"app.announce_paused": "Recording paused",
|
||||
"app.announce_stopped": "Recording stopped",
|
||||
"app.consent_dialog": "Recording consent",
|
||||
"app.vault_dialog": "Unlock encryption vault",
|
||||
"app.vault_title": "Unlock encryption vault",
|
||||
"app.vault_desc": "Your meetings are encrypted at rest. Enter your password to read them.",
|
||||
"app.vault_password": "Vault password",
|
||||
"app.vault_incorrect": "Incorrect password",
|
||||
"app.unlock": "Unlock",
|
||||
"app.continue_locked": "Continue locked",
|
||||
"app.show_meetings": "Show meetings list",
|
||||
"app.hide_meetings": "Hide meetings list",
|
||||
"app.show_summary": "Show summary panel",
|
||||
"app.hide_summary": "Hide summary panel",
|
||||
"app.resize_meetings": "Resize meetings list",
|
||||
"app.resize_summary": "Resize summary panel",
|
||||
|
||||
"meetings.search_placeholder": "Search meetings…",
|
||||
"meetings.search_aria": "Search meetings",
|
||||
"meetings.filter_tag_aria": "Filter by tag",
|
||||
"meetings.all_tags": "All tags",
|
||||
"meetings.from_date_aria": "From date",
|
||||
"meetings.to_date_aria": "To date",
|
||||
"meetings.bulk_format_aria": "Bulk export format",
|
||||
"meetings.bulk_export": "Bulk export",
|
||||
"meetings.bulk_export_title": "Export every meeting matching the tag/date filters above",
|
||||
"meetings.exporting": "Exporting…",
|
||||
"meetings.exported_one": "Exported {n} meeting.",
|
||||
"meetings.exported_many": "Exported {n} meetings.",
|
||||
"meetings.searching": "Searching…",
|
||||
"meetings.loading": "Loading meetings…",
|
||||
"meetings.no_matches": "No matches.",
|
||||
"meetings.empty": "No meetings yet — click Record above to start.",
|
||||
"meetings.status.recording": "recording",
|
||||
"meetings.status.transcribing": "transcribing",
|
||||
"meetings.status.recovering": "recovering",
|
||||
"meetings.status.error": "error",
|
||||
"meetings.resume": "Resume transcription",
|
||||
"meetings.delete_aria": "Delete meeting",
|
||||
"meetings.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",
|
||||
"transcript.hide": "Hide transcript",
|
||||
"transcript.empty": "No transcript for this meeting.",
|
||||
"transcript.will_appear": "Transcript will appear here as you record.",
|
||||
"transcript.play_from_here": "Play from here",
|
||||
"transcript.resize": "Resize transcript and notes",
|
||||
"transcript.reprocess_placeholder": "Re-transcribe with…",
|
||||
"transcript.reprocess_lang_aria": "Reprocess language",
|
||||
"transcript.reprocess_keep_lang": "Keep current language",
|
||||
"transcript.reprocess_go": "Go",
|
||||
"transcript.retranscribing": "Re-transcribing…",
|
||||
"transcript.has_note": "Has a note",
|
||||
"transcript.note_placeholder": "Add a note for this moment…",
|
||||
"transcript.note_aria": "Note for this transcript line",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "Word document",
|
||||
"transcript.filter_obsidian": "Obsidian note",
|
||||
|
||||
"notes.heading": "Notes",
|
||||
"notes.show": "Show notes",
|
||||
"notes.hide": "Hide notes",
|
||||
"notes.placeholder": "Notes…",
|
||||
"notes.live_placeholder": "Type notes while you talk…",
|
||||
"notes.toolbar_aria": "Notes formatting",
|
||||
"notes.bold": "Bold",
|
||||
"notes.italic": "Italic",
|
||||
"notes.h1": "Heading 1",
|
||||
"notes.h2": "Heading 2",
|
||||
"notes.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",
|
||||
"notes.preview": "Preview",
|
||||
"notes.export_md_title": "Export notes as .md",
|
||||
"notes.export_pdf_title": "Export notes as .pdf",
|
||||
"notes.export_docx_title": "Export notes as .docx",
|
||||
"notes.export_bundle_title": "Export audio + transcript + notes to a folder",
|
||||
"notes.export_obsidian_title": "Export one Obsidian note (notes + summary + transcript, no audio)",
|
||||
|
||||
"summary.recording_heading": "Recording",
|
||||
"summary.loading_recording": "Loading recording…",
|
||||
"summary.sync_heading": "Sync",
|
||||
"summary.uploading": "Uploading…",
|
||||
"summary.upload_now": "Upload now",
|
||||
"summary.no_uploads": "No uploads yet for this meeting.",
|
||||
"summary.retry": "retry",
|
||||
"summary.tags_heading": "Tags",
|
||||
"summary.tags_select": "Select a meeting to tag it.",
|
||||
"summary.tag_add_placeholder": "Add tag…",
|
||||
"summary.tag_first_placeholder": "project, client, topic…",
|
||||
"summary.generating": "Generating…",
|
||||
"summary.generate_tags": "Generate tags",
|
||||
"summary.saving": "Saving…",
|
||||
"summary.save_tags": "Save tags",
|
||||
"summary.summary_heading": "Summary",
|
||||
"summary.ai_provider": "AI provider",
|
||||
"summary.provider_off": "Off",
|
||||
"summary.provider_custom": "Custom",
|
||||
"summary.local": "local",
|
||||
"summary.leaves_device": "leaves this device",
|
||||
"summary.this_hosted_provider": "this hosted provider",
|
||||
"summary.select_generate": "Select a meeting to generate a summary.",
|
||||
"summary.decisions_heading": "Decisions",
|
||||
"summary.regenerate": "Regenerate",
|
||||
"summary.generated_hint": "Generated locally after the meeting (requires a local LLM provider).",
|
||||
"summary.no_provider": "No AI provider configured — enable one in Settings.",
|
||||
"summary.generate_summary": "Generate summary",
|
||||
"summary.briefs_heading": "Feature briefs",
|
||||
"summary.briefs_select": "Select a meeting to create or view feature briefs.",
|
||||
"summary.briefs_desc": "Distills this meeting into an agent-ready spec (requires a local LLM provider) — hand it to a coding agent, or serve it over the MCP server once that's on.",
|
||||
"summary.brief_repo_placeholder": "Target repo (optional), e.g. acme/reporting-web",
|
||||
"summary.distilling": "Distilling…",
|
||||
"summary.create_brief": "Create feature brief",
|
||||
"summary.brief_mcp_title": "Available when the MCP server is on",
|
||||
"summary.copied": "Copied",
|
||||
"summary.copy_md": "Copy as Markdown",
|
||||
"summary.copy_json": "Copy as JSON",
|
||||
"summary.brief_problem": "Problem",
|
||||
"summary.brief_outcome": "Desired outcome",
|
||||
"summary.brief_criteria": "Acceptance criteria",
|
||||
"summary.brief_none": "None captured.",
|
||||
"summary.brief_context": "Context",
|
||||
"summary.action_items_heading": "Action items",
|
||||
"summary.ai_select": "Select a meeting to see its action items.",
|
||||
"summary.ai_empty": "None yet — add one below, or generate a summary to extract them automatically.",
|
||||
"summary.confirmed": "Confirmed",
|
||||
"summary.ai_text_placeholder": "Action item…",
|
||||
"summary.ai_text_aria": "Action item text",
|
||||
"summary.owner": "Owner",
|
||||
"summary.due_date": "Due date",
|
||||
"summary.reminder_title": "Schedule a local reminder for this due date",
|
||||
"summary.ai_delete_title": "Delete this action item",
|
||||
"summary.ai_delete_aria": "Delete action item",
|
||||
"summary.add_action_item": "Add action item",
|
||||
"summary.save_action_items": "Save action items",
|
||||
"summary.calendar_heading": "Calendar event",
|
||||
"summary.cal_select": "Select a meeting to link it to a calendar event.",
|
||||
"summary.event_search_placeholder": "Search event title…",
|
||||
"summary.linked_event": "Linked event",
|
||||
"summary.change_event": "Change event…",
|
||||
"summary.link_event": "Link an event…",
|
||||
"summary.untitled_event": "(untitled)",
|
||||
"summary.no_events_1": "No events imported yet — import a",
|
||||
"summary.no_events_2": "from Settings → Calendar.",
|
||||
"summary.no_events_match": "No events match this search/date — try clearing one.",
|
||||
"summary.organizer_label": "Organizer: {name}",
|
||||
"summary.participants_heading": "Participants",
|
||||
"summary.participants_hint": "Populated from the linked calendar event.",
|
||||
"summary.speakers_heading": "Speakers",
|
||||
"summary.speakers_select": "Select a meeting to name its speakers.",
|
||||
"summary.no_speakers": "No speakers detected yet.",
|
||||
"summary.speaker_name_placeholder": "Speaker name",
|
||||
"summary.save": "Save",
|
||||
"summary.cancel": "Cancel",
|
||||
"summary.name_speaker": "Name this speaker…",
|
||||
"summary.add_new_name": "+ Add new name…",
|
||||
|
||||
"settings.recording.title": "Recording",
|
||||
"settings.recording.record_default": "Record meetings by default",
|
||||
"settings.recording.save_audio_as": "save audio as",
|
||||
"settings.recording.default_hint": "Off by default. When off, audio is used only to produce the transcript and is deleted when the meeting is finalized. You can also toggle recording per meeting.",
|
||||
"settings.recording.consent_label": "Consent: {status}.",
|
||||
"settings.recording.consent_ack": "acknowledged",
|
||||
"settings.recording.consent_not": "not yet acknowledged",
|
||||
"settings.recording.auto_label": "Auto-start recording when a calendar event begins",
|
||||
"settings.recording.auto_hint": "Only while WhispAssist is open. When an imported calendar event's start time arrives, a recording begins automatically (using your default retention setting above). Nothing runs in the background — the timer is armed only while the app is running. Import events under Settings → Calendar.",
|
||||
"settings.recording.autostart_label": "Launch WhispAssist at login",
|
||||
"settings.recording.autostart_hint": "Starts WhispAssist automatically when you sign in to Windows. Off by default; installs a per-user startup entry (no admin required) and does not begin recording on its own.",
|
||||
"settings.recording.close_tray_label": "Close to system tray",
|
||||
"settings.recording.close_tray_hint": "Closing the window keeps WhispAssist running in the background instead of quitting. Reopen it from the tray icon; use the tray's Quit to exit fully. On by default."
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"nav.recording": "Grabación",
|
||||
"nav.hardware": "Hardware",
|
||||
"nav.storage": "Almacenamiento",
|
||||
"nav.calendar": "Calendario",
|
||||
"nav.sync": "Sincronización",
|
||||
"nav.ai": "IA",
|
||||
"nav.mcp": "Servidor MCP",
|
||||
"nav.privacy": "Privacidad",
|
||||
"nav.about": "Acerca de",
|
||||
"nav.language": "Idioma",
|
||||
|
||||
"settings.language.title": "Idioma",
|
||||
"settings.language.display": "Idioma de la interfaz",
|
||||
"settings.language.display_hint": "El idioma de la interfaz de la aplicación. Añadir un idioma es tan sencillo como colocar un único archivo de traducción.",
|
||||
|
||||
"settings.transcription.title": "Idioma de transcripción",
|
||||
"settings.transcription.label": "Idioma",
|
||||
"settings.transcription.auto": "Detección automática",
|
||||
"settings.transcription.applies_hint": "Se aplica a la próxima grabación. El idioma realmente utilizado se muestra después en cada reunión.",
|
||||
"settings.transcription.english_only": "Solo inglés",
|
||||
"settings.transcription.model_english_only": "«{model}» es solo en inglés.",
|
||||
"settings.transcription.no_model": "Ningún modelo seleccionado.",
|
||||
"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",
|
||||
"settings.hardware.auto_backend": "Automático (el mejor disponible)",
|
||||
"settings.hardware.not_available": "no disponible",
|
||||
"settings.hardware.fallback_hint": "Recurre automáticamente (NPU → NVIDIA → AMD → Intel → CPU) si el backend elegido no se carga.",
|
||||
"settings.hardware.audio_devices": "Dispositivos de audio",
|
||||
"settings.hardware.recording_device": "Dispositivo de grabación",
|
||||
"settings.hardware.default_system_audio": "Audio del sistema predeterminado",
|
||||
"settings.hardware.recording_device_hint": "WhispAssist graba lo que reproduce este dispositivo (loopback) — el otro lado de la llamada. Elige una salida concreta si no quieres que siga el valor predeterminado del sistema de Windows.",
|
||||
"settings.hardware.microphone": "Micrófono",
|
||||
"settings.hardware.mic_off": "Desactivado — no capturar mi micrófono",
|
||||
"settings.hardware.default_mic": "Micrófono predeterminado",
|
||||
"settings.hardware.mic_hint": "Añade tu propia voz a la transcripción en directo para que se capturen ambos lados de la reunión. Permanece en tu dispositivo — no se sube nada. Elige «Desactivado» para transcribir solo el audio del sistema de arriba.",
|
||||
"settings.hardware.npu_detected": "Acelerador NPU detectado",
|
||||
"settings.hardware.ready": "Listo",
|
||||
"settings.hardware.downloading": "Descargando…",
|
||||
"settings.hardware.package_needed": "Se necesita un paquete",
|
||||
"settings.hardware.npu_hint": "Tu NPU necesita un paquete de aceleración único (modelo Whisper ONNX{extra}). Se descarga en segundo plano; también puedes iniciarlo ahora.",
|
||||
"settings.hardware.plus_openvino": " + entorno de ejecución OpenVINO",
|
||||
"settings.hardware.download_npu": "Descargar paquete NPU",
|
||||
"settings.hardware.dml_title": "Aceleración de GPU (DirectML)",
|
||||
"settings.hardware.dml_hint": "Tu GPU puede acelerarse sin Vulkan mediante DirectML — un paquete único (modelo Whisper ONNX{extra}).",
|
||||
"settings.hardware.plus_directml": " + entorno de ejecución DirectML",
|
||||
"settings.hardware.download_dml": "Descargar paquete DirectML",
|
||||
"settings.hardware.detection_unavailable": "Detección de hardware no disponible.",
|
||||
"settings.hardware.low_overhead": "Preajuste de baja carga",
|
||||
"settings.hardware.low_overhead_note": "CPU + modelo más pequeño, para uso con batería/en segundo plano",
|
||||
"settings.hardware.models": "Modelos",
|
||||
"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",
|
||||
"settings.hardware.download": "Descargar",
|
||||
"settings.hardware.diarization": "Diarización de hablantes",
|
||||
"settings.hardware.diarization_hint": "Instala ambos modelos para que las grabaciones terminadas se separen por hablante (Hablante 1, Hablante 2, …) en lugar de una única transcripción continua. Se ejecuta totalmente sin conexión, una vez por reunión al detenerse. Sin ellos, cada línea se atribuye a un solo hablante.",
|
||||
|
||||
"settings.storage.title": "Almacenamiento",
|
||||
"settings.storage.location_label": "Ubicación",
|
||||
"settings.storage.location_hint": "Cambiar la ubicación de almacenamiento aún no es compatible — aquí es donde residen actualmente las reuniones, los modelos y la base de datos.",
|
||||
"settings.storage.retention_title": "Retención",
|
||||
"settings.storage.retention_hint": "Elimina automáticamente las reuniones más antiguas que superen un límite. Se comprueba una vez al inicio; vacío significa sin límite. Nunca afecta a una reunión que se está grabando.",
|
||||
"settings.storage.max_age": "Antigüedad máxima (días)",
|
||||
"settings.storage.max_size": "Tamaño máximo (GB)",
|
||||
"settings.storage.no_limit": "sin límite",
|
||||
"settings.storage.export_import_title": "Exportar e importar",
|
||||
"settings.storage.export_hint_1": "Mueve grabaciones entre ordenadores como carpetas de paquete portátiles (audio, transcripción, notas, resumen y un",
|
||||
"settings.storage.export_hint_2": "manifiesto). Exporta a cualquier carpeta — una unidad sincronizada, una memoria USB o el punto de montaje local de un destino de sincronización — y luego impórtalo en la otra máquina. Las reuniones importadas obtienen un id nuevo, por lo que reimportar nunca sobrescribe nada.",
|
||||
"settings.storage.export_dialog_title": "Exportar todas las reuniones a…",
|
||||
"settings.storage.import_dialog_title": "Importar un paquete (o una carpeta de paquetes)…",
|
||||
"settings.storage.exporting": "Exportando…",
|
||||
"settings.storage.export_all": "Exportar todas las reuniones…",
|
||||
"settings.storage.importing": "Importando…",
|
||||
"settings.storage.import_all": "Importar reuniones…",
|
||||
"settings.storage.exported_one": "{n} reunión exportada.",
|
||||
"settings.storage.exported_many": "{n} reuniones exportadas.",
|
||||
"settings.storage.imported_one": "{n} reunión importada.",
|
||||
"settings.storage.imported_many": "{n} reuniones importadas.",
|
||||
|
||||
"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…",
|
||||
"settings.calendar.password": "Contraseña",
|
||||
"settings.calendar.password_note": "raramente necesaria",
|
||||
"settings.calendar.optional": "opcional",
|
||||
"settings.calendar.import_range": "Rango de importación",
|
||||
"settings.calendar.range_30": "Últimos 30 días",
|
||||
"settings.calendar.range_90": "Últimos 90 días",
|
||||
"settings.calendar.range_180": "Últimos 6 meses",
|
||||
"settings.calendar.range_365": "Último año",
|
||||
"settings.calendar.range_all": "Todo el tiempo",
|
||||
"settings.calendar.range_hint": "Un buzón de larga duración puede contener años de entradas periódicas/festivas — reducir el rango mantiene el calendario importado en lo que realmente es relevante. Se aplica tanto a este botón de importar como a la reimportación automática al iniciar.",
|
||||
"settings.calendar.importing": "Importando…",
|
||||
"settings.calendar.import": "Importar",
|
||||
"settings.calendar.import_progress": "{processed}/{total} eventos",
|
||||
"settings.calendar.auto_reimport": "Reimportar este archivo automáticamente al iniciar",
|
||||
"settings.calendar.auto_reimport_hint": "Se ejecuta una vez al inicio, no con un temporizador — la reimportación se puede repetir sin problemas (los eventos existentes se cotejan y actualizan, no se duplican).",
|
||||
"settings.calendar.import_failed": "Error de importación: {error} — el archivo en sí no se toca; comprueba la ruta e inténtalo de nuevo.",
|
||||
"settings.calendar.cleanup_title": "Limpiar",
|
||||
"settings.calendar.cleanup_hint": "Los eventos ya vinculados a una reunión grabada se conservan siempre, sin importar la opción elegida abajo.",
|
||||
"settings.calendar.cleanup_30": "Más antiguos de 30 días",
|
||||
"settings.calendar.cleanup_90": "Más antiguos de 90 días",
|
||||
"settings.calendar.cleanup_180": "Más antiguos de 6 meses",
|
||||
"settings.calendar.cleanup_365": "Más antiguos de 1 año",
|
||||
"settings.calendar.cleanup_all": "Eliminar todo",
|
||||
"settings.calendar.cleaning": "Limpiando…",
|
||||
"settings.calendar.cleanup_btn": "Limpiar calendario",
|
||||
"settings.calendar.cleanup_result": "{deleted}{extra} eliminados.",
|
||||
"settings.calendar.cleanup_kept": ", {n} conservados (vinculados a una reunión)",
|
||||
"settings.calendar.cleanup_failed": "Error al limpiar: {error}",
|
||||
"settings.calendar.imported_events": "Eventos importados",
|
||||
"settings.calendar.no_events": "Aún no se han importado eventos.",
|
||||
"settings.calendar.search_title": "Buscar título",
|
||||
"settings.calendar.search_placeholder": "Nombre de la reunión…",
|
||||
"settings.calendar.date_label": "Fecha",
|
||||
"settings.calendar.events_count": "{shown} de {total} eventos",
|
||||
"settings.calendar.untitled": "(sin título)",
|
||||
|
||||
"settings.dialog_title": "Ajustes",
|
||||
"settings.close": "Cerrar",
|
||||
"settings.close_title": "Cerrar (Esc)",
|
||||
|
||||
"settings.sync.stub": "Modo de vista previa — el backend de sincronización aún no está implementado (Fase 9). Los cambios se mantienen solo en la interfaz.",
|
||||
"settings.sync.title": "Sincronización y subida",
|
||||
"settings.sync.enable_label": "Habilitar la subida de artefactos de reuniones a los destinos configurados",
|
||||
"settings.sync.enable_hint": "Desactivado por defecto. No se sube nada a menos que esto esté activado y haya un destino habilitado. Los destinos autoalojados mantienen los datos en tu propio servidor; las nubes de terceros están claramente etiquetadas.",
|
||||
"settings.sync.targets_title": "Destinos",
|
||||
"settings.sync.no_targets": "Aún no hay destinos. Añade uno abajo.",
|
||||
"settings.sync.third_party": "terceros",
|
||||
"settings.sync.your_server": "tu servidor",
|
||||
"settings.sync.edit": "Editar",
|
||||
"settings.sync.remove": "Eliminar",
|
||||
"settings.sync.edit_target": "Editar destino",
|
||||
"settings.sync.add_target": "Añadir un destino",
|
||||
"settings.sync.webdav_hint": "WebDAV cubre Nextcloud, ownCloud, Cloudreve, Seafile y Synology. (Seafile: habilita SeafDAV en el servidor.)",
|
||||
"settings.sync.name": "Nombre",
|
||||
"settings.sync.name_placeholder": "Nextcloud de casa",
|
||||
"settings.sync.provider": "Proveedor",
|
||||
"settings.sync.server_url": "URL del servidor",
|
||||
"settings.sync.server_url_note_1": "Solo la URL de tu servidor — WhispAssist añade",
|
||||
"settings.sync.server_url_note_2": "automáticamente.",
|
||||
"settings.sync.remote_folder": "Carpeta remota",
|
||||
"settings.sync.username": "Nombre de usuario",
|
||||
"settings.sync.app_password": "Contraseña de aplicación",
|
||||
"settings.sync.pw_keep": "déjalo en blanco para mantener la contraseña actual",
|
||||
"settings.sync.pw_store": "almacenada en el gestor de credenciales del sistema",
|
||||
"settings.sync.upload_legend": "Subir",
|
||||
"settings.sync.artifact_transcript": "transcripción",
|
||||
"settings.sync.artifact_notes": "notas",
|
||||
"settings.sync.artifact_summary": "resumen",
|
||||
"settings.sync.artifact_recording": "grabación (.wav, si se conserva)",
|
||||
"settings.sync.allow_plaintext": "Permitir http en texto plano para una dirección LAN (no recomendado)",
|
||||
"settings.sync.encrypt_before": "Cifrar antes de subir (el destino almacena solo texto cifrado)",
|
||||
"settings.sync.requires_vault": "— requiere una bóveda desbloqueada",
|
||||
"settings.sync.test_connection": "Probar conexión",
|
||||
"settings.sync.save_changes": "Guardar cambios",
|
||||
"settings.sync.add_target_btn": "Añadir destino",
|
||||
"settings.sync.cancel": "Cancelar",
|
||||
"settings.sync.third_party_banner": "{kind} es una nube de terceros — subir envía tus datos fuera de tu dispositivo a {kind}.",
|
||||
"settings.sync.link_account": "Vincular cuenta de {kind}…",
|
||||
"settings.sync.oauth_hint": "Abre un inicio de sesión OAuth (redirección loopback); el token se almacena en el gestor de credenciales del sistema.",
|
||||
"settings.sync.footnote": "Las credenciales nunca se escriben en los ajustes ni en la base de datos — solo en el gestor de credenciales del sistema. TLS es obligatorio para destinos que no sean de la LAN.",
|
||||
|
||||
"settings.ai.title": "Proveedor de resúmenes de IA",
|
||||
"settings.ai.intro_1": "Los resúmenes se ejecutan en un LLM local por defecto. Apunta esto a Ollama en este PC o en otra máquina de tu LAN (p. ej.",
|
||||
"settings.ai.intro_2": "— ambos cuentan como local, así que nada sale de tu red. Los proveedores alojados (Anthropic) son opcionales, están desactivados por defecto y envían la transcripción a un tercero en cuanto activas uno.",
|
||||
"settings.ai.provider": "Proveedor",
|
||||
"settings.ai.off": "Desactivado",
|
||||
"settings.ai.provider_ollama": "Ollama (local / LAN)",
|
||||
"settings.ai.provider_custom": "Personalizado (compatible con OpenAI)",
|
||||
"settings.ai.provider_anthropic": "Anthropic (Claude) — alojado, sale de este dispositivo",
|
||||
"settings.ai.endpoint": "Endpoint",
|
||||
"settings.ai.model": "Modelo",
|
||||
"settings.ai.api_key": "Clave API",
|
||||
"settings.ai.api_key_set_placeholder": "•••••••••••••••• (ya configurada — déjalo en blanco para mantenerla)",
|
||||
"settings.ai.show_api_key": "Mostrar clave API",
|
||||
"settings.ai.hide_api_key": "Ocultar clave API",
|
||||
"settings.ai.anthropic_banner": "Anthropic es un servicio alojado de terceros — la transcripción de esta reunión sale de tu dispositivo cuando generas un resumen.",
|
||||
"settings.ai.endpoint_banner": "Este endpoint no está en tu máquina ni en tu LAN — tu transcripción saldría de tu red.",
|
||||
"settings.ai.saving": "Guardando…",
|
||||
"settings.ai.save": "Guardar",
|
||||
"settings.ai.test_connection": "Probar conexión",
|
||||
"settings.ai.this_hosted_provider": "este proveedor alojado",
|
||||
"settings.ai.reachable": "Accesible",
|
||||
"settings.ai.unreachable": "Inaccesible",
|
||||
"settings.ai.model_count_one": "· {n} modelo",
|
||||
"settings.ai.model_count_many": "· {n} modelos",
|
||||
"settings.ai.on_lan": "· en tu máquina / LAN",
|
||||
"settings.ai.leaves_network": "· sale de tu red",
|
||||
"settings.ai.advanced_title": "Configuración avanzada de Ollama",
|
||||
"settings.ai.changed": "{n} modificados",
|
||||
"settings.ai.reset_all": "Restablecer todo a los valores predeterminados",
|
||||
"settings.ai.system_prompt": "Prompt del sistema",
|
||||
"settings.ai.system_prompt_placeholder": "p. ej. Eres un resumidor de reuniones conciso.",
|
||||
"settings.ai.system_prompt_hint": "Se añade antes del formato de salida requerido por WhispAssist, para que tus instrucciones no puedan romper el análisis del resumen/tareas.",
|
||||
"settings.ai.think": "Razonar",
|
||||
"settings.ai.think_help": "Esfuerzo de razonamiento (solo modelos de razonamiento).",
|
||||
"settings.ai.keep_alive": "Mantener activo",
|
||||
"settings.ai.keep_alive_help": "Cuánto tiempo permanece el modelo en la RAM. p. ej. 5m, 1h, 0 (descargar), -1 (siempre).",
|
||||
"settings.ai.runtime_hardware": "Ejecución y hardware",
|
||||
"settings.ai.rarely_needed": "— raramente necesario",
|
||||
"settings.ai.save_advanced": "Guardar avanzado",
|
||||
"settings.ai.saved": "Guardado",
|
||||
"settings.ai.turn_off": "Desactivar",
|
||||
"settings.ai.default_value": "Predeterminado {value}.",
|
||||
"settings.ai.reset_default": "Restablecer al valor predeterminado",
|
||||
"settings.ai.reset_field": "Restablecer {name}",
|
||||
|
||||
"settings.mcp.title": "Servidor MCP",
|
||||
"settings.mcp.banner_1": "Esto permite que tu propio agente de programación (Claude Code, Codex, Copilot, OpenCode, …) obtenga el contexto de la reunión en tu máquina local. Una vez conectado,",
|
||||
"settings.mcp.banner_agent": "ese agente",
|
||||
"settings.mcp.banner_2": "puede reenviar lo que lee a la nube de su propio proveedor de modelos — fuera del control de WhispAssist. WhispAssist en sí nunca envía estos datos a ningún sitio; el servidor solo escucha en este dispositivo (",
|
||||
"settings.mcp.banner_3": ") y cada lectura se registra abajo.",
|
||||
"settings.mcp.enable": "Habilitar el servidor MCP",
|
||||
"settings.mcp.enable_hint": "Desactivado por defecto. Solo loopback, protegido con token — nada es accesible desde la red.",
|
||||
"settings.mcp.transport": "Transporte",
|
||||
"settings.mcp.transport_http": "HTTP en streaming",
|
||||
"settings.mcp.transport_stdio": "stdio (el agente genera un proceso)",
|
||||
"settings.mcp.port": "Puerto",
|
||||
"settings.mcp.transport_change_hint": "Para cambiar el transporte/puerto, apaga primero el servidor y vuelve a encenderlo.",
|
||||
"settings.mcp.scope_title": "Alcance",
|
||||
"settings.mcp.expose": "Exponer",
|
||||
"settings.mcp.scope_none": "Ninguno — no se comparte nada",
|
||||
"settings.mcp.scope_selected": "Seleccionados — solo los resúmenes de funciones que hayas marcado como compartidos",
|
||||
"settings.mcp.scope_all": "Todo — reuniones, transcripciones, tareas y resúmenes compartidos",
|
||||
"settings.mcp.expose_recordings": "Permitir también reuniones con una grabación guardada (desactivado por defecto — la transcripción de una reunión grabada se retiene incluso en el alcance «Todo» hasta que esto esté activado)",
|
||||
"settings.mcp.token_title": "Nuevo token de autenticación — se muestra una vez, cópialo ahora",
|
||||
"settings.mcp.token_hint": "Esto no se volverá a mostrar. Se almacena en el gestor de credenciales del sistema; si lo pierdes, apaga el servidor y vuelve a encenderlo para generar uno nuevo.",
|
||||
"settings.mcp.copied": "Copiado",
|
||||
"settings.mcp.copy": "Copiar",
|
||||
"settings.mcp.endpoint": "Endpoint",
|
||||
"settings.mcp.endpoint_hint": "Apunta la configuración del cliente MCP de tu agente a {kind}, con el token de arriba como credencial bearer.",
|
||||
"settings.mcp.kind_command": "comando",
|
||||
"settings.mcp.kind_url": "URL",
|
||||
"settings.mcp.access_log": "Registro de accesos",
|
||||
"settings.mcp.access_log_hint": "Cada lectura de herramienta que hace un agente, permitida o denegada (FR-MCP-5).",
|
||||
"settings.mcp.no_reads": "Ningún agente ha leído nada aún.",
|
||||
"settings.mcp.log_meeting": "reunión {id}",
|
||||
"settings.mcp.refresh": "Actualizar",
|
||||
|
||||
"settings.privacy.title": "Privacidad",
|
||||
"settings.privacy.intro": "Lo que WhispAssist puede enviar realmente fuera de este dispositivo ahora mismo. Con todo desactivado (lo predeterminado), nada sale del dispositivo.",
|
||||
"settings.privacy.llm_endpoint": "Endpoint del LLM",
|
||||
"settings.privacy.off": "desactivado",
|
||||
"settings.privacy.local_only": "solo local",
|
||||
"settings.privacy.leaves_device": "sale de este dispositivo",
|
||||
"settings.privacy.sync_label": "Sincronización",
|
||||
"settings.privacy.enabled": "habilitada",
|
||||
"settings.privacy.mcp_label": "Servidor MCP",
|
||||
"settings.privacy.mcp_on": "activado · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "Entrante solo en loopback — no añade nada a la lista de salida de arriba. Un agente conectado aún puede reenviar lo que lee a su propio proveedor de modelos; consulta la pestaña del servidor MCP.",
|
||||
"settings.privacy.egress_title": "Lista de permitidos de salida",
|
||||
"settings.privacy.no_egress": "No hay hosts en la lista de permitidos — WA no realiza ninguna salida de contenido.",
|
||||
"settings.privacy.sync_targets_title": "Destinos de sincronización",
|
||||
"settings.privacy.third_party": "terceros",
|
||||
"settings.privacy.your_server": "tu servidor",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "sin TLS",
|
||||
"settings.privacy.refresh": "Actualizar",
|
||||
"settings.privacy.unavailable": "Autocomprobación de privacidad no disponible.",
|
||||
"settings.privacy.vault_title": "Bóveda de cifrado",
|
||||
"settings.privacy.vault_intro": "Cifra notas, transcripciones y resúmenes en reposo con una contraseña. (Los archivos de audio aún no se cifran.)",
|
||||
"settings.privacy.vault_password": "Contraseña de la bóveda",
|
||||
"settings.privacy.enable_vault": "Habilitar bóveda",
|
||||
"settings.privacy.vault_pw_hint": "Usa al menos 8 caracteres. Si la olvidas, el contenido cifrado no se podrá recuperar.",
|
||||
"settings.privacy.vault_locked_1": "La bóveda está ",
|
||||
"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",
|
||||
"settings.privacy.vault_unlocked_2": ". Las nuevas notas, transcripciones y resúmenes se cifran en reposo.",
|
||||
"settings.privacy.lock_now": "Bloquear ahora",
|
||||
"settings.privacy.change_password": "Cambiar contraseña",
|
||||
"settings.privacy.current_password": "Contraseña actual",
|
||||
"settings.privacy.new_password": "Nueva contraseña",
|
||||
|
||||
"settings.about.title": "Acerca de",
|
||||
"settings.about.tagline": "Un asistente de reuniones totalmente local, de código abierto y nativo de Windows.",
|
||||
"settings.about.build_commit": "Commit de compilación",
|
||||
|
||||
"consent.heading": "Antes de grabar",
|
||||
"consent.body": "Grabar conversaciones sin el consentimiento de los participantes puede ser ilegal en tu región. Consulta las leyes locales sobre grabación. Esto es una advertencia, no asesoramiento legal.",
|
||||
"consent.accept": "Entiendo — habilitar la grabación",
|
||||
"consent.cancel": "Cancelar",
|
||||
|
||||
"hosted.heading": "Antes de usar {provider}",
|
||||
"hosted.body_1": "Generar con",
|
||||
"hosted.body_2": "envía la transcripción de esta reunión a sus servidores — sale de este dispositivo y queda sujeta a su política de privacidad. WhispAssist no tiene control sobre el tratamiento de los datos una vez que salen de tu dispositivo. Esto está desactivado por defecto; lo estás eligiendo ahora.",
|
||||
"hosted.accept": "Entiendo — continuar",
|
||||
"hosted.cancel": "Cancelar",
|
||||
|
||||
"theme.group": "Tema",
|
||||
"theme.system": "Según el sistema",
|
||||
"theme.light": "Claro",
|
||||
"theme.dark": "Oscuro",
|
||||
|
||||
"import.dialog_label": "Añadir una reunión desde un archivo o URL",
|
||||
"import.title": "Añadir una reunión",
|
||||
"import.close": "Cerrar",
|
||||
"import.close_title": "Cerrar (Esc)",
|
||||
"import.body": "Importa una grabación existente — un archivo de audio/vídeo local o un enlace (YouTube, una página de streaming o una URL de medios directa). Se transcribe y se diariza igual que una grabación en directo.",
|
||||
"import.file_or_url": "Archivo o URL",
|
||||
"import.source_placeholder": "Pega una URL o busca un archivo…",
|
||||
"import.choose_file": "Elegir un archivo local",
|
||||
"import.browse": "Examinar…",
|
||||
"import.title_label": "Título",
|
||||
"import.optional": "opcional",
|
||||
"import.title_placeholder": "Por defecto, el nombre del archivo",
|
||||
"import.filter_av": "Audio / vídeo",
|
||||
"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}",
|
||||
|
||||
"levelmeter.system_mic": "Nivel de entrada del sistema y del micrófono",
|
||||
"levelmeter.system": "Nivel de entrada del sistema",
|
||||
|
||||
"app.tagline": "local · privado",
|
||||
"app.note_template": "Plantilla de notas",
|
||||
"app.no_template": "Sin plantilla",
|
||||
"app.add_meeting": "Añadir reunión",
|
||||
"app.add_meeting_title": "Añadir una reunión desde un archivo o URL",
|
||||
"app.record": "Grabar",
|
||||
"app.record_title": "Iniciar grabación (Ctrl+Mayús+R)",
|
||||
"app.stop": "Detener",
|
||||
"app.stop_title": "Detener grabación (Ctrl+Mayús+R)",
|
||||
"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",
|
||||
"app.not_saved": "no guardado",
|
||||
"app.reconnecting": "reconectando el dispositivo de audio…",
|
||||
"app.settings": "Ajustes",
|
||||
"app.settings_title": "Ajustes (Ctrl+,)",
|
||||
"app.discard_confirm": "¿Descartar esta grabación? Se eliminarán su audio y su transcripción.",
|
||||
"app.announce_started": "Grabación iniciada",
|
||||
"app.announce_paused": "Grabación en pausa",
|
||||
"app.announce_stopped": "Grabación detenida",
|
||||
"app.consent_dialog": "Consentimiento de grabación",
|
||||
"app.vault_dialog": "Desbloquear la bóveda de cifrado",
|
||||
"app.vault_title": "Desbloquear la bóveda de cifrado",
|
||||
"app.vault_desc": "Tus reuniones están cifradas en reposo. Introduce tu contraseña para leerlas.",
|
||||
"app.vault_password": "Contraseña de la bóveda",
|
||||
"app.vault_incorrect": "Contraseña incorrecta",
|
||||
"app.unlock": "Desbloquear",
|
||||
"app.continue_locked": "Continuar bloqueado",
|
||||
"app.show_meetings": "Mostrar lista de reuniones",
|
||||
"app.hide_meetings": "Ocultar lista de reuniones",
|
||||
"app.show_summary": "Mostrar panel de resumen",
|
||||
"app.hide_summary": "Ocultar panel de resumen",
|
||||
"app.resize_meetings": "Redimensionar lista de reuniones",
|
||||
"app.resize_summary": "Redimensionar panel de resumen",
|
||||
|
||||
"meetings.search_placeholder": "Buscar reuniones…",
|
||||
"meetings.search_aria": "Buscar reuniones",
|
||||
"meetings.filter_tag_aria": "Filtrar por etiqueta",
|
||||
"meetings.all_tags": "Todas las etiquetas",
|
||||
"meetings.from_date_aria": "Desde la fecha",
|
||||
"meetings.to_date_aria": "Hasta la fecha",
|
||||
"meetings.bulk_format_aria": "Formato de exportación masiva",
|
||||
"meetings.bulk_export": "Exportación masiva",
|
||||
"meetings.bulk_export_title": "Exportar todas las reuniones que coincidan con los filtros de etiqueta/fecha de arriba",
|
||||
"meetings.exporting": "Exportando…",
|
||||
"meetings.exported_one": "{n} reunión exportada.",
|
||||
"meetings.exported_many": "{n} reuniones exportadas.",
|
||||
"meetings.searching": "Buscando…",
|
||||
"meetings.loading": "Cargando reuniones…",
|
||||
"meetings.no_matches": "Sin coincidencias.",
|
||||
"meetings.empty": "Aún no hay reuniones — haz clic en Grabar arriba para empezar.",
|
||||
"meetings.status.recording": "grabando",
|
||||
"meetings.status.transcribing": "transcribiendo",
|
||||
"meetings.status.recovering": "recuperando",
|
||||
"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",
|
||||
"transcript.hide": "Ocultar transcripción",
|
||||
"transcript.empty": "No hay transcripción para esta reunión.",
|
||||
"transcript.will_appear": "La transcripción aparecerá aquí mientras grabas.",
|
||||
"transcript.play_from_here": "Reproducir desde aquí",
|
||||
"transcript.resize": "Redimensionar transcripción y notas",
|
||||
"transcript.reprocess_placeholder": "Volver a transcribir con…",
|
||||
"transcript.reprocess_lang_aria": "Idioma de reprocesamiento",
|
||||
"transcript.reprocess_keep_lang": "Mantener el idioma actual",
|
||||
"transcript.reprocess_go": "Ir",
|
||||
"transcript.retranscribing": "Volviendo a transcribir…",
|
||||
"transcript.has_note": "Tiene una nota",
|
||||
"transcript.note_placeholder": "Añade una nota para este momento…",
|
||||
"transcript.note_aria": "Nota para esta línea de la transcripción",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "Documento de Word",
|
||||
"transcript.filter_obsidian": "Nota de Obsidian",
|
||||
|
||||
"notes.heading": "Notas",
|
||||
"notes.show": "Mostrar notas",
|
||||
"notes.hide": "Ocultar notas",
|
||||
"notes.placeholder": "Notas…",
|
||||
"notes.live_placeholder": "Escribe notas mientras hablas…",
|
||||
"notes.toolbar_aria": "Formato de notas",
|
||||
"notes.bold": "Negrita",
|
||||
"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",
|
||||
"notes.preview": "Vista previa",
|
||||
"notes.export_md_title": "Exportar notas como .md",
|
||||
"notes.export_pdf_title": "Exportar notas como .pdf",
|
||||
"notes.export_docx_title": "Exportar notas como .docx",
|
||||
"notes.export_bundle_title": "Exportar audio + transcripción + notas a una carpeta",
|
||||
"notes.export_obsidian_title": "Exportar una nota de Obsidian (notas + resumen + transcripción, sin audio)",
|
||||
|
||||
"summary.recording_heading": "Grabación",
|
||||
"summary.loading_recording": "Cargando grabación…",
|
||||
"summary.sync_heading": "Sincronización",
|
||||
"summary.uploading": "Subiendo…",
|
||||
"summary.upload_now": "Subir ahora",
|
||||
"summary.no_uploads": "Aún no hay subidas para esta reunión.",
|
||||
"summary.retry": "reintentar",
|
||||
"summary.tags_heading": "Etiquetas",
|
||||
"summary.tags_select": "Selecciona una reunión para etiquetarla.",
|
||||
"summary.tag_add_placeholder": "Añadir etiqueta…",
|
||||
"summary.tag_first_placeholder": "proyecto, cliente, tema…",
|
||||
"summary.generating": "Generando…",
|
||||
"summary.generate_tags": "Generar etiquetas",
|
||||
"summary.saving": "Guardando…",
|
||||
"summary.save_tags": "Guardar etiquetas",
|
||||
"summary.summary_heading": "Resumen",
|
||||
"summary.ai_provider": "Proveedor de IA",
|
||||
"summary.provider_off": "Desactivado",
|
||||
"summary.provider_custom": "Personalizado",
|
||||
"summary.local": "local",
|
||||
"summary.leaves_device": "sale de este dispositivo",
|
||||
"summary.this_hosted_provider": "este proveedor alojado",
|
||||
"summary.select_generate": "Selecciona una reunión para generar un resumen.",
|
||||
"summary.decisions_heading": "Decisiones",
|
||||
"summary.regenerate": "Regenerar",
|
||||
"summary.generated_hint": "Generado localmente tras la reunión (requiere un proveedor de LLM local).",
|
||||
"summary.no_provider": "No hay proveedor de IA configurado — habilita uno en Ajustes.",
|
||||
"summary.generate_summary": "Generar resumen",
|
||||
"summary.briefs_heading": "Resúmenes de funciones",
|
||||
"summary.briefs_select": "Selecciona una reunión para crear o ver resúmenes de funciones.",
|
||||
"summary.briefs_desc": "Destila esta reunión en una especificación lista para agentes (requiere un proveedor de LLM local) — entrégala a un agente de programación o sírvela a través del servidor MCP una vez que esté activado.",
|
||||
"summary.brief_repo_placeholder": "Repo de destino (opcional), p. ej. acme/reporting-web",
|
||||
"summary.distilling": "Destilando…",
|
||||
"summary.create_brief": "Crear resumen de función",
|
||||
"summary.brief_mcp_title": "Disponible cuando el servidor MCP está activado",
|
||||
"summary.copied": "Copiado",
|
||||
"summary.copy_md": "Copiar como Markdown",
|
||||
"summary.copy_json": "Copiar como JSON",
|
||||
"summary.brief_problem": "Problema",
|
||||
"summary.brief_outcome": "Resultado deseado",
|
||||
"summary.brief_criteria": "Criterios de aceptación",
|
||||
"summary.brief_none": "Nada capturado.",
|
||||
"summary.brief_context": "Contexto",
|
||||
"summary.action_items_heading": "Tareas",
|
||||
"summary.ai_select": "Selecciona una reunión para ver sus tareas.",
|
||||
"summary.ai_empty": "Aún no hay ninguna — añade una abajo o genera un resumen para extraerlas automáticamente.",
|
||||
"summary.confirmed": "Confirmada",
|
||||
"summary.ai_text_placeholder": "Tarea…",
|
||||
"summary.ai_text_aria": "Texto de la tarea",
|
||||
"summary.owner": "Responsable",
|
||||
"summary.due_date": "Fecha límite",
|
||||
"summary.reminder_title": "Programar un recordatorio local para esta fecha límite",
|
||||
"summary.ai_delete_title": "Eliminar esta tarea",
|
||||
"summary.ai_delete_aria": "Eliminar tarea",
|
||||
"summary.add_action_item": "Añadir tarea",
|
||||
"summary.save_action_items": "Guardar tareas",
|
||||
"summary.calendar_heading": "Evento del calendario",
|
||||
"summary.cal_select": "Selecciona una reunión para vincularla a un evento del calendario.",
|
||||
"summary.event_search_placeholder": "Buscar título del evento…",
|
||||
"summary.linked_event": "Evento vinculado",
|
||||
"summary.change_event": "Cambiar evento…",
|
||||
"summary.link_event": "Vincular un evento…",
|
||||
"summary.untitled_event": "(sin título)",
|
||||
"summary.no_events_1": "Aún no se han importado eventos — importa uno",
|
||||
"summary.no_events_2": "desde Ajustes → Calendario.",
|
||||
"summary.no_events_match": "Ningún evento coincide con esta búsqueda/fecha — prueba a borrar una.",
|
||||
"summary.organizer_label": "Organizador: {name}",
|
||||
"summary.participants_heading": "Participantes",
|
||||
"summary.participants_hint": "Se rellena a partir del evento del calendario vinculado.",
|
||||
"summary.speakers_heading": "Hablantes",
|
||||
"summary.speakers_select": "Selecciona una reunión para nombrar a sus hablantes.",
|
||||
"summary.no_speakers": "Aún no se han detectado hablantes.",
|
||||
"summary.speaker_name_placeholder": "Nombre del hablante",
|
||||
"summary.save": "Guardar",
|
||||
"summary.cancel": "Cancelar",
|
||||
"summary.name_speaker": "Nombrar a este hablante…",
|
||||
"summary.add_new_name": "+ Añadir nuevo nombre…",
|
||||
|
||||
"settings.recording.title": "Grabación",
|
||||
"settings.recording.record_default": "Grabar las reuniones por defecto",
|
||||
"settings.recording.save_audio_as": "guardar el audio como",
|
||||
"settings.recording.default_hint": "Desactivado por defecto. Cuando está desactivado, el audio se usa solo para producir la transcripción y se elimina al finalizar la reunión. También puedes activar la grabación por reunión.",
|
||||
"settings.recording.consent_label": "Consentimiento: {status}.",
|
||||
"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.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."
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"nav.recording": "Grabación",
|
||||
"nav.hardware": "Hardware",
|
||||
"nav.storage": "Almacenamiento",
|
||||
"nav.calendar": "Calendario",
|
||||
"nav.sync": "Sincronización",
|
||||
"nav.ai": "IA",
|
||||
"nav.mcp": "Servidor MCP",
|
||||
"nav.privacy": "Privacidad",
|
||||
"nav.about": "Acerca de",
|
||||
"nav.language": "Idioma",
|
||||
|
||||
"settings.language.title": "Idioma",
|
||||
"settings.language.display": "Idioma de la interfaz",
|
||||
"settings.language.display_hint": "El idioma de la interfaz de la aplicación. Agregar un idioma es tan sencillo como colocar un solo archivo de traducción.",
|
||||
|
||||
"settings.transcription.title": "Idioma de transcripción",
|
||||
"settings.transcription.label": "Idioma",
|
||||
"settings.transcription.auto": "Detección automática",
|
||||
"settings.transcription.applies_hint": "Se aplica a la próxima grabación. El idioma realmente utilizado se muestra después en cada reunión.",
|
||||
"settings.transcription.english_only": "Solo inglés",
|
||||
"settings.transcription.model_english_only": "«{model}» es solo en inglés.",
|
||||
"settings.transcription.no_model": "Ningún modelo seleccionado.",
|
||||
"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",
|
||||
"settings.hardware.auto_backend": "Automático (el mejor disponible)",
|
||||
"settings.hardware.not_available": "no disponible",
|
||||
"settings.hardware.fallback_hint": "Recurre automáticamente (NPU → NVIDIA → AMD → Intel → CPU) si el backend elegido no se carga.",
|
||||
"settings.hardware.audio_devices": "Dispositivos de audio",
|
||||
"settings.hardware.recording_device": "Dispositivo de grabación",
|
||||
"settings.hardware.default_system_audio": "Audio del sistema predeterminado",
|
||||
"settings.hardware.recording_device_hint": "WhispAssist graba lo que reproduce este dispositivo (loopback) — el otro lado de la llamada. Elige una salida específica si no quieres que siga el valor predeterminado del sistema de Windows.",
|
||||
"settings.hardware.microphone": "Micrófono",
|
||||
"settings.hardware.mic_off": "Desactivado — no capturar mi micrófono",
|
||||
"settings.hardware.default_mic": "Micrófono predeterminado",
|
||||
"settings.hardware.mic_hint": "Agrega tu propia voz a la transcripción en vivo para que se capturen ambos lados de la reunión. Permanece en tu dispositivo — no se sube nada. Elige «Desactivado» para transcribir solo el audio del sistema de arriba.",
|
||||
"settings.hardware.npu_detected": "Acelerador NPU detectado",
|
||||
"settings.hardware.ready": "Listo",
|
||||
"settings.hardware.downloading": "Descargando…",
|
||||
"settings.hardware.package_needed": "Se necesita un paquete",
|
||||
"settings.hardware.npu_hint": "Tu NPU necesita un paquete de aceleración único (modelo Whisper ONNX{extra}). Se descarga en segundo plano; también puedes iniciarlo ahora.",
|
||||
"settings.hardware.plus_openvino": " + entorno de ejecución OpenVINO",
|
||||
"settings.hardware.download_npu": "Descargar paquete NPU",
|
||||
"settings.hardware.dml_title": "Aceleración de GPU (DirectML)",
|
||||
"settings.hardware.dml_hint": "Tu GPU puede acelerarse sin Vulkan mediante DirectML — un paquete único (modelo Whisper ONNX{extra}).",
|
||||
"settings.hardware.plus_directml": " + entorno de ejecución DirectML",
|
||||
"settings.hardware.download_dml": "Descargar paquete DirectML",
|
||||
"settings.hardware.detection_unavailable": "Detección de hardware no disponible.",
|
||||
"settings.hardware.low_overhead": "Preajuste de baja carga",
|
||||
"settings.hardware.low_overhead_note": "CPU + modelo más pequeño, para uso con batería/en segundo plano",
|
||||
"settings.hardware.models": "Modelos",
|
||||
"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",
|
||||
"settings.hardware.download": "Descargar",
|
||||
"settings.hardware.diarization": "Diarización de hablantes",
|
||||
"settings.hardware.diarization_hint": "Instala ambos modelos para que las grabaciones terminadas se separen por hablante (Hablante 1, Hablante 2, …) en lugar de una sola transcripción continua. Se ejecuta totalmente sin conexión, una vez por reunión al detenerse. Sin ellos, cada línea se atribuye a un solo hablante.",
|
||||
|
||||
"settings.storage.title": "Almacenamiento",
|
||||
"settings.storage.location_label": "Ubicación",
|
||||
"settings.storage.location_hint": "Cambiar la ubicación de almacenamiento aún no es compatible — aquí es donde residen actualmente las reuniones, los modelos y la base de datos.",
|
||||
"settings.storage.retention_title": "Retención",
|
||||
"settings.storage.retention_hint": "Elimina automáticamente las reuniones más antiguas que superen un límite. Se comprueba una vez al inicio; vacío significa sin límite. Nunca afecta a una reunión que se está grabando.",
|
||||
"settings.storage.max_age": "Antigüedad máxima (días)",
|
||||
"settings.storage.max_size": "Tamaño máximo (GB)",
|
||||
"settings.storage.no_limit": "sin límite",
|
||||
"settings.storage.export_import_title": "Exportar e importar",
|
||||
"settings.storage.export_hint_1": "Mueve grabaciones entre computadoras como carpetas de paquete portátiles (audio, transcripción, notas, resumen y un",
|
||||
"settings.storage.export_hint_2": "manifiesto). Exporta a cualquier carpeta — una unidad sincronizada, una memoria USB o el punto de montaje local de un destino de sincronización — y luego impórtalo en la otra computadora. Las reuniones importadas obtienen un id nuevo, por lo que reimportar nunca sobrescribe nada.",
|
||||
"settings.storage.export_dialog_title": "Exportar todas las reuniones a…",
|
||||
"settings.storage.import_dialog_title": "Importar un paquete (o una carpeta de paquetes)…",
|
||||
"settings.storage.exporting": "Exportando…",
|
||||
"settings.storage.export_all": "Exportar todas las reuniones…",
|
||||
"settings.storage.importing": "Importando…",
|
||||
"settings.storage.import_all": "Importar reuniones…",
|
||||
"settings.storage.exported_one": "{n} reunión exportada.",
|
||||
"settings.storage.exported_many": "{n} reuniones exportadas.",
|
||||
"settings.storage.imported_one": "{n} reunión importada.",
|
||||
"settings.storage.imported_many": "{n} reuniones importadas.",
|
||||
|
||||
"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…",
|
||||
"settings.calendar.password": "Contraseña",
|
||||
"settings.calendar.password_note": "raramente necesaria",
|
||||
"settings.calendar.optional": "opcional",
|
||||
"settings.calendar.import_range": "Rango de importación",
|
||||
"settings.calendar.range_30": "Últimos 30 días",
|
||||
"settings.calendar.range_90": "Últimos 90 días",
|
||||
"settings.calendar.range_180": "Últimos 6 meses",
|
||||
"settings.calendar.range_365": "Último año",
|
||||
"settings.calendar.range_all": "Todo el tiempo",
|
||||
"settings.calendar.range_hint": "Un buzón de larga duración puede contener años de entradas periódicas/festivas — reducir el rango mantiene el calendario importado en lo que realmente es relevante. Se aplica tanto a este botón de importar como a la reimportación automática al iniciar.",
|
||||
"settings.calendar.importing": "Importando…",
|
||||
"settings.calendar.import": "Importar",
|
||||
"settings.calendar.import_progress": "{processed}/{total} eventos",
|
||||
"settings.calendar.auto_reimport": "Reimportar este archivo automáticamente al iniciar",
|
||||
"settings.calendar.auto_reimport_hint": "Se ejecuta una vez al inicio, no con un temporizador — la reimportación se puede repetir sin problemas (los eventos existentes se cotejan y actualizan, no se duplican).",
|
||||
"settings.calendar.import_failed": "Error de importación: {error} — el archivo en sí no se toca; comprueba la ruta e inténtalo de nuevo.",
|
||||
"settings.calendar.cleanup_title": "Limpiar",
|
||||
"settings.calendar.cleanup_hint": "Los eventos ya vinculados a una reunión grabada se conservan siempre, sin importar la opción elegida abajo.",
|
||||
"settings.calendar.cleanup_30": "Más antiguos de 30 días",
|
||||
"settings.calendar.cleanup_90": "Más antiguos de 90 días",
|
||||
"settings.calendar.cleanup_180": "Más antiguos de 6 meses",
|
||||
"settings.calendar.cleanup_365": "Más antiguos de 1 año",
|
||||
"settings.calendar.cleanup_all": "Eliminar todo",
|
||||
"settings.calendar.cleaning": "Limpiando…",
|
||||
"settings.calendar.cleanup_btn": "Limpiar calendario",
|
||||
"settings.calendar.cleanup_result": "{deleted}{extra} eliminados.",
|
||||
"settings.calendar.cleanup_kept": ", {n} conservados (vinculados a una reunión)",
|
||||
"settings.calendar.cleanup_failed": "Error al limpiar: {error}",
|
||||
"settings.calendar.imported_events": "Eventos importados",
|
||||
"settings.calendar.no_events": "Aún no se han importado eventos.",
|
||||
"settings.calendar.search_title": "Buscar título",
|
||||
"settings.calendar.search_placeholder": "Nombre de la reunión…",
|
||||
"settings.calendar.date_label": "Fecha",
|
||||
"settings.calendar.events_count": "{shown} de {total} eventos",
|
||||
"settings.calendar.untitled": "(sin título)",
|
||||
|
||||
"settings.dialog_title": "Configuración",
|
||||
"settings.close": "Cerrar",
|
||||
"settings.close_title": "Cerrar (Esc)",
|
||||
|
||||
"settings.sync.stub": "Modo de vista previa — el backend de sincronización aún no está implementado (Fase 9). Los cambios se mantienen solo en la interfaz.",
|
||||
"settings.sync.title": "Sincronización y subida",
|
||||
"settings.sync.enable_label": "Habilitar la subida de artefactos de reuniones a los destinos configurados",
|
||||
"settings.sync.enable_hint": "Desactivado por defecto. No se sube nada a menos que esto esté activado y haya un destino habilitado. Los destinos autoalojados mantienen los datos en tu propio servidor; las nubes de terceros están claramente etiquetadas.",
|
||||
"settings.sync.targets_title": "Destinos",
|
||||
"settings.sync.no_targets": "Aún no hay destinos. Agrega uno abajo.",
|
||||
"settings.sync.third_party": "terceros",
|
||||
"settings.sync.your_server": "tu servidor",
|
||||
"settings.sync.edit": "Editar",
|
||||
"settings.sync.remove": "Quitar",
|
||||
"settings.sync.edit_target": "Editar destino",
|
||||
"settings.sync.add_target": "Agregar un destino",
|
||||
"settings.sync.webdav_hint": "WebDAV cubre Nextcloud, ownCloud, Cloudreve, Seafile y Synology. (Seafile: habilita SeafDAV en el servidor.)",
|
||||
"settings.sync.name": "Nombre",
|
||||
"settings.sync.name_placeholder": "Nextcloud de casa",
|
||||
"settings.sync.provider": "Proveedor",
|
||||
"settings.sync.server_url": "URL del servidor",
|
||||
"settings.sync.server_url_note_1": "Solo la URL de tu servidor — WhispAssist agrega",
|
||||
"settings.sync.server_url_note_2": "automáticamente.",
|
||||
"settings.sync.remote_folder": "Carpeta remota",
|
||||
"settings.sync.username": "Nombre de usuario",
|
||||
"settings.sync.app_password": "Contraseña de aplicación",
|
||||
"settings.sync.pw_keep": "déjalo en blanco para mantener la contraseña actual",
|
||||
"settings.sync.pw_store": "almacenada en el administrador de credenciales del sistema",
|
||||
"settings.sync.upload_legend": "Subir",
|
||||
"settings.sync.artifact_transcript": "transcripción",
|
||||
"settings.sync.artifact_notes": "notas",
|
||||
"settings.sync.artifact_summary": "resumen",
|
||||
"settings.sync.artifact_recording": "grabación (.wav, si se conserva)",
|
||||
"settings.sync.allow_plaintext": "Permitir http en texto plano para una dirección LAN (no recomendado)",
|
||||
"settings.sync.encrypt_before": "Cifrar antes de subir (el destino almacena solo texto cifrado)",
|
||||
"settings.sync.requires_vault": "— requiere una bóveda desbloqueada",
|
||||
"settings.sync.test_connection": "Probar conexión",
|
||||
"settings.sync.save_changes": "Guardar cambios",
|
||||
"settings.sync.add_target_btn": "Agregar destino",
|
||||
"settings.sync.cancel": "Cancelar",
|
||||
"settings.sync.third_party_banner": "{kind} es una nube de terceros — subir envía tus datos fuera de tu dispositivo a {kind}.",
|
||||
"settings.sync.link_account": "Vincular cuenta de {kind}…",
|
||||
"settings.sync.oauth_hint": "Abre un inicio de sesión OAuth (redirección loopback); el token se almacena en el administrador de credenciales del sistema.",
|
||||
"settings.sync.footnote": "Las credenciales nunca se escriben en la configuración ni en la base de datos — solo en el administrador de credenciales del sistema. TLS es obligatorio para destinos que no sean de la LAN.",
|
||||
|
||||
"settings.ai.title": "Proveedor de resúmenes de IA",
|
||||
"settings.ai.intro_1": "Los resúmenes se ejecutan en un LLM local por defecto. Apunta esto a Ollama en esta computadora o en otra máquina de tu LAN (p. ej.",
|
||||
"settings.ai.intro_2": "— ambos cuentan como local, así que nada sale de tu red. Los proveedores alojados (Anthropic) son opcionales, están desactivados por defecto y envían la transcripción a un tercero en cuanto activas uno.",
|
||||
"settings.ai.provider": "Proveedor",
|
||||
"settings.ai.off": "Desactivado",
|
||||
"settings.ai.provider_ollama": "Ollama (local / LAN)",
|
||||
"settings.ai.provider_custom": "Personalizado (compatible con OpenAI)",
|
||||
"settings.ai.provider_anthropic": "Anthropic (Claude) — alojado, sale de este dispositivo",
|
||||
"settings.ai.endpoint": "Endpoint",
|
||||
"settings.ai.model": "Modelo",
|
||||
"settings.ai.api_key": "Clave API",
|
||||
"settings.ai.api_key_set_placeholder": "•••••••••••••••• (ya configurada — déjalo en blanco para mantenerla)",
|
||||
"settings.ai.show_api_key": "Mostrar clave API",
|
||||
"settings.ai.hide_api_key": "Ocultar clave API",
|
||||
"settings.ai.anthropic_banner": "Anthropic es un servicio alojado de terceros — la transcripción de esta reunión sale de tu dispositivo cuando generas un resumen.",
|
||||
"settings.ai.endpoint_banner": "Este endpoint no está en tu máquina ni en tu LAN — tu transcripción saldría de tu red.",
|
||||
"settings.ai.saving": "Guardando…",
|
||||
"settings.ai.save": "Guardar",
|
||||
"settings.ai.test_connection": "Probar conexión",
|
||||
"settings.ai.this_hosted_provider": "este proveedor alojado",
|
||||
"settings.ai.reachable": "Accesible",
|
||||
"settings.ai.unreachable": "Inaccesible",
|
||||
"settings.ai.model_count_one": "· {n} modelo",
|
||||
"settings.ai.model_count_many": "· {n} modelos",
|
||||
"settings.ai.on_lan": "· en tu máquina / LAN",
|
||||
"settings.ai.leaves_network": "· sale de tu red",
|
||||
"settings.ai.advanced_title": "Configuración avanzada de Ollama",
|
||||
"settings.ai.changed": "{n} modificados",
|
||||
"settings.ai.reset_all": "Restablecer todo a los valores predeterminados",
|
||||
"settings.ai.system_prompt": "Prompt del sistema",
|
||||
"settings.ai.system_prompt_placeholder": "p. ej. Eres un resumidor de reuniones conciso.",
|
||||
"settings.ai.system_prompt_hint": "Se agrega antes del formato de salida requerido por WhispAssist, para que tus instrucciones no puedan romper el análisis del resumen/tareas.",
|
||||
"settings.ai.think": "Razonar",
|
||||
"settings.ai.think_help": "Esfuerzo de razonamiento (solo modelos de razonamiento).",
|
||||
"settings.ai.keep_alive": "Mantener activo",
|
||||
"settings.ai.keep_alive_help": "Cuánto tiempo permanece el modelo en la RAM. p. ej. 5m, 1h, 0 (descargar), -1 (siempre).",
|
||||
"settings.ai.runtime_hardware": "Ejecución y hardware",
|
||||
"settings.ai.rarely_needed": "— raramente necesario",
|
||||
"settings.ai.save_advanced": "Guardar avanzado",
|
||||
"settings.ai.saved": "Guardado",
|
||||
"settings.ai.turn_off": "Desactivar",
|
||||
"settings.ai.default_value": "Predeterminado {value}.",
|
||||
"settings.ai.reset_default": "Restablecer al valor predeterminado",
|
||||
"settings.ai.reset_field": "Restablecer {name}",
|
||||
|
||||
"settings.mcp.title": "Servidor MCP",
|
||||
"settings.mcp.banner_1": "Esto permite que tu propio agente de programación (Claude Code, Codex, Copilot, OpenCode, …) obtenga el contexto de la reunión en tu máquina local. Una vez conectado,",
|
||||
"settings.mcp.banner_agent": "ese agente",
|
||||
"settings.mcp.banner_2": "puede reenviar lo que lee a la nube de su propio proveedor de modelos — fuera del control de WhispAssist. WhispAssist en sí nunca envía estos datos a ningún lado; el servidor solo escucha en este dispositivo (",
|
||||
"settings.mcp.banner_3": ") y cada lectura se registra abajo.",
|
||||
"settings.mcp.enable": "Habilitar el servidor MCP",
|
||||
"settings.mcp.enable_hint": "Desactivado por defecto. Solo loopback, protegido con token — nada es accesible desde la red.",
|
||||
"settings.mcp.transport": "Transporte",
|
||||
"settings.mcp.transport_http": "HTTP en streaming",
|
||||
"settings.mcp.transport_stdio": "stdio (el agente genera un proceso)",
|
||||
"settings.mcp.port": "Puerto",
|
||||
"settings.mcp.transport_change_hint": "Para cambiar el transporte/puerto, apaga primero el servidor y vuelve a encenderlo.",
|
||||
"settings.mcp.scope_title": "Alcance",
|
||||
"settings.mcp.expose": "Exponer",
|
||||
"settings.mcp.scope_none": "Ninguno — no se comparte nada",
|
||||
"settings.mcp.scope_selected": "Seleccionados — solo los resúmenes de funciones que hayas marcado como compartidos",
|
||||
"settings.mcp.scope_all": "Todo — reuniones, transcripciones, tareas y resúmenes compartidos",
|
||||
"settings.mcp.expose_recordings": "Permitir también reuniones con una grabación guardada (desactivado por defecto — la transcripción de una reunión grabada se retiene incluso en el alcance «Todo» hasta que esto esté activado)",
|
||||
"settings.mcp.token_title": "Nuevo token de autenticación — se muestra una vez, cópialo ahora",
|
||||
"settings.mcp.token_hint": "Esto no se volverá a mostrar. Se almacena en el administrador de credenciales del sistema; si lo pierdes, apaga el servidor y vuelve a encenderlo para generar uno nuevo.",
|
||||
"settings.mcp.copied": "Copiado",
|
||||
"settings.mcp.copy": "Copiar",
|
||||
"settings.mcp.endpoint": "Endpoint",
|
||||
"settings.mcp.endpoint_hint": "Apunta la configuración del cliente MCP de tu agente a {kind}, con el token de arriba como credencial bearer.",
|
||||
"settings.mcp.kind_command": "comando",
|
||||
"settings.mcp.kind_url": "URL",
|
||||
"settings.mcp.access_log": "Registro de accesos",
|
||||
"settings.mcp.access_log_hint": "Cada lectura de herramienta que hace un agente, permitida o denegada (FR-MCP-5).",
|
||||
"settings.mcp.no_reads": "Ningún agente ha leído nada aún.",
|
||||
"settings.mcp.log_meeting": "reunión {id}",
|
||||
"settings.mcp.refresh": "Actualizar",
|
||||
|
||||
"settings.privacy.title": "Privacidad",
|
||||
"settings.privacy.intro": "Lo que WhispAssist puede enviar realmente fuera de este dispositivo ahora mismo. Con todo desactivado (lo predeterminado), nada sale del dispositivo.",
|
||||
"settings.privacy.llm_endpoint": "Endpoint del LLM",
|
||||
"settings.privacy.off": "desactivado",
|
||||
"settings.privacy.local_only": "solo local",
|
||||
"settings.privacy.leaves_device": "sale de este dispositivo",
|
||||
"settings.privacy.sync_label": "Sincronización",
|
||||
"settings.privacy.enabled": "habilitada",
|
||||
"settings.privacy.mcp_label": "Servidor MCP",
|
||||
"settings.privacy.mcp_on": "activado · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "Entrante solo en loopback — no agrega nada a la lista de salida de arriba. Un agente conectado aún puede reenviar lo que lee a su propio proveedor de modelos; consulta la pestaña del servidor MCP.",
|
||||
"settings.privacy.egress_title": "Lista de permitidos de salida",
|
||||
"settings.privacy.no_egress": "No hay hosts en la lista de permitidos — WA no realiza ninguna salida de contenido.",
|
||||
"settings.privacy.sync_targets_title": "Destinos de sincronización",
|
||||
"settings.privacy.third_party": "terceros",
|
||||
"settings.privacy.your_server": "tu servidor",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "sin TLS",
|
||||
"settings.privacy.refresh": "Actualizar",
|
||||
"settings.privacy.unavailable": "Autocomprobación de privacidad no disponible.",
|
||||
"settings.privacy.vault_title": "Bóveda de cifrado",
|
||||
"settings.privacy.vault_intro": "Cifra notas, transcripciones y resúmenes en reposo con una contraseña. (Los archivos de audio aún no se cifran.)",
|
||||
"settings.privacy.vault_password": "Contraseña de la bóveda",
|
||||
"settings.privacy.enable_vault": "Habilitar bóveda",
|
||||
"settings.privacy.vault_pw_hint": "Usa al menos 8 caracteres. Si la olvidas, el contenido cifrado no se podrá recuperar.",
|
||||
"settings.privacy.vault_locked_1": "La bóveda está ",
|
||||
"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",
|
||||
"settings.privacy.vault_unlocked_2": ". Las nuevas notas, transcripciones y resúmenes se cifran en reposo.",
|
||||
"settings.privacy.lock_now": "Bloquear ahora",
|
||||
"settings.privacy.change_password": "Cambiar contraseña",
|
||||
"settings.privacy.current_password": "Contraseña actual",
|
||||
"settings.privacy.new_password": "Nueva contraseña",
|
||||
|
||||
"settings.about.title": "Acerca de",
|
||||
"settings.about.tagline": "Un asistente de reuniones totalmente local, de código abierto y nativo de Windows.",
|
||||
"settings.about.build_commit": "Commit de compilación",
|
||||
|
||||
"consent.heading": "Antes de grabar",
|
||||
"consent.body": "Grabar conversaciones sin el consentimiento de los participantes puede ser ilegal en tu región. Consulta las leyes locales sobre grabación. Esto es una advertencia, no asesoría legal.",
|
||||
"consent.accept": "Entiendo — habilitar la grabación",
|
||||
"consent.cancel": "Cancelar",
|
||||
|
||||
"hosted.heading": "Antes de usar {provider}",
|
||||
"hosted.body_1": "Generar con",
|
||||
"hosted.body_2": "envía la transcripción de esta reunión a sus servidores — sale de este dispositivo y queda sujeta a su política de privacidad. WhispAssist no tiene control sobre el tratamiento de los datos una vez que salen de tu dispositivo. Esto está desactivado por defecto; lo estás eligiendo ahora.",
|
||||
"hosted.accept": "Entiendo — continuar",
|
||||
"hosted.cancel": "Cancelar",
|
||||
|
||||
"theme.group": "Tema",
|
||||
"theme.system": "Según el sistema",
|
||||
"theme.light": "Claro",
|
||||
"theme.dark": "Oscuro",
|
||||
|
||||
"import.dialog_label": "Agregar una reunión desde un archivo o URL",
|
||||
"import.title": "Agregar una reunión",
|
||||
"import.close": "Cerrar",
|
||||
"import.close_title": "Cerrar (Esc)",
|
||||
"import.body": "Importa una grabación existente — un archivo de audio/video local o un enlace (YouTube, una página de streaming o una URL de medios directa). Se transcribe y se diariza igual que una grabación en vivo.",
|
||||
"import.file_or_url": "Archivo o URL",
|
||||
"import.source_placeholder": "Pega una URL o busca un archivo…",
|
||||
"import.choose_file": "Elegir un archivo local",
|
||||
"import.browse": "Examinar…",
|
||||
"import.title_label": "Título",
|
||||
"import.optional": "opcional",
|
||||
"import.title_placeholder": "Por defecto, el nombre del archivo",
|
||||
"import.filter_av": "Audio / video",
|
||||
"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}",
|
||||
|
||||
"levelmeter.system_mic": "Nivel de entrada del sistema y del micrófono",
|
||||
"levelmeter.system": "Nivel de entrada del sistema",
|
||||
|
||||
"app.tagline": "local · privado",
|
||||
"app.note_template": "Plantilla de notas",
|
||||
"app.no_template": "Sin plantilla",
|
||||
"app.add_meeting": "Agregar reunión",
|
||||
"app.add_meeting_title": "Agregar una reunión desde un archivo o URL",
|
||||
"app.record": "Grabar",
|
||||
"app.record_title": "Iniciar grabación (Ctrl+Mayús+R)",
|
||||
"app.stop": "Detener",
|
||||
"app.stop_title": "Detener grabación (Ctrl+Mayús+R)",
|
||||
"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",
|
||||
"app.not_saved": "no guardado",
|
||||
"app.reconnecting": "reconectando el dispositivo de audio…",
|
||||
"app.settings": "Configuración",
|
||||
"app.settings_title": "Configuración (Ctrl+,)",
|
||||
"app.discard_confirm": "¿Descartar esta grabación? Se eliminarán su audio y su transcripción.",
|
||||
"app.announce_started": "Grabación iniciada",
|
||||
"app.announce_paused": "Grabación en pausa",
|
||||
"app.announce_stopped": "Grabación detenida",
|
||||
"app.consent_dialog": "Consentimiento de grabación",
|
||||
"app.vault_dialog": "Desbloquear la bóveda de cifrado",
|
||||
"app.vault_title": "Desbloquear la bóveda de cifrado",
|
||||
"app.vault_desc": "Tus reuniones están cifradas en reposo. Ingresa tu contraseña para leerlas.",
|
||||
"app.vault_password": "Contraseña de la bóveda",
|
||||
"app.vault_incorrect": "Contraseña incorrecta",
|
||||
"app.unlock": "Desbloquear",
|
||||
"app.continue_locked": "Continuar bloqueado",
|
||||
"app.show_meetings": "Mostrar lista de reuniones",
|
||||
"app.hide_meetings": "Ocultar lista de reuniones",
|
||||
"app.show_summary": "Mostrar panel de resumen",
|
||||
"app.hide_summary": "Ocultar panel de resumen",
|
||||
"app.resize_meetings": "Redimensionar lista de reuniones",
|
||||
"app.resize_summary": "Redimensionar panel de resumen",
|
||||
|
||||
"meetings.search_placeholder": "Buscar reuniones…",
|
||||
"meetings.search_aria": "Buscar reuniones",
|
||||
"meetings.filter_tag_aria": "Filtrar por etiqueta",
|
||||
"meetings.all_tags": "Todas las etiquetas",
|
||||
"meetings.from_date_aria": "Desde la fecha",
|
||||
"meetings.to_date_aria": "Hasta la fecha",
|
||||
"meetings.bulk_format_aria": "Formato de exportación masiva",
|
||||
"meetings.bulk_export": "Exportación masiva",
|
||||
"meetings.bulk_export_title": "Exportar todas las reuniones que coincidan con los filtros de etiqueta/fecha de arriba",
|
||||
"meetings.exporting": "Exportando…",
|
||||
"meetings.exported_one": "{n} reunión exportada.",
|
||||
"meetings.exported_many": "{n} reuniones exportadas.",
|
||||
"meetings.searching": "Buscando…",
|
||||
"meetings.loading": "Cargando reuniones…",
|
||||
"meetings.no_matches": "Sin coincidencias.",
|
||||
"meetings.empty": "Aún no hay reuniones — haz clic en Grabar arriba para empezar.",
|
||||
"meetings.status.recording": "grabando",
|
||||
"meetings.status.transcribing": "transcribiendo",
|
||||
"meetings.status.recovering": "recuperando",
|
||||
"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",
|
||||
"transcript.hide": "Ocultar transcripción",
|
||||
"transcript.empty": "No hay transcripción para esta reunión.",
|
||||
"transcript.will_appear": "La transcripción aparecerá aquí mientras grabas.",
|
||||
"transcript.play_from_here": "Reproducir desde aquí",
|
||||
"transcript.resize": "Redimensionar transcripción y notas",
|
||||
"transcript.reprocess_placeholder": "Volver a transcribir con…",
|
||||
"transcript.reprocess_lang_aria": "Idioma de reprocesamiento",
|
||||
"transcript.reprocess_keep_lang": "Mantener el idioma actual",
|
||||
"transcript.reprocess_go": "Ir",
|
||||
"transcript.retranscribing": "Volviendo a transcribir…",
|
||||
"transcript.has_note": "Tiene una nota",
|
||||
"transcript.note_placeholder": "Agrega una nota para este momento…",
|
||||
"transcript.note_aria": "Nota para esta línea de la transcripción",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "Documento de Word",
|
||||
"transcript.filter_obsidian": "Nota de Obsidian",
|
||||
|
||||
"notes.heading": "Notas",
|
||||
"notes.show": "Mostrar notas",
|
||||
"notes.hide": "Ocultar notas",
|
||||
"notes.placeholder": "Notas…",
|
||||
"notes.live_placeholder": "Escribe notas mientras hablas…",
|
||||
"notes.toolbar_aria": "Formato de notas",
|
||||
"notes.bold": "Negrita",
|
||||
"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",
|
||||
"notes.preview": "Vista previa",
|
||||
"notes.export_md_title": "Exportar notas como .md",
|
||||
"notes.export_pdf_title": "Exportar notas como .pdf",
|
||||
"notes.export_docx_title": "Exportar notas como .docx",
|
||||
"notes.export_bundle_title": "Exportar audio + transcripción + notas a una carpeta",
|
||||
"notes.export_obsidian_title": "Exportar una nota de Obsidian (notas + resumen + transcripción, sin audio)",
|
||||
|
||||
"summary.recording_heading": "Grabación",
|
||||
"summary.loading_recording": "Cargando grabación…",
|
||||
"summary.sync_heading": "Sincronización",
|
||||
"summary.uploading": "Subiendo…",
|
||||
"summary.upload_now": "Subir ahora",
|
||||
"summary.no_uploads": "Aún no hay subidas para esta reunión.",
|
||||
"summary.retry": "reintentar",
|
||||
"summary.tags_heading": "Etiquetas",
|
||||
"summary.tags_select": "Selecciona una reunión para etiquetarla.",
|
||||
"summary.tag_add_placeholder": "Agregar etiqueta…",
|
||||
"summary.tag_first_placeholder": "proyecto, cliente, tema…",
|
||||
"summary.generating": "Generando…",
|
||||
"summary.generate_tags": "Generar etiquetas",
|
||||
"summary.saving": "Guardando…",
|
||||
"summary.save_tags": "Guardar etiquetas",
|
||||
"summary.summary_heading": "Resumen",
|
||||
"summary.ai_provider": "Proveedor de IA",
|
||||
"summary.provider_off": "Desactivado",
|
||||
"summary.provider_custom": "Personalizado",
|
||||
"summary.local": "local",
|
||||
"summary.leaves_device": "sale de este dispositivo",
|
||||
"summary.this_hosted_provider": "este proveedor alojado",
|
||||
"summary.select_generate": "Selecciona una reunión para generar un resumen.",
|
||||
"summary.decisions_heading": "Decisiones",
|
||||
"summary.regenerate": "Regenerar",
|
||||
"summary.generated_hint": "Generado localmente tras la reunión (requiere un proveedor de LLM local).",
|
||||
"summary.no_provider": "No hay proveedor de IA configurado — habilita uno en Configuración.",
|
||||
"summary.generate_summary": "Generar resumen",
|
||||
"summary.briefs_heading": "Resúmenes de funciones",
|
||||
"summary.briefs_select": "Selecciona una reunión para crear o ver resúmenes de funciones.",
|
||||
"summary.briefs_desc": "Destila esta reunión en una especificación lista para agentes (requiere un proveedor de LLM local) — entrégala a un agente de programación o sírvela a través del servidor MCP una vez que esté activado.",
|
||||
"summary.brief_repo_placeholder": "Repo de destino (opcional), p. ej. acme/reporting-web",
|
||||
"summary.distilling": "Destilando…",
|
||||
"summary.create_brief": "Crear resumen de función",
|
||||
"summary.brief_mcp_title": "Disponible cuando el servidor MCP está activado",
|
||||
"summary.copied": "Copiado",
|
||||
"summary.copy_md": "Copiar como Markdown",
|
||||
"summary.copy_json": "Copiar como JSON",
|
||||
"summary.brief_problem": "Problema",
|
||||
"summary.brief_outcome": "Resultado deseado",
|
||||
"summary.brief_criteria": "Criterios de aceptación",
|
||||
"summary.brief_none": "Nada capturado.",
|
||||
"summary.brief_context": "Contexto",
|
||||
"summary.action_items_heading": "Tareas",
|
||||
"summary.ai_select": "Selecciona una reunión para ver sus tareas.",
|
||||
"summary.ai_empty": "Aún no hay ninguna — agrega una abajo o genera un resumen para extraerlas automáticamente.",
|
||||
"summary.confirmed": "Confirmada",
|
||||
"summary.ai_text_placeholder": "Tarea…",
|
||||
"summary.ai_text_aria": "Texto de la tarea",
|
||||
"summary.owner": "Responsable",
|
||||
"summary.due_date": "Fecha límite",
|
||||
"summary.reminder_title": "Programar un recordatorio local para esta fecha límite",
|
||||
"summary.ai_delete_title": "Eliminar esta tarea",
|
||||
"summary.ai_delete_aria": "Eliminar tarea",
|
||||
"summary.add_action_item": "Agregar tarea",
|
||||
"summary.save_action_items": "Guardar tareas",
|
||||
"summary.calendar_heading": "Evento del calendario",
|
||||
"summary.cal_select": "Selecciona una reunión para vincularla a un evento del calendario.",
|
||||
"summary.event_search_placeholder": "Buscar título del evento…",
|
||||
"summary.linked_event": "Evento vinculado",
|
||||
"summary.change_event": "Cambiar evento…",
|
||||
"summary.link_event": "Vincular un evento…",
|
||||
"summary.untitled_event": "(sin título)",
|
||||
"summary.no_events_1": "Aún no se han importado eventos — importa uno",
|
||||
"summary.no_events_2": "desde Configuración → Calendario.",
|
||||
"summary.no_events_match": "Ningún evento coincide con esta búsqueda/fecha — prueba a borrar una.",
|
||||
"summary.organizer_label": "Organizador: {name}",
|
||||
"summary.participants_heading": "Participantes",
|
||||
"summary.participants_hint": "Se rellena a partir del evento del calendario vinculado.",
|
||||
"summary.speakers_heading": "Hablantes",
|
||||
"summary.speakers_select": "Selecciona una reunión para nombrar a sus hablantes.",
|
||||
"summary.no_speakers": "Aún no se han detectado hablantes.",
|
||||
"summary.speaker_name_placeholder": "Nombre del hablante",
|
||||
"summary.save": "Guardar",
|
||||
"summary.cancel": "Cancelar",
|
||||
"summary.name_speaker": "Nombrar a este hablante…",
|
||||
"summary.add_new_name": "+ Agregar nuevo nombre…",
|
||||
|
||||
"settings.recording.title": "Grabación",
|
||||
"settings.recording.record_default": "Grabar las reuniones por defecto",
|
||||
"settings.recording.save_audio_as": "guardar el audio como",
|
||||
"settings.recording.default_hint": "Desactivado por defecto. Cuando está desactivado, el audio se usa solo para producir la transcripción y se elimina al finalizar la reunión. También puedes activar la grabación por reunión.",
|
||||
"settings.recording.consent_label": "Consentimiento: {status}.",
|
||||
"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.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."
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"nav.recording": "Tallennus",
|
||||
"nav.hardware": "Laitteisto",
|
||||
"nav.storage": "Tallennustila",
|
||||
"nav.calendar": "Kalenteri",
|
||||
"nav.sync": "Synkronointi",
|
||||
"nav.ai": "Tekoäly",
|
||||
"nav.mcp": "MCP-palvelin",
|
||||
"nav.privacy": "Yksityisyys",
|
||||
"nav.about": "Tietoja",
|
||||
"nav.language": "Kieli",
|
||||
|
||||
"settings.language.title": "Kieli",
|
||||
"settings.language.display": "Näyttökieli",
|
||||
"settings.language.display_hint": "Sovelluksen käyttöliittymän kieli. Kielen lisääminen on yhtä helppoa kuin yhden käännöstiedoston pudottaminen paikalleen.",
|
||||
|
||||
"settings.transcription.title": "Litterointikieli",
|
||||
"settings.transcription.label": "Kieli",
|
||||
"settings.transcription.auto": "Tunnista automaattisesti",
|
||||
"settings.transcription.applies_hint": "Koskee seuraavaa tallennusta. Todella käytetty kieli näytetään jälkeenpäin jokaisessa kokouksessa.",
|
||||
"settings.transcription.english_only": "Vain englanti",
|
||||
"settings.transcription.model_english_only": "”{model}” on vain englanniksi.",
|
||||
"settings.transcription.no_model": "Mallia ei ole valittu.",
|
||||
"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ä",
|
||||
"settings.hardware.auto_backend": "Automaattinen (paras saatavilla)",
|
||||
"settings.hardware.not_available": "ei saatavilla",
|
||||
"settings.hardware.fallback_hint": "Vaihtaa automaattisesti (NPU → NVIDIA → AMD → Intel → CPU), jos valittu taustajärjestelmä ei lataudu.",
|
||||
"settings.hardware.audio_devices": "Äänilaitteet",
|
||||
"settings.hardware.recording_device": "Tallennuslaite",
|
||||
"settings.hardware.default_system_audio": "Järjestelmän oletusääni",
|
||||
"settings.hardware.recording_device_hint": "WhispAssist tallentaa sen, mitä tämä laite toistaa (loopback) — puhelun toisen puolen. Valitse tietty lähtö, jos et halua sen seuraavan Windowsin järjestelmän oletusta.",
|
||||
"settings.hardware.microphone": "Mikrofoni",
|
||||
"settings.hardware.mic_off": "Pois — älä kaappaa mikrofoniani",
|
||||
"settings.hardware.default_mic": "Oletusmikrofoni",
|
||||
"settings.hardware.mic_hint": "Lisää oman äänesi suoraan litterointiin, jotta kokouksen molemmat puolet tallentuvat. Pysyy laitteellasi — mitään ei ladata verkkoon. Valitse ”Pois” litteroidaksesi vain yllä olevan järjestelmän äänen.",
|
||||
"settings.hardware.npu_detected": "NPU-kiihdytin havaittu",
|
||||
"settings.hardware.ready": "Valmis",
|
||||
"settings.hardware.downloading": "Ladataan…",
|
||||
"settings.hardware.package_needed": "Paketti tarvitaan",
|
||||
"settings.hardware.npu_hint": "NPU:si tarvitsee kertaluonteisen kiihdytyspaketin (Whisper ONNX -malli{extra}). Se ladataan taustalla; voit myös käynnistää sen nyt.",
|
||||
"settings.hardware.plus_openvino": " + OpenVINO-suoritusympäristö",
|
||||
"settings.hardware.download_npu": "Lataa NPU-paketti",
|
||||
"settings.hardware.dml_title": "GPU-kiihdytys (DirectML)",
|
||||
"settings.hardware.dml_hint": "GPU:tasi voidaan kiihdyttää ilman Vulkania DirectML:n kautta — kertaluonteinen paketti (Whisper ONNX -malli{extra}).",
|
||||
"settings.hardware.plus_directml": " + DirectML-suoritusympäristö",
|
||||
"settings.hardware.download_dml": "Lataa DirectML-paketti",
|
||||
"settings.hardware.detection_unavailable": "Laitteiston tunnistus ei saatavilla.",
|
||||
"settings.hardware.low_overhead": "Kevyt esiasetus",
|
||||
"settings.hardware.low_overhead_note": "CPU + pienin malli, akku-/taustakäyttöön",
|
||||
"settings.hardware.models": "Mallit",
|
||||
"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",
|
||||
"settings.hardware.download": "Lataa",
|
||||
"settings.hardware.diarization": "Puhujien erottelu",
|
||||
"settings.hardware.diarization_hint": "Asenna molemmat mallit, jotta valmiit tallennukset erotellaan puhujittain (Puhuja 1, Puhuja 2, …) yhden juoksevan litteroinnin sijaan. Toimii täysin offline-tilassa, kerran kokousta kohden sen pysähdyttyä. Ilman niitä jokainen rivi liitetään yhteen puhujaan.",
|
||||
|
||||
"settings.storage.title": "Tallennustila",
|
||||
"settings.storage.location_label": "Sijainti",
|
||||
"settings.storage.location_hint": "Tallennuspaikan vaihtamista ei vielä tueta — täällä kokoukset, mallit ja tietokanta tällä hetkellä sijaitsevat.",
|
||||
"settings.storage.retention_title": "Säilytys",
|
||||
"settings.storage.retention_hint": "Poistaa automaattisesti vanhimmat kokoukset rajan yli. Tarkistetaan kerran käynnistyksessä; tyhjä tarkoittaa ei rajaa. Ei koskaan koske kokoukseen, jota parhaillaan tallennetaan.",
|
||||
"settings.storage.max_age": "Enimmäisikä (päivää)",
|
||||
"settings.storage.max_size": "Enimmäiskoko (Gt)",
|
||||
"settings.storage.no_limit": "ei rajaa",
|
||||
"settings.storage.export_import_title": "Vienti ja tuonti",
|
||||
"settings.storage.export_hint_1": "Siirrä tallennuksia tietokoneiden välillä siirrettävinä pakettikansioina (ääni, litterointi, muistiinpanot, yhteenveto ja",
|
||||
"settings.storage.export_hint_2": "luettelotiedosto). Vie mihin tahansa kansioon — synkronoituun asemaan, USB-tikkuun tai synkronointikohteen paikalliseen liitospisteeseen — ja tuo se sitten toisella koneella. Tuodut kokoukset saavat uuden id:n, joten uudelleentuonti ei koskaan korvaa mitään.",
|
||||
"settings.storage.export_dialog_title": "Vie kaikki kokoukset kohteeseen…",
|
||||
"settings.storage.import_dialog_title": "Tuo paketti (tai pakettien kansio)…",
|
||||
"settings.storage.exporting": "Viedään…",
|
||||
"settings.storage.export_all": "Vie kaikki kokoukset…",
|
||||
"settings.storage.importing": "Tuodaan…",
|
||||
"settings.storage.import_all": "Tuo kokouksia…",
|
||||
"settings.storage.exported_one": "Vietiin {n} kokous.",
|
||||
"settings.storage.exported_many": "Vietiin {n} kokousta.",
|
||||
"settings.storage.imported_one": "Tuotiin {n} kokous.",
|
||||
"settings.storage.imported_many": "Tuotiin {n} kokousta.",
|
||||
|
||||
"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…",
|
||||
"settings.calendar.password": "Salasana",
|
||||
"settings.calendar.password_note": "harvoin tarpeen",
|
||||
"settings.calendar.optional": "valinnainen",
|
||||
"settings.calendar.import_range": "Tuontialue",
|
||||
"settings.calendar.range_30": "Viimeiset 30 päivää",
|
||||
"settings.calendar.range_90": "Viimeiset 90 päivää",
|
||||
"settings.calendar.range_180": "Viimeiset 6 kuukautta",
|
||||
"settings.calendar.range_365": "Viimeinen vuosi",
|
||||
"settings.calendar.range_all": "Koko ajalta",
|
||||
"settings.calendar.range_hint": "Pitkäikäinen postilaatikko voi sisältää vuosien toistuvia/pyhäpäivämerkintöjä — alueen kaventaminen pitää tuodun kalenterin siinä, mikä on todella olennaista. Koskee sekä tätä tuontipainiketta että automaattista uudelleentuontia käynnistyksessä.",
|
||||
"settings.calendar.importing": "Tuodaan…",
|
||||
"settings.calendar.import": "Tuo",
|
||||
"settings.calendar.import_progress": "{processed}/{total} tapahtumaa",
|
||||
"settings.calendar.auto_reimport": "Tuo tämä tiedosto automaattisesti uudelleen käynnistyksessä",
|
||||
"settings.calendar.auto_reimport_hint": "Suoritetaan kerran käynnistyksessä, ei ajastimella — uudelleentuonti on turvallista toistaa (olemassa olevat tapahtumat sovitetaan ja päivitetään, ei kahdenneta).",
|
||||
"settings.calendar.import_failed": "Tuonti epäonnistui: {error} — itse tiedostoon ei kosketa; tarkista polku ja yritä uudelleen.",
|
||||
"settings.calendar.cleanup_title": "Siivoa",
|
||||
"settings.calendar.cleanup_hint": "Tapahtumat, jotka on jo liitetty tallennettuun kokoukseen, säilytetään aina, riippumatta alla valitusta vaihtoehdosta.",
|
||||
"settings.calendar.cleanup_30": "Vanhemmat kuin 30 päivää",
|
||||
"settings.calendar.cleanup_90": "Vanhemmat kuin 90 päivää",
|
||||
"settings.calendar.cleanup_180": "Vanhemmat kuin 6 kuukautta",
|
||||
"settings.calendar.cleanup_365": "Vanhemmat kuin 1 vuosi",
|
||||
"settings.calendar.cleanup_all": "Poista kaikki",
|
||||
"settings.calendar.cleaning": "Siivotaan…",
|
||||
"settings.calendar.cleanup_btn": "Siivoa kalenteri",
|
||||
"settings.calendar.cleanup_result": "Poistettiin {deleted}{extra}.",
|
||||
"settings.calendar.cleanup_kept": ", säilytettiin {n} (liitetty kokoukseen)",
|
||||
"settings.calendar.cleanup_failed": "Siivous epäonnistui: {error}",
|
||||
"settings.calendar.imported_events": "Tuodut tapahtumat",
|
||||
"settings.calendar.no_events": "Tapahtumia ei ole vielä tuotu.",
|
||||
"settings.calendar.search_title": "Hae otsikkoa",
|
||||
"settings.calendar.search_placeholder": "Kokouksen nimi…",
|
||||
"settings.calendar.date_label": "Päivämäärä",
|
||||
"settings.calendar.events_count": "{shown}/{total} tapahtumaa",
|
||||
"settings.calendar.untitled": "(nimetön)",
|
||||
|
||||
"settings.dialog_title": "Asetukset",
|
||||
"settings.close": "Sulje",
|
||||
"settings.close_title": "Sulje (Esc)",
|
||||
|
||||
"settings.sync.stub": "Esikatselutila — synkronoinnin taustajärjestelmää ei ole vielä toteutettu (Vaihe 9). Muutokset säilytetään vain käyttöliittymässä.",
|
||||
"settings.sync.title": "Synkronointi ja lataus",
|
||||
"settings.sync.enable_label": "Ota käyttöön kokousartefaktien lataaminen määritettyihin kohteisiin",
|
||||
"settings.sync.enable_hint": "Oletuksena pois. Mitään ei ladata, ellei tämä ole päällä ja kohde otettu käyttöön. Itse isännöidyt kohteet pitävät tiedot omalla palvelimellasi; kolmannen osapuolen pilvet on selkeästi merkitty.",
|
||||
"settings.sync.targets_title": "Kohteet",
|
||||
"settings.sync.no_targets": "Ei vielä kohteita. Lisää yksi alta.",
|
||||
"settings.sync.third_party": "kolmas osapuoli",
|
||||
"settings.sync.your_server": "palvelimesi",
|
||||
"settings.sync.edit": "Muokkaa",
|
||||
"settings.sync.remove": "Poista",
|
||||
"settings.sync.edit_target": "Muokkaa kohdetta",
|
||||
"settings.sync.add_target": "Lisää kohde",
|
||||
"settings.sync.webdav_hint": "WebDAV kattaa Nextcloudin, ownCloudin, Cloudreven, Seafilen ja Synologyn. (Seafile: ota SeafDAV käyttöön palvelimen puolella.)",
|
||||
"settings.sync.name": "Nimi",
|
||||
"settings.sync.name_placeholder": "Koti-Nextcloud",
|
||||
"settings.sync.provider": "Palveluntarjoaja",
|
||||
"settings.sync.server_url": "Palvelimen URL",
|
||||
"settings.sync.server_url_note_1": "Vain palvelimesi URL — WhispAssist lisää",
|
||||
"settings.sync.server_url_note_2": "automaattisesti.",
|
||||
"settings.sync.remote_folder": "Etäkansio",
|
||||
"settings.sync.username": "Käyttäjänimi",
|
||||
"settings.sync.app_password": "Sovellussalasana",
|
||||
"settings.sync.pw_keep": "jätä tyhjäksi säilyttääksesi nykyisen salasanan",
|
||||
"settings.sync.pw_store": "tallennettu käyttöjärjestelmän tunnistetietovarastoon",
|
||||
"settings.sync.upload_legend": "Lataa",
|
||||
"settings.sync.artifact_transcript": "litterointi",
|
||||
"settings.sync.artifact_notes": "muistiinpanot",
|
||||
"settings.sync.artifact_summary": "yhteenveto",
|
||||
"settings.sync.artifact_recording": "tallennus (.wav, jos säilytetty)",
|
||||
"settings.sync.allow_plaintext": "Salli selkotekstinen http LAN-osoitteelle (ei suositella)",
|
||||
"settings.sync.encrypt_before": "Salaa ennen lataamista (kohde tallentaa vain salatekstin)",
|
||||
"settings.sync.requires_vault": "— vaatii avatun holvin",
|
||||
"settings.sync.test_connection": "Testaa yhteys",
|
||||
"settings.sync.save_changes": "Tallenna muutokset",
|
||||
"settings.sync.add_target_btn": "Lisää kohde",
|
||||
"settings.sync.cancel": "Peruuta",
|
||||
"settings.sync.third_party_banner": "{kind} on kolmannen osapuolen pilvi — lataaminen lähettää tietosi pois laitteeltasi kohteeseen {kind}.",
|
||||
"settings.sync.link_account": "Yhdistä {kind}-tili…",
|
||||
"settings.sync.oauth_hint": "Avaa OAuth-kirjautumisen (loopback-uudelleenohjaus); tunnus tallennetaan käyttöjärjestelmän tunnistetietovarastoon.",
|
||||
"settings.sync.footnote": "Tunnistetietoja ei koskaan kirjoiteta asetuksiin tai tietokantaan — vain käyttöjärjestelmän tunnistetietovarastoon. TLS vaaditaan LAN:in ulkopuolisille kohteille.",
|
||||
|
||||
"settings.ai.title": "Tekoälyn yhteenvetopalveluntarjoaja",
|
||||
"settings.ai.intro_1": "Yhteenvedot suoritetaan oletuksena paikallisella LLM:llä. Osoita tämä Ollamaan tällä tietokoneella tai toisella koneella LAN:issasi (esim.",
|
||||
"settings.ai.intro_2": "— molemmat lasketaan paikallisiksi, joten mikään ei poistu verkostasi. Isännöidyt palveluntarjoajat (Anthropic) ovat valinnaisia, oletuksena pois, ja lähettävät litteroinnin kolmannelle osapuolelle heti kun otat yhden käyttöön.",
|
||||
"settings.ai.provider": "Palveluntarjoaja",
|
||||
"settings.ai.off": "Pois",
|
||||
"settings.ai.provider_ollama": "Ollama (paikallinen / LAN)",
|
||||
"settings.ai.provider_custom": "Mukautettu (OpenAI-yhteensopiva)",
|
||||
"settings.ai.provider_anthropic": "Anthropic (Claude) — isännöity, poistuu tältä laitteelta",
|
||||
"settings.ai.endpoint": "Päätepiste",
|
||||
"settings.ai.model": "Malli",
|
||||
"settings.ai.api_key": "API-avain",
|
||||
"settings.ai.api_key_set_placeholder": "•••••••••••••••• (jo asetettu — jätä tyhjäksi säilyttääksesi sen)",
|
||||
"settings.ai.show_api_key": "Näytä API-avain",
|
||||
"settings.ai.hide_api_key": "Piilota API-avain",
|
||||
"settings.ai.anthropic_banner": "Anthropic on isännöity kolmannen osapuolen palvelu — tämän kokouksen litterointi poistuu laitteeltasi, kun luot yhteenvedon.",
|
||||
"settings.ai.endpoint_banner": "Tämä päätepiste ei ole koneellasi tai LAN:issasi — litterointisi poistuisi verkostasi.",
|
||||
"settings.ai.saving": "Tallennetaan…",
|
||||
"settings.ai.save": "Tallenna",
|
||||
"settings.ai.test_connection": "Testaa yhteys",
|
||||
"settings.ai.this_hosted_provider": "tämä isännöity palveluntarjoaja",
|
||||
"settings.ai.reachable": "Tavoitettavissa",
|
||||
"settings.ai.unreachable": "Ei tavoitettavissa",
|
||||
"settings.ai.model_count_one": "· {n} malli",
|
||||
"settings.ai.model_count_many": "· {n} mallia",
|
||||
"settings.ai.on_lan": "· koneellasi / LAN",
|
||||
"settings.ai.leaves_network": "· poistuu verkostasi",
|
||||
"settings.ai.advanced_title": "Ollaman lisäasetukset",
|
||||
"settings.ai.changed": "{n} muutettu",
|
||||
"settings.ai.reset_all": "Palauta kaikki oletuksiin",
|
||||
"settings.ai.system_prompt": "Järjestelmäkehote",
|
||||
"settings.ai.system_prompt_placeholder": "esim. Olet ytimekäs kokousten yhteenvetäjä.",
|
||||
"settings.ai.system_prompt_hint": "Lisätään ennen WhispAssistin vaadittua tulostemuotoa, jotta ohjeesi eivät voi rikkoa yhteenvedon/tehtävien jäsentämistä.",
|
||||
"settings.ai.think": "Päättele",
|
||||
"settings.ai.think_help": "Päättelypanostus (vain päättelymallit).",
|
||||
"settings.ai.keep_alive": "Pidä käynnissä",
|
||||
"settings.ai.keep_alive_help": "Kuinka kauan malli pysyy RAM:issa. esim. 5m, 1h, 0 (pura), -1 (ikuisesti).",
|
||||
"settings.ai.runtime_hardware": "Suoritus ja laitteisto",
|
||||
"settings.ai.rarely_needed": "— harvoin tarpeen",
|
||||
"settings.ai.save_advanced": "Tallenna lisäasetukset",
|
||||
"settings.ai.saved": "Tallennettu",
|
||||
"settings.ai.turn_off": "Kytke pois",
|
||||
"settings.ai.default_value": "Oletus {value}.",
|
||||
"settings.ai.reset_default": "Palauta oletukseen",
|
||||
"settings.ai.reset_field": "Palauta {name}",
|
||||
|
||||
"settings.mcp.title": "MCP-palvelin",
|
||||
"settings.mcp.banner_1": "Tämä antaa oman koodausagenttisi (Claude Code, Codex, Copilot, OpenCode, …) hakea kokouskontekstia paikallisella koneellasi. Kun yhteys on muodostettu,",
|
||||
"settings.mcp.banner_agent": "kyseinen agentti",
|
||||
"settings.mcp.banner_2": "voi välittää lukemansa oman mallipalveluntarjoajansa pilveen — WhispAssistin hallinnan ulkopuolelle. WhispAssist itse ei koskaan lähetä näitä tietoja minnekään; palvelin kuuntelee vain tällä laitteella (",
|
||||
"settings.mcp.banner_3": ") ja jokainen lukukerta kirjataan alle.",
|
||||
"settings.mcp.enable": "Ota MCP-palvelin käyttöön",
|
||||
"settings.mcp.enable_hint": "Oletuksena pois. Vain loopback, tunnussuojattu — mitään ei voi tavoittaa verkosta.",
|
||||
"settings.mcp.transport": "Kuljetus",
|
||||
"settings.mcp.transport_http": "Suoratoistava HTTP",
|
||||
"settings.mcp.transport_stdio": "stdio (agentti käynnistää prosessin)",
|
||||
"settings.mcp.port": "Portti",
|
||||
"settings.mcp.transport_change_hint": "Vaihtaaksesi kuljetuksen/portin, kytke palvelin ensin pois ja sitten takaisin päälle.",
|
||||
"settings.mcp.scope_title": "Laajuus",
|
||||
"settings.mcp.expose": "Julkaise",
|
||||
"settings.mcp.scope_none": "Ei mitään — mitään ei jaeta",
|
||||
"settings.mcp.scope_selected": "Valitut — vain ominaisuustiivistelmät, jotka olet merkinnyt jaetuiksi",
|
||||
"settings.mcp.scope_all": "Kaikki — kokoukset, litteroinnit, tehtävät ja jaetut tiivistelmät",
|
||||
"settings.mcp.expose_recordings": "Salli myös kokoukset, joilla on tallennettu tallennus (oletuksena pois — tallennetun kokouksen litterointi pidätetään jopa ”Kaikki”-laajuudessa, kunnes tämä on päällä)",
|
||||
"settings.mcp.token_title": "Uusi todennustunnus — näytetään kerran, kopioi se nyt",
|
||||
"settings.mcp.token_hint": "Tätä ei näytetä uudelleen. Se tallennetaan käyttöjärjestelmän tunnistetietovarastoon; jos hukkaat sen, kytke palvelin pois ja takaisin päälle luodaksesi uuden.",
|
||||
"settings.mcp.copied": "Kopioitu",
|
||||
"settings.mcp.copy": "Kopioi",
|
||||
"settings.mcp.endpoint": "Päätepiste",
|
||||
"settings.mcp.endpoint_hint": "Osoita agenttisi MCP-asiakasmääritys kohteeseen {kind}, käyttäen yllä olevaa tunnusta bearer-tunnistetietona.",
|
||||
"settings.mcp.kind_command": "komento",
|
||||
"settings.mcp.kind_url": "URL",
|
||||
"settings.mcp.access_log": "Käyttöloki",
|
||||
"settings.mcp.access_log_hint": "Jokainen työkaluluku, jonka agentti tekee, sallittu tai evätty (FR-MCP-5).",
|
||||
"settings.mcp.no_reads": "Yksikään agentti ei ole vielä lukenut mitään.",
|
||||
"settings.mcp.log_meeting": "kokous {id}",
|
||||
"settings.mcp.refresh": "Päivitä",
|
||||
|
||||
"settings.privacy.title": "Yksityisyys",
|
||||
"settings.privacy.intro": "Mitä WhispAssist saa tällä hetkellä todella lähettää pois tältä laitteelta. Kun kaikki on pois (oletus), mikään ei poistu laitteelta lainkaan.",
|
||||
"settings.privacy.llm_endpoint": "LLM-päätepiste",
|
||||
"settings.privacy.off": "pois",
|
||||
"settings.privacy.local_only": "vain paikallinen",
|
||||
"settings.privacy.leaves_device": "poistuu tältä laitteelta",
|
||||
"settings.privacy.sync_label": "Synkronointi",
|
||||
"settings.privacy.enabled": "käytössä",
|
||||
"settings.privacy.mcp_label": "MCP-palvelin",
|
||||
"settings.privacy.mcp_on": "päällä · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "Saapuva vain loopbackissa — ei lisää mitään yllä olevaan lähtevän luetteloon. Yhdistetty agentti voi silti välittää lukemansa omalle mallipalveluntarjoajalleen; katso MCP-palvelin-välilehti.",
|
||||
"settings.privacy.egress_title": "Lähtevän liikenteen sallittujen luettelo",
|
||||
"settings.privacy.no_egress": "Yhtään isäntää ei ole sallittu — WA ei tee mitään sisällön lähtevää siirtoa.",
|
||||
"settings.privacy.sync_targets_title": "Synkronointikohteet",
|
||||
"settings.privacy.third_party": "kolmas osapuoli",
|
||||
"settings.privacy.your_server": "palvelimesi",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "ei TLS:ää",
|
||||
"settings.privacy.refresh": "Päivitä",
|
||||
"settings.privacy.unavailable": "Yksityisyyden itsetarkistus ei saatavilla.",
|
||||
"settings.privacy.vault_title": "Salausholvi",
|
||||
"settings.privacy.vault_intro": "Salaa muistiinpanot, litteroinnit ja yhteenvedot levossa salasanalla. (Äänitiedostoja ei vielä salata.)",
|
||||
"settings.privacy.vault_password": "Holvin salasana",
|
||||
"settings.privacy.enable_vault": "Ota holvi käyttöön",
|
||||
"settings.privacy.vault_pw_hint": "Käytä vähintään 8 merkkiä. Jos unohdat sen, salattua sisältöä ei voi palauttaa.",
|
||||
"settings.privacy.vault_locked_1": "Holvi on ",
|
||||
"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",
|
||||
"settings.privacy.vault_unlocked_2": ". Uudet muistiinpanot, litteroinnit ja yhteenvedot salataan levossa.",
|
||||
"settings.privacy.lock_now": "Lukitse nyt",
|
||||
"settings.privacy.change_password": "Vaihda salasana",
|
||||
"settings.privacy.current_password": "Nykyinen salasana",
|
||||
"settings.privacy.new_password": "Uusi salasana",
|
||||
|
||||
"settings.about.title": "Tietoja",
|
||||
"settings.about.tagline": "Täysin paikallinen, avoimen lähdekoodin, Windows-natiivi kokousavustaja.",
|
||||
"settings.about.build_commit": "Build-commit",
|
||||
|
||||
"consent.heading": "Ennen kuin tallennat",
|
||||
"consent.body": "Keskustelujen tallentaminen ilman osallistujien suostumusta voi olla laitonta alueellasi. Tarkista paikalliset tallennuslait. Tämä on varoitus, ei oikeudellinen neuvo.",
|
||||
"consent.accept": "Ymmärrän — ota tallennus käyttöön",
|
||||
"consent.cancel": "Peruuta",
|
||||
|
||||
"hosted.heading": "Ennen kuin käytät palvelua {provider}",
|
||||
"hosted.body_1": "Luominen palvelulla",
|
||||
"hosted.body_2": "lähettää tämän kokouksen litteroinnin heidän palvelimilleen — se poistuu tältä laitteelta ja kuuluu heidän tietosuojakäytäntönsä piiriin. WhispAssistilla ei ole hallintaa tietojen käsittelyyn, kun ne poistuvat laitteeltasi. Tämä on oletuksena pois; valitset sen nyt.",
|
||||
"hosted.accept": "Ymmärrän — jatka",
|
||||
"hosted.cancel": "Peruuta",
|
||||
|
||||
"theme.group": "Teema",
|
||||
"theme.system": "Seuraa järjestelmää",
|
||||
"theme.light": "Vaalea",
|
||||
"theme.dark": "Tumma",
|
||||
|
||||
"import.dialog_label": "Lisää kokous tiedostosta tai URL-osoitteesta",
|
||||
"import.title": "Lisää kokous",
|
||||
"import.close": "Sulje",
|
||||
"import.close_title": "Sulje (Esc)",
|
||||
"import.body": "Tuo olemassa oleva tallennus — paikallinen ääni-/videotiedosto tai linkki (YouTube, suoratoistosivu tai suora media-URL). Se litteroidaan ja eritellään puhujittain aivan kuten suora tallennus.",
|
||||
"import.file_or_url": "Tiedosto tai URL",
|
||||
"import.source_placeholder": "Liitä URL tai selaa tiedostoa…",
|
||||
"import.choose_file": "Valitse paikallinen tiedosto",
|
||||
"import.browse": "Selaa…",
|
||||
"import.title_label": "Otsikko",
|
||||
"import.optional": "valinnainen",
|
||||
"import.title_placeholder": "Oletuksena tiedoston nimi",
|
||||
"import.filter_av": "Ääni / video",
|
||||
"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}",
|
||||
|
||||
"levelmeter.system_mic": "Järjestelmän ja mikrofonin tulotaso",
|
||||
"levelmeter.system": "Järjestelmän tulotaso",
|
||||
|
||||
"app.tagline": "paikallinen · yksityinen",
|
||||
"app.note_template": "Muistiinpanomalli",
|
||||
"app.no_template": "Ei mallia",
|
||||
"app.add_meeting": "Lisää kokous",
|
||||
"app.add_meeting_title": "Lisää kokous tiedostosta tai URL-osoitteesta",
|
||||
"app.record": "Tallenna",
|
||||
"app.record_title": "Aloita tallennus (Ctrl+Shift+R)",
|
||||
"app.stop": "Pysäytä",
|
||||
"app.stop_title": "Pysäytä tallennus (Ctrl+Shift+R)",
|
||||
"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",
|
||||
"app.not_saved": "ei tallennettu",
|
||||
"app.reconnecting": "yhdistetään äänilaitetta uudelleen…",
|
||||
"app.settings": "Asetukset",
|
||||
"app.settings_title": "Asetukset (Ctrl+,)",
|
||||
"app.discard_confirm": "Hylätäänkö tämä tallennus? Sen ääni ja litterointi poistetaan.",
|
||||
"app.announce_started": "Tallennus aloitettu",
|
||||
"app.announce_paused": "Tallennus keskeytetty",
|
||||
"app.announce_stopped": "Tallennus pysäytetty",
|
||||
"app.consent_dialog": "Tallennussuostumus",
|
||||
"app.vault_dialog": "Avaa salausholvin lukitus",
|
||||
"app.vault_title": "Avaa salausholvin lukitus",
|
||||
"app.vault_desc": "Kokouksesi on salattu levossa. Syötä salasanasi lukeaksesi ne.",
|
||||
"app.vault_password": "Holvin salasana",
|
||||
"app.vault_incorrect": "Väärä salasana",
|
||||
"app.unlock": "Avaa lukitus",
|
||||
"app.continue_locked": "Jatka lukittuna",
|
||||
"app.show_meetings": "Näytä kokousluettelo",
|
||||
"app.hide_meetings": "Piilota kokousluettelo",
|
||||
"app.show_summary": "Näytä yhteenvetopaneeli",
|
||||
"app.hide_summary": "Piilota yhteenvetopaneeli",
|
||||
"app.resize_meetings": "Muuta kokousluettelon kokoa",
|
||||
"app.resize_summary": "Muuta yhteenvetopaneelin kokoa",
|
||||
|
||||
"meetings.search_placeholder": "Hae kokouksia…",
|
||||
"meetings.search_aria": "Hae kokouksia",
|
||||
"meetings.filter_tag_aria": "Suodata tunnisteen mukaan",
|
||||
"meetings.all_tags": "Kaikki tunnisteet",
|
||||
"meetings.from_date_aria": "Alkupäivä",
|
||||
"meetings.to_date_aria": "Loppupäivä",
|
||||
"meetings.bulk_format_aria": "Joukkoviennin muoto",
|
||||
"meetings.bulk_export": "Joukkovienti",
|
||||
"meetings.bulk_export_title": "Vie kaikki kokoukset, jotka vastaavat yllä olevia tunniste-/päivämääräsuodattimia",
|
||||
"meetings.exporting": "Viedään…",
|
||||
"meetings.exported_one": "Vietiin {n} kokous.",
|
||||
"meetings.exported_many": "Vietiin {n} kokousta.",
|
||||
"meetings.searching": "Haetaan…",
|
||||
"meetings.loading": "Ladataan kokouksia…",
|
||||
"meetings.no_matches": "Ei osumia.",
|
||||
"meetings.empty": "Ei vielä kokouksia — napsauta yllä Tallenna aloittaaksesi.",
|
||||
"meetings.status.recording": "tallennetaan",
|
||||
"meetings.status.transcribing": "litteroidaan",
|
||||
"meetings.status.recovering": "palautetaan",
|
||||
"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",
|
||||
"transcript.hide": "Piilota litterointi",
|
||||
"transcript.empty": "Tälle kokoukselle ei ole litterointia.",
|
||||
"transcript.will_appear": "Litterointi ilmestyy tähän tallennuksen aikana.",
|
||||
"transcript.play_from_here": "Toista tästä",
|
||||
"transcript.resize": "Muuta litteroinnin ja muistiinpanojen kokoa",
|
||||
"transcript.reprocess_placeholder": "Litteroi uudelleen mallilla…",
|
||||
"transcript.reprocess_lang_aria": "Uudelleenkäsittelyn kieli",
|
||||
"transcript.reprocess_keep_lang": "Säilytä nykyinen kieli",
|
||||
"transcript.reprocess_go": "Suorita",
|
||||
"transcript.retranscribing": "Litteroidaan uudelleen…",
|
||||
"transcript.has_note": "On muistiinpano",
|
||||
"transcript.note_placeholder": "Lisää muistiinpano tälle hetkelle…",
|
||||
"transcript.note_aria": "Muistiinpano tälle litterointiriville",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "Word-asiakirja",
|
||||
"transcript.filter_obsidian": "Obsidian-muistiinpano",
|
||||
|
||||
"notes.heading": "Muistiinpanot",
|
||||
"notes.show": "Näytä muistiinpanot",
|
||||
"notes.hide": "Piilota muistiinpanot",
|
||||
"notes.placeholder": "Muistiinpanot…",
|
||||
"notes.live_placeholder": "Kirjoita muistiinpanoja puhuessasi…",
|
||||
"notes.toolbar_aria": "Muistiinpanojen muotoilu",
|
||||
"notes.bold": "Lihavointi",
|
||||
"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",
|
||||
"notes.preview": "Esikatselu",
|
||||
"notes.export_md_title": "Vie muistiinpanot muodossa .md",
|
||||
"notes.export_pdf_title": "Vie muistiinpanot muodossa .pdf",
|
||||
"notes.export_docx_title": "Vie muistiinpanot muodossa .docx",
|
||||
"notes.export_bundle_title": "Vie ääni + litterointi + muistiinpanot kansioon",
|
||||
"notes.export_obsidian_title": "Vie yksi Obsidian-muistiinpano (muistiinpanot + yhteenveto + litterointi, ei ääntä)",
|
||||
|
||||
"summary.recording_heading": "Tallennus",
|
||||
"summary.loading_recording": "Ladataan tallennusta…",
|
||||
"summary.sync_heading": "Synkronointi",
|
||||
"summary.uploading": "Ladataan…",
|
||||
"summary.upload_now": "Lataa nyt",
|
||||
"summary.no_uploads": "Tälle kokoukselle ei ole vielä latauksia.",
|
||||
"summary.retry": "yritä uudelleen",
|
||||
"summary.tags_heading": "Tunnisteet",
|
||||
"summary.tags_select": "Valitse kokous merkitäksesi sen tunnisteilla.",
|
||||
"summary.tag_add_placeholder": "Lisää tunniste…",
|
||||
"summary.tag_first_placeholder": "projekti, asiakas, aihe…",
|
||||
"summary.generating": "Luodaan…",
|
||||
"summary.generate_tags": "Luo tunnisteet",
|
||||
"summary.saving": "Tallennetaan…",
|
||||
"summary.save_tags": "Tallenna tunnisteet",
|
||||
"summary.summary_heading": "Yhteenveto",
|
||||
"summary.ai_provider": "Tekoälyn palveluntarjoaja",
|
||||
"summary.provider_off": "Pois",
|
||||
"summary.provider_custom": "Mukautettu",
|
||||
"summary.local": "paikallinen",
|
||||
"summary.leaves_device": "poistuu tältä laitteelta",
|
||||
"summary.this_hosted_provider": "tämä isännöity palveluntarjoaja",
|
||||
"summary.select_generate": "Valitse kokous luodaksesi yhteenvedon.",
|
||||
"summary.decisions_heading": "Päätökset",
|
||||
"summary.regenerate": "Luo uudelleen",
|
||||
"summary.generated_hint": "Luotu paikallisesti kokouksen jälkeen (vaatii paikallisen LLM-palveluntarjoajan).",
|
||||
"summary.no_provider": "Tekoälyn palveluntarjoajaa ei ole määritetty — ota yksi käyttöön Asetuksissa.",
|
||||
"summary.generate_summary": "Luo yhteenveto",
|
||||
"summary.briefs_heading": "Ominaisuustiivistelmät",
|
||||
"summary.briefs_select": "Valitse kokous luodaksesi tai nähdäksesi ominaisuustiivistelmiä.",
|
||||
"summary.briefs_desc": "Tiivistää tämän kokouksen agentille valmiiksi määrittelyksi (vaatii paikallisen LLM-palveluntarjoajan) — anna se koodausagentille tai tarjoa se MCP-palvelimen kautta, kun se on päällä.",
|
||||
"summary.brief_repo_placeholder": "Kohderepo (valinnainen), esim. acme/reporting-web",
|
||||
"summary.distilling": "Tiivistetään…",
|
||||
"summary.create_brief": "Luo ominaisuustiivistelmä",
|
||||
"summary.brief_mcp_title": "Saatavilla, kun MCP-palvelin on päällä",
|
||||
"summary.copied": "Kopioitu",
|
||||
"summary.copy_md": "Kopioi Markdownina",
|
||||
"summary.copy_json": "Kopioi JSONina",
|
||||
"summary.brief_problem": "Ongelma",
|
||||
"summary.brief_outcome": "Haluttu lopputulos",
|
||||
"summary.brief_criteria": "Hyväksymiskriteerit",
|
||||
"summary.brief_none": "Ei mitään tallennettu.",
|
||||
"summary.brief_context": "Konteksti",
|
||||
"summary.action_items_heading": "Tehtävät",
|
||||
"summary.ai_select": "Valitse kokous nähdäksesi sen tehtävät.",
|
||||
"summary.ai_empty": "Ei vielä yhtään — lisää yksi alle tai luo yhteenveto poimiaksesi ne automaattisesti.",
|
||||
"summary.confirmed": "Vahvistettu",
|
||||
"summary.ai_text_placeholder": "Tehtävä…",
|
||||
"summary.ai_text_aria": "Tehtävän teksti",
|
||||
"summary.owner": "Vastuuhenkilö",
|
||||
"summary.due_date": "Määräpäivä",
|
||||
"summary.reminder_title": "Ajasta paikallinen muistutus tälle määräpäivälle",
|
||||
"summary.ai_delete_title": "Poista tämä tehtävä",
|
||||
"summary.ai_delete_aria": "Poista tehtävä",
|
||||
"summary.add_action_item": "Lisää tehtävä",
|
||||
"summary.save_action_items": "Tallenna tehtävät",
|
||||
"summary.calendar_heading": "Kalenteritapahtuma",
|
||||
"summary.cal_select": "Valitse kokous liittääksesi sen kalenteritapahtumaan.",
|
||||
"summary.event_search_placeholder": "Hae tapahtuman otsikkoa…",
|
||||
"summary.linked_event": "Linkitetty tapahtuma",
|
||||
"summary.change_event": "Vaihda tapahtuma…",
|
||||
"summary.link_event": "Linkitä tapahtuma…",
|
||||
"summary.untitled_event": "(nimetön)",
|
||||
"summary.no_events_1": "Tapahtumia ei ole vielä tuotu — tuo",
|
||||
"summary.no_events_2": "kohdasta Asetukset → Kalenteri.",
|
||||
"summary.no_events_match": "Yksikään tapahtuma ei vastaa tätä hakua/päivämäärää — yritä tyhjentää yksi.",
|
||||
"summary.organizer_label": "Järjestäjä: {name}",
|
||||
"summary.participants_heading": "Osallistujat",
|
||||
"summary.participants_hint": "Täytetään linkitetystä kalenteritapahtumasta.",
|
||||
"summary.speakers_heading": "Puhujat",
|
||||
"summary.speakers_select": "Valitse kokous nimetäksesi sen puhujat.",
|
||||
"summary.no_speakers": "Puhujia ei ole vielä havaittu.",
|
||||
"summary.speaker_name_placeholder": "Puhujan nimi",
|
||||
"summary.save": "Tallenna",
|
||||
"summary.cancel": "Peruuta",
|
||||
"summary.name_speaker": "Nimeä tämä puhuja…",
|
||||
"summary.add_new_name": "+ Lisää uusi nimi…",
|
||||
|
||||
"settings.recording.title": "Tallennus",
|
||||
"settings.recording.record_default": "Tallenna kokoukset oletuksena",
|
||||
"settings.recording.save_audio_as": "tallenna ääni muodossa",
|
||||
"settings.recording.default_hint": "Oletuksena pois. Kun pois, ääntä käytetään vain litteroinnin tuottamiseen ja se poistetaan, kun kokous viimeistellään. Voit myös vaihtaa tallennusta kokouskohtaisesti.",
|
||||
"settings.recording.consent_label": "Suostumus: {status}.",
|
||||
"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.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ä."
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"nav.recording": "Enregistrement",
|
||||
"nav.hardware": "Matériel",
|
||||
"nav.storage": "Stockage",
|
||||
"nav.calendar": "Calendrier",
|
||||
"nav.sync": "Synchronisation",
|
||||
"nav.ai": "IA",
|
||||
"nav.mcp": "Serveur MCP",
|
||||
"nav.privacy": "Confidentialité",
|
||||
"nav.about": "À propos",
|
||||
"nav.language": "Langue",
|
||||
|
||||
"settings.language.title": "Langue",
|
||||
"settings.language.display": "Langue d'affichage",
|
||||
"settings.language.display_hint": "La langue de l'interface de l'application. Ajouter une langue est aussi simple que de déposer un seul fichier de traduction.",
|
||||
|
||||
"settings.transcription.title": "Langue de transcription",
|
||||
"settings.transcription.label": "Langue",
|
||||
"settings.transcription.auto": "Détection automatique",
|
||||
"settings.transcription.applies_hint": "S'applique au prochain enregistrement. La langue réellement utilisée est ensuite affichée sur chaque réunion.",
|
||||
"settings.transcription.english_only": "Anglais seulement",
|
||||
"settings.transcription.model_english_only": "« {model} » est en anglais seulement.",
|
||||
"settings.transcription.no_model": "Aucun modèle sélectionné.",
|
||||
"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é",
|
||||
"settings.hardware.auto_backend": "Automatique (le meilleur disponible)",
|
||||
"settings.hardware.not_available": "non disponible",
|
||||
"settings.hardware.fallback_hint": "Bascule automatiquement (NPU → NVIDIA → AMD → Intel → CPU) si le moteur choisi ne se charge pas.",
|
||||
"settings.hardware.audio_devices": "Périphériques audio",
|
||||
"settings.hardware.recording_device": "Périphérique d'enregistrement",
|
||||
"settings.hardware.default_system_audio": "Audio système par défaut",
|
||||
"settings.hardware.recording_device_hint": "WhispAssist enregistre ce que ce périphérique restitue (loopback) — l'autre côté de l'appel. Choisissez une sortie précise si vous ne voulez pas qu'il suive le périphérique par défaut de Windows.",
|
||||
"settings.hardware.microphone": "Microphone",
|
||||
"settings.hardware.mic_off": "Désactivé — ne pas capturer mon microphone",
|
||||
"settings.hardware.default_mic": "Microphone par défaut",
|
||||
"settings.hardware.mic_hint": "Ajoute votre propre voix à la transcription en direct afin que les deux côtés de la réunion soient captés. Reste sur votre appareil — rien n'est téléversé. Choisissez « Désactivé » pour ne transcrire que l'audio système ci-dessus.",
|
||||
"settings.hardware.npu_detected": "Accélérateur NPU détecté",
|
||||
"settings.hardware.ready": "Prêt",
|
||||
"settings.hardware.downloading": "Téléchargement…",
|
||||
"settings.hardware.package_needed": "Paquet requis",
|
||||
"settings.hardware.npu_hint": "Votre NPU nécessite un paquet d'accélération unique (modèle Whisper ONNX{extra}). Il se télécharge en arrière-plan ; vous pouvez aussi le démarrer maintenant.",
|
||||
"settings.hardware.plus_openvino": " + environnement d'exécution OpenVINO",
|
||||
"settings.hardware.download_npu": "Télécharger le paquet NPU",
|
||||
"settings.hardware.dml_title": "Accélération GPU (DirectML)",
|
||||
"settings.hardware.dml_hint": "Votre GPU peut être accéléré sans Vulkan via DirectML — un paquet unique (modèle Whisper ONNX{extra}).",
|
||||
"settings.hardware.plus_directml": " + environnement d'exécution DirectML",
|
||||
"settings.hardware.download_dml": "Télécharger le paquet DirectML",
|
||||
"settings.hardware.detection_unavailable": "Détection du matériel indisponible.",
|
||||
"settings.hardware.low_overhead": "Préréglage à faible charge",
|
||||
"settings.hardware.low_overhead_note": "CPU + plus petit modèle, pour une utilisation sur batterie/en arrière-plan",
|
||||
"settings.hardware.models": "Modèles",
|
||||
"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",
|
||||
"settings.hardware.download": "Télécharger",
|
||||
"settings.hardware.diarization": "Diarisation des locuteurs",
|
||||
"settings.hardware.diarization_hint": "Installez les deux modèles pour que les enregistrements terminés soient séparés par locuteur (Locuteur 1, Locuteur 2, …) au lieu d'une seule transcription continue. Fonctionne entièrement hors ligne, une fois par réunion après son arrêt. Sans eux, chaque ligne est attribuée à un seul locuteur.",
|
||||
|
||||
"settings.storage.title": "Stockage",
|
||||
"settings.storage.location_label": "Emplacement",
|
||||
"settings.storage.location_hint": "Changer l'emplacement de stockage n'est pas encore pris en charge — c'est ici que résident actuellement les réunions, les modèles et la base de données.",
|
||||
"settings.storage.retention_title": "Conservation",
|
||||
"settings.storage.retention_hint": "Supprime automatiquement les réunions les plus anciennes au-delà d'une limite. Vérifié une fois au démarrage ; vide signifie aucune limite. Ne touche jamais à une réunion en cours d'enregistrement.",
|
||||
"settings.storage.max_age": "Âge maximal (jours)",
|
||||
"settings.storage.max_size": "Taille maximale (Go)",
|
||||
"settings.storage.no_limit": "aucune limite",
|
||||
"settings.storage.export_import_title": "Exportation et importation",
|
||||
"settings.storage.export_hint_1": "Déplacez les enregistrements entre ordinateurs sous forme de dossiers de paquets portables (audio, transcription, notes, résumé et un",
|
||||
"settings.storage.export_hint_2": "manifeste). Exportez vers n'importe quel dossier — un lecteur synchronisé, une clé USB ou le point de montage local d'une cible de synchronisation — puis importez-le sur l'autre machine. Les réunions importées obtiennent un nouvel identifiant, de sorte qu'une réimportation n'écrase jamais rien.",
|
||||
"settings.storage.export_dialog_title": "Exporter toutes les réunions vers…",
|
||||
"settings.storage.import_dialog_title": "Importer un paquet (ou un dossier de paquets)…",
|
||||
"settings.storage.exporting": "Exportation…",
|
||||
"settings.storage.export_all": "Exporter toutes les réunions…",
|
||||
"settings.storage.importing": "Importation…",
|
||||
"settings.storage.import_all": "Importer des réunions…",
|
||||
"settings.storage.exported_one": "{n} réunion exportée.",
|
||||
"settings.storage.exported_many": "{n} réunions exportées.",
|
||||
"settings.storage.imported_one": "{n} réunion importée.",
|
||||
"settings.storage.imported_many": "{n} réunions importées.",
|
||||
|
||||
"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…",
|
||||
"settings.calendar.password": "Mot de passe",
|
||||
"settings.calendar.password_note": "rarement nécessaire",
|
||||
"settings.calendar.optional": "facultatif",
|
||||
"settings.calendar.import_range": "Plage d'importation",
|
||||
"settings.calendar.range_30": "30 derniers jours",
|
||||
"settings.calendar.range_90": "90 derniers jours",
|
||||
"settings.calendar.range_180": "6 derniers mois",
|
||||
"settings.calendar.range_365": "Dernière année",
|
||||
"settings.calendar.range_all": "Toute la période",
|
||||
"settings.calendar.range_hint": "Une boîte de courriel ancienne peut contenir des années d'entrées récurrentes/de congés — réduire la plage limite le calendrier importé à ce qui est réellement pertinent. S'applique à ce bouton d'importation ainsi qu'à la réimportation automatique au lancement.",
|
||||
"settings.calendar.importing": "Importation…",
|
||||
"settings.calendar.import": "Importer",
|
||||
"settings.calendar.import_progress": "{processed}/{total} événements",
|
||||
"settings.calendar.auto_reimport": "Réimporter ce fichier automatiquement au lancement",
|
||||
"settings.calendar.auto_reimport_hint": "S'exécute une fois au démarrage, pas sur une minuterie — la réimportation peut être répétée sans risque (les événements existants sont mis en correspondance et mis à jour, pas dupliqués).",
|
||||
"settings.calendar.import_failed": "Échec de l'importation : {error} — le fichier lui-même n'est pas modifié ; vérifiez le chemin et réessayez.",
|
||||
"settings.calendar.cleanup_title": "Nettoyer",
|
||||
"settings.calendar.cleanup_hint": "Les événements déjà rattachés à une réunion enregistrée sont toujours conservés, quelle que soit l'option choisie ci-dessous.",
|
||||
"settings.calendar.cleanup_30": "Plus anciens que 30 jours",
|
||||
"settings.calendar.cleanup_90": "Plus anciens que 90 jours",
|
||||
"settings.calendar.cleanup_180": "Plus anciens que 6 mois",
|
||||
"settings.calendar.cleanup_365": "Plus anciens que 1 an",
|
||||
"settings.calendar.cleanup_all": "Tout supprimer",
|
||||
"settings.calendar.cleaning": "Nettoyage…",
|
||||
"settings.calendar.cleanup_btn": "Nettoyer le calendrier",
|
||||
"settings.calendar.cleanup_result": "{deleted}{extra} supprimés.",
|
||||
"settings.calendar.cleanup_kept": ", {n} conservés (liés à une réunion)",
|
||||
"settings.calendar.cleanup_failed": "Échec du nettoyage : {error}",
|
||||
"settings.calendar.imported_events": "Événements importés",
|
||||
"settings.calendar.no_events": "Aucun événement importé pour l'instant.",
|
||||
"settings.calendar.search_title": "Rechercher un titre",
|
||||
"settings.calendar.search_placeholder": "Nom de la réunion…",
|
||||
"settings.calendar.date_label": "Date",
|
||||
"settings.calendar.events_count": "{shown} sur {total} événements",
|
||||
"settings.calendar.untitled": "(sans titre)",
|
||||
|
||||
"settings.dialog_title": "Paramètres",
|
||||
"settings.close": "Fermer",
|
||||
"settings.close_title": "Fermer (Échap)",
|
||||
|
||||
"settings.sync.stub": "Mode aperçu — le moteur de synchronisation n'est pas encore implémenté (Phase 9). Les modifications ne sont conservées que dans l'interface.",
|
||||
"settings.sync.title": "Synchronisation et téléversement",
|
||||
"settings.sync.enable_label": "Activer le téléversement des artefacts de réunion vers les cibles configurées",
|
||||
"settings.sync.enable_hint": "Désactivé par défaut. Rien n'est téléversé sauf si ceci est activé et qu'une cible est activée. Les cibles auto-hébergées conservent les données sur votre propre serveur ; les nuages tiers sont clairement étiquetés.",
|
||||
"settings.sync.targets_title": "Cibles",
|
||||
"settings.sync.no_targets": "Aucune cible pour l'instant. Ajoutez-en une ci-dessous.",
|
||||
"settings.sync.third_party": "tiers",
|
||||
"settings.sync.your_server": "votre serveur",
|
||||
"settings.sync.edit": "Modifier",
|
||||
"settings.sync.remove": "Supprimer",
|
||||
"settings.sync.edit_target": "Modifier la cible",
|
||||
"settings.sync.add_target": "Ajouter une cible",
|
||||
"settings.sync.webdav_hint": "WebDAV couvre Nextcloud, ownCloud, Cloudreve, Seafile et Synology. (Seafile : activez SeafDAV côté serveur.)",
|
||||
"settings.sync.name": "Nom",
|
||||
"settings.sync.name_placeholder": "Nextcloud maison",
|
||||
"settings.sync.provider": "Fournisseur",
|
||||
"settings.sync.server_url": "URL du serveur",
|
||||
"settings.sync.server_url_note_1": "Juste l'URL de votre serveur — WhispAssist ajoute",
|
||||
"settings.sync.server_url_note_2": "automatiquement.",
|
||||
"settings.sync.remote_folder": "Dossier distant",
|
||||
"settings.sync.username": "Nom d'utilisateur",
|
||||
"settings.sync.app_password": "Mot de passe d'application",
|
||||
"settings.sync.pw_keep": "laissez vide pour conserver le mot de passe actuel",
|
||||
"settings.sync.pw_store": "stocké dans le gestionnaire d'identifiants du système d'exploitation",
|
||||
"settings.sync.upload_legend": "Téléverser",
|
||||
"settings.sync.artifact_transcript": "transcription",
|
||||
"settings.sync.artifact_notes": "notes",
|
||||
"settings.sync.artifact_summary": "résumé",
|
||||
"settings.sync.artifact_recording": "enregistrement (.wav, si conservé)",
|
||||
"settings.sync.allow_plaintext": "Autoriser le http en clair pour une adresse LAN (non recommandé)",
|
||||
"settings.sync.encrypt_before": "Chiffrer avant le téléversement (la destination ne stocke que le texte chiffré)",
|
||||
"settings.sync.requires_vault": "— nécessite un coffre déverrouillé",
|
||||
"settings.sync.test_connection": "Tester la connexion",
|
||||
"settings.sync.save_changes": "Enregistrer les modifications",
|
||||
"settings.sync.add_target_btn": "Ajouter la cible",
|
||||
"settings.sync.cancel": "Annuler",
|
||||
"settings.sync.third_party_banner": "{kind} est un nuage tiers — le téléversement envoie vos données hors de votre appareil vers {kind}.",
|
||||
"settings.sync.link_account": "Lier le compte {kind}…",
|
||||
"settings.sync.oauth_hint": "Ouvre une connexion OAuth (redirection loopback) ; le jeton est stocké dans le gestionnaire d'identifiants du système d'exploitation.",
|
||||
"settings.sync.footnote": "Les identifiants ne sont jamais écrits dans les paramètres ni dans la base de données — uniquement dans le gestionnaire d'identifiants du système d'exploitation. TLS est requis pour les cibles hors LAN.",
|
||||
|
||||
"settings.ai.title": "Fournisseur de résumés IA",
|
||||
"settings.ai.intro_1": "Les résumés s'exécutent par défaut sur un LLM local. Pointez ceci vers Ollama sur ce PC ou une autre machine de votre LAN (par ex.",
|
||||
"settings.ai.intro_2": "— les deux comptent comme local, donc rien ne quitte votre réseau. Les fournisseurs hébergés (Anthropic) sont facultatifs, désactivés par défaut, et envoient la transcription à un tiers dès que vous en activez un.",
|
||||
"settings.ai.provider": "Fournisseur",
|
||||
"settings.ai.off": "Désactivé",
|
||||
"settings.ai.provider_ollama": "Ollama (local / LAN)",
|
||||
"settings.ai.provider_custom": "Personnalisé (compatible OpenAI)",
|
||||
"settings.ai.provider_anthropic": "Anthropic (Claude) — hébergé, quitte cet appareil",
|
||||
"settings.ai.endpoint": "Point de terminaison",
|
||||
"settings.ai.model": "Modèle",
|
||||
"settings.ai.api_key": "Clé API",
|
||||
"settings.ai.api_key_set_placeholder": "•••••••••••••••• (déjà définie — laissez vide pour la conserver)",
|
||||
"settings.ai.show_api_key": "Afficher la clé API",
|
||||
"settings.ai.hide_api_key": "Masquer la clé API",
|
||||
"settings.ai.anthropic_banner": "Anthropic est un service hébergé tiers — la transcription de cette réunion quitte votre appareil lorsque vous générez un résumé.",
|
||||
"settings.ai.endpoint_banner": "Ce point de terminaison n'est pas sur votre machine ni sur votre LAN — votre transcription quitterait votre réseau.",
|
||||
"settings.ai.saving": "Enregistrement…",
|
||||
"settings.ai.save": "Enregistrer",
|
||||
"settings.ai.test_connection": "Tester la connexion",
|
||||
"settings.ai.this_hosted_provider": "ce fournisseur hébergé",
|
||||
"settings.ai.reachable": "Joignable",
|
||||
"settings.ai.unreachable": "Injoignable",
|
||||
"settings.ai.model_count_one": "· {n} modèle",
|
||||
"settings.ai.model_count_many": "· {n} modèles",
|
||||
"settings.ai.on_lan": "· sur votre machine / LAN",
|
||||
"settings.ai.leaves_network": "· quitte votre réseau",
|
||||
"settings.ai.advanced_title": "Configuration avancée d'Ollama",
|
||||
"settings.ai.changed": "{n} modifiés",
|
||||
"settings.ai.reset_all": "Tout réinitialiser aux valeurs par défaut",
|
||||
"settings.ai.system_prompt": "Invite système",
|
||||
"settings.ai.system_prompt_placeholder": "par ex. Vous êtes un rédacteur de résumés de réunion concis.",
|
||||
"settings.ai.system_prompt_hint": "Ajoutée avant le format de sortie requis par WhispAssist, afin que vos instructions ne puissent pas casser l'analyse du résumé/des actions à mener.",
|
||||
"settings.ai.think": "Réfléchir",
|
||||
"settings.ai.think_help": "Effort de raisonnement (modèles de raisonnement uniquement).",
|
||||
"settings.ai.keep_alive": "Maintenir en vie",
|
||||
"settings.ai.keep_alive_help": "Durée pendant laquelle le modèle reste en RAM. par ex. 5m, 1h, 0 (décharger), -1 (indéfiniment).",
|
||||
"settings.ai.runtime_hardware": "Exécution et matériel",
|
||||
"settings.ai.rarely_needed": "— rarement nécessaire",
|
||||
"settings.ai.save_advanced": "Enregistrer les options avancées",
|
||||
"settings.ai.saved": "Enregistré",
|
||||
"settings.ai.turn_off": "Désactiver",
|
||||
"settings.ai.default_value": "Par défaut {value}.",
|
||||
"settings.ai.reset_default": "Réinitialiser à la valeur par défaut",
|
||||
"settings.ai.reset_field": "Réinitialiser {name}",
|
||||
|
||||
"settings.mcp.title": "Serveur MCP",
|
||||
"settings.mcp.banner_1": "Cela permet à votre propre agent de codage (Claude Code, Codex, Copilot, OpenCode, …) de récupérer le contexte de la réunion sur votre machine locale. Une fois connecté,",
|
||||
"settings.mcp.banner_agent": "cet agent",
|
||||
"settings.mcp.banner_2": "peut transmettre ce qu'il lit au nuage de son propre fournisseur de modèle — hors du contrôle de WhispAssist. WhispAssist lui-même n'envoie jamais ces données où que ce soit ; le serveur n'écoute que sur cet appareil (",
|
||||
"settings.mcp.banner_3": ") et chaque lecture est journalisée ci-dessous.",
|
||||
"settings.mcp.enable": "Activer le serveur MCP",
|
||||
"settings.mcp.enable_hint": "Désactivé par défaut. Loopback uniquement, protégé par jeton — rien n'est accessible depuis le réseau.",
|
||||
"settings.mcp.transport": "Transport",
|
||||
"settings.mcp.transport_http": "HTTP en flux continu",
|
||||
"settings.mcp.transport_stdio": "stdio (l'agent lance un processus)",
|
||||
"settings.mcp.port": "Port",
|
||||
"settings.mcp.transport_change_hint": "Pour changer de transport/port, désactivez d'abord le serveur, puis réactivez-le.",
|
||||
"settings.mcp.scope_title": "Portée",
|
||||
"settings.mcp.expose": "Exposer",
|
||||
"settings.mcp.scope_none": "Aucune — rien n'est partagé",
|
||||
"settings.mcp.scope_selected": "Sélectionnés — uniquement les briefs de fonctionnalité que vous avez marqués comme partagés",
|
||||
"settings.mcp.scope_all": "Tout — réunions, transcriptions, actions à mener et briefs partagés",
|
||||
"settings.mcp.expose_recordings": "Autoriser aussi les réunions avec un enregistrement sauvegardé (désactivé par défaut — la transcription d'une réunion enregistrée est retenue même en portée « Tout » jusqu'à ce que ceci soit activé)",
|
||||
"settings.mcp.token_title": "Nouveau jeton d'authentification — affiché une seule fois, copiez-le maintenant",
|
||||
"settings.mcp.token_hint": "Ceci ne sera plus affiché. Il est stocké dans le gestionnaire d'identifiants du système d'exploitation ; si vous le perdez, désactivez puis réactivez le serveur pour en générer un nouveau.",
|
||||
"settings.mcp.copied": "Copié",
|
||||
"settings.mcp.copy": "Copier",
|
||||
"settings.mcp.endpoint": "Point de terminaison",
|
||||
"settings.mcp.endpoint_hint": "Pointez la configuration du client MCP de votre agent vers {kind}, avec le jeton ci-dessus comme identifiant bearer.",
|
||||
"settings.mcp.kind_command": "commande",
|
||||
"settings.mcp.kind_url": "URL",
|
||||
"settings.mcp.access_log": "Journal d'accès",
|
||||
"settings.mcp.access_log_hint": "Chaque lecture d'outil effectuée par un agent, autorisée ou refusée (FR-MCP-5).",
|
||||
"settings.mcp.no_reads": "Aucun agent n'a encore rien lu.",
|
||||
"settings.mcp.log_meeting": "réunion {id}",
|
||||
"settings.mcp.refresh": "Actualiser",
|
||||
|
||||
"settings.privacy.title": "Confidentialité",
|
||||
"settings.privacy.intro": "Ce que WhispAssist est réellement autorisé à envoyer hors de cet appareil actuellement. Avec tout désactivé (le réglage par défaut), rien ne quitte l'appareil.",
|
||||
"settings.privacy.llm_endpoint": "Point de terminaison LLM",
|
||||
"settings.privacy.off": "désactivé",
|
||||
"settings.privacy.local_only": "local uniquement",
|
||||
"settings.privacy.leaves_device": "quitte cet appareil",
|
||||
"settings.privacy.sync_label": "Synchronisation",
|
||||
"settings.privacy.enabled": "activée",
|
||||
"settings.privacy.mcp_label": "Serveur MCP",
|
||||
"settings.privacy.mcp_on": "activé · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "Entrant sur loopback uniquement — n'ajoute rien à la liste de sortie ci-dessus. Un agent connecté peut néanmoins transmettre ce qu'il lit à son propre fournisseur de modèle ; voir l'onglet du serveur MCP.",
|
||||
"settings.privacy.egress_title": "Liste d'autorisation de sortie",
|
||||
"settings.privacy.no_egress": "Aucun hôte n'est autorisé — WA n'effectue aucune sortie de contenu.",
|
||||
"settings.privacy.sync_targets_title": "Cibles de synchronisation",
|
||||
"settings.privacy.third_party": "tiers",
|
||||
"settings.privacy.your_server": "votre serveur",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "sans TLS",
|
||||
"settings.privacy.refresh": "Actualiser",
|
||||
"settings.privacy.unavailable": "Autovérification de confidentialité indisponible.",
|
||||
"settings.privacy.vault_title": "Coffre de chiffrement",
|
||||
"settings.privacy.vault_intro": "Chiffrez les notes, transcriptions et résumés au repos avec un mot de passe. (Les fichiers audio ne sont pas encore chiffrés.)",
|
||||
"settings.privacy.vault_password": "Mot de passe du coffre",
|
||||
"settings.privacy.enable_vault": "Activer le coffre",
|
||||
"settings.privacy.vault_pw_hint": "Utilisez au moins 8 caractères. Si vous l'oubliez, le contenu chiffré ne pourra pas être récupéré.",
|
||||
"settings.privacy.vault_locked_1": "Le coffre est ",
|
||||
"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é",
|
||||
"settings.privacy.vault_unlocked_2": ". Les nouvelles notes, transcriptions et résumés sont chiffrés au repos.",
|
||||
"settings.privacy.lock_now": "Verrouiller maintenant",
|
||||
"settings.privacy.change_password": "Changer le mot de passe",
|
||||
"settings.privacy.current_password": "Mot de passe actuel",
|
||||
"settings.privacy.new_password": "Nouveau mot de passe",
|
||||
|
||||
"settings.about.title": "À propos",
|
||||
"settings.about.tagline": "Un assistant de réunion entièrement local, à code source ouvert et natif Windows.",
|
||||
"settings.about.build_commit": "Commit de compilation",
|
||||
|
||||
"consent.heading": "Avant d'enregistrer",
|
||||
"consent.body": "Enregistrer des conversations sans le consentement des participants peut être illégal dans votre région. Vérifiez les lois locales sur l'enregistrement. Ceci est une mise en garde, pas un avis juridique.",
|
||||
"consent.accept": "Je comprends — activer l'enregistrement",
|
||||
"consent.cancel": "Annuler",
|
||||
|
||||
"hosted.heading": "Avant d'utiliser {provider}",
|
||||
"hosted.body_1": "Générer avec",
|
||||
"hosted.body_2": "envoie la transcription de cette réunion sur leurs serveurs — elle quitte cet appareil et est soumise à leur politique de confidentialité. WhispAssist n'a aucun contrôle sur le traitement des données une fois qu'elles quittent votre appareil. Ceci est désactivé par défaut ; vous le choisissez maintenant.",
|
||||
"hosted.accept": "Je comprends — continuer",
|
||||
"hosted.cancel": "Annuler",
|
||||
|
||||
"theme.group": "Thème",
|
||||
"theme.system": "Suivre le système",
|
||||
"theme.light": "Clair",
|
||||
"theme.dark": "Sombre",
|
||||
|
||||
"import.dialog_label": "Ajouter une réunion depuis un fichier ou une URL",
|
||||
"import.title": "Ajouter une réunion",
|
||||
"import.close": "Fermer",
|
||||
"import.close_title": "Fermer (Échap)",
|
||||
"import.body": "Importez un enregistrement existant — un fichier audio/vidéo local ou un lien (YouTube, une page de diffusion en continu ou une URL média directe). Il est transcrit et diarisé comme un enregistrement en direct.",
|
||||
"import.file_or_url": "Fichier ou URL",
|
||||
"import.source_placeholder": "Collez une URL ou parcourez pour trouver un fichier…",
|
||||
"import.choose_file": "Choisir un fichier local",
|
||||
"import.browse": "Parcourir…",
|
||||
"import.title_label": "Titre",
|
||||
"import.optional": "facultatif",
|
||||
"import.title_placeholder": "Par défaut, le nom du fichier",
|
||||
"import.filter_av": "Audio / vidéo",
|
||||
"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}",
|
||||
|
||||
"levelmeter.system_mic": "Niveau d'entrée du système et du microphone",
|
||||
"levelmeter.system": "Niveau d'entrée du système",
|
||||
|
||||
"app.tagline": "local · privé",
|
||||
"app.note_template": "Modèle de notes",
|
||||
"app.no_template": "Aucun modèle",
|
||||
"app.add_meeting": "Ajouter une réunion",
|
||||
"app.add_meeting_title": "Ajouter une réunion depuis un fichier ou une URL",
|
||||
"app.record": "Enregistrer",
|
||||
"app.record_title": "Démarrer l'enregistrement (Ctrl+Maj+R)",
|
||||
"app.stop": "Arrêter",
|
||||
"app.stop_title": "Arrêter l'enregistrement (Ctrl+Maj+R)",
|
||||
"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",
|
||||
"app.not_saved": "non enregistré",
|
||||
"app.reconnecting": "reconnexion du périphérique audio…",
|
||||
"app.settings": "Paramètres",
|
||||
"app.settings_title": "Paramètres (Ctrl+,)",
|
||||
"app.discard_confirm": "Abandonner cet enregistrement ? Son audio et sa transcription seront supprimés.",
|
||||
"app.announce_started": "Enregistrement démarré",
|
||||
"app.announce_paused": "Enregistrement en pause",
|
||||
"app.announce_stopped": "Enregistrement arrêté",
|
||||
"app.consent_dialog": "Consentement à l'enregistrement",
|
||||
"app.vault_dialog": "Déverrouiller le coffre de chiffrement",
|
||||
"app.vault_title": "Déverrouiller le coffre de chiffrement",
|
||||
"app.vault_desc": "Vos réunions sont chiffrées au repos. Saisissez votre mot de passe pour les lire.",
|
||||
"app.vault_password": "Mot de passe du coffre",
|
||||
"app.vault_incorrect": "Mot de passe incorrect",
|
||||
"app.unlock": "Déverrouiller",
|
||||
"app.continue_locked": "Continuer verrouillé",
|
||||
"app.show_meetings": "Afficher la liste des réunions",
|
||||
"app.hide_meetings": "Masquer la liste des réunions",
|
||||
"app.show_summary": "Afficher le panneau de résumé",
|
||||
"app.hide_summary": "Masquer le panneau de résumé",
|
||||
"app.resize_meetings": "Redimensionner la liste des réunions",
|
||||
"app.resize_summary": "Redimensionner le panneau de résumé",
|
||||
|
||||
"meetings.search_placeholder": "Rechercher des réunions…",
|
||||
"meetings.search_aria": "Rechercher des réunions",
|
||||
"meetings.filter_tag_aria": "Filtrer par étiquette",
|
||||
"meetings.all_tags": "Toutes les étiquettes",
|
||||
"meetings.from_date_aria": "À partir de la date",
|
||||
"meetings.to_date_aria": "Jusqu'à la date",
|
||||
"meetings.bulk_format_aria": "Format d'exportation groupée",
|
||||
"meetings.bulk_export": "Exportation groupée",
|
||||
"meetings.bulk_export_title": "Exporter toutes les réunions correspondant aux filtres d'étiquette/date ci-dessus",
|
||||
"meetings.exporting": "Exportation…",
|
||||
"meetings.exported_one": "{n} réunion exportée.",
|
||||
"meetings.exported_many": "{n} réunions exportées.",
|
||||
"meetings.searching": "Recherche…",
|
||||
"meetings.loading": "Chargement des réunions…",
|
||||
"meetings.no_matches": "Aucun résultat.",
|
||||
"meetings.empty": "Aucune réunion pour l'instant — cliquez sur Enregistrer ci-dessus pour commencer.",
|
||||
"meetings.status.recording": "enregistrement",
|
||||
"meetings.status.transcribing": "transcription",
|
||||
"meetings.status.recovering": "récupération",
|
||||
"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",
|
||||
"transcript.hide": "Masquer la transcription",
|
||||
"transcript.empty": "Aucune transcription pour cette réunion.",
|
||||
"transcript.will_appear": "La transcription apparaîtra ici au fur et à mesure de l'enregistrement.",
|
||||
"transcript.play_from_here": "Lire à partir d'ici",
|
||||
"transcript.resize": "Redimensionner la transcription et les notes",
|
||||
"transcript.reprocess_placeholder": "Re-transcrire avec…",
|
||||
"transcript.reprocess_lang_aria": "Langue de retraitement",
|
||||
"transcript.reprocess_keep_lang": "Conserver la langue actuelle",
|
||||
"transcript.reprocess_go": "Lancer",
|
||||
"transcript.retranscribing": "Re-transcription…",
|
||||
"transcript.has_note": "Comporte une note",
|
||||
"transcript.note_placeholder": "Ajouter une note pour ce moment…",
|
||||
"transcript.note_aria": "Note pour cette ligne de transcription",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "Document Word",
|
||||
"transcript.filter_obsidian": "Note Obsidian",
|
||||
|
||||
"notes.heading": "Notes",
|
||||
"notes.show": "Afficher les notes",
|
||||
"notes.hide": "Masquer les notes",
|
||||
"notes.placeholder": "Notes…",
|
||||
"notes.live_placeholder": "Tapez des notes pendant que vous parlez…",
|
||||
"notes.toolbar_aria": "Mise en forme des notes",
|
||||
"notes.bold": "Gras",
|
||||
"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",
|
||||
"notes.preview": "Aperçu",
|
||||
"notes.export_md_title": "Exporter les notes en .md",
|
||||
"notes.export_pdf_title": "Exporter les notes en .pdf",
|
||||
"notes.export_docx_title": "Exporter les notes en .docx",
|
||||
"notes.export_bundle_title": "Exporter audio + transcription + notes vers un dossier",
|
||||
"notes.export_obsidian_title": "Exporter une note Obsidian (notes + résumé + transcription, sans audio)",
|
||||
|
||||
"summary.recording_heading": "Enregistrement",
|
||||
"summary.loading_recording": "Chargement de l'enregistrement…",
|
||||
"summary.sync_heading": "Synchronisation",
|
||||
"summary.uploading": "Téléversement…",
|
||||
"summary.upload_now": "Téléverser maintenant",
|
||||
"summary.no_uploads": "Aucun téléversement pour cette réunion pour l'instant.",
|
||||
"summary.retry": "réessayer",
|
||||
"summary.tags_heading": "Étiquettes",
|
||||
"summary.tags_select": "Sélectionnez une réunion pour l'étiqueter.",
|
||||
"summary.tag_add_placeholder": "Ajouter une étiquette…",
|
||||
"summary.tag_first_placeholder": "projet, client, sujet…",
|
||||
"summary.generating": "Génération…",
|
||||
"summary.generate_tags": "Générer des étiquettes",
|
||||
"summary.saving": "Enregistrement…",
|
||||
"summary.save_tags": "Enregistrer les étiquettes",
|
||||
"summary.summary_heading": "Résumé",
|
||||
"summary.ai_provider": "Fournisseur d'IA",
|
||||
"summary.provider_off": "Désactivé",
|
||||
"summary.provider_custom": "Personnalisé",
|
||||
"summary.local": "local",
|
||||
"summary.leaves_device": "quitte cet appareil",
|
||||
"summary.this_hosted_provider": "ce fournisseur hébergé",
|
||||
"summary.select_generate": "Sélectionnez une réunion pour générer un résumé.",
|
||||
"summary.decisions_heading": "Décisions",
|
||||
"summary.regenerate": "Régénérer",
|
||||
"summary.generated_hint": "Généré localement après la réunion (nécessite un fournisseur LLM local).",
|
||||
"summary.no_provider": "Aucun fournisseur d'IA configuré — activez-en un dans les Paramètres.",
|
||||
"summary.generate_summary": "Générer un résumé",
|
||||
"summary.briefs_heading": "Briefs de fonctionnalité",
|
||||
"summary.briefs_select": "Sélectionnez une réunion pour créer ou consulter des briefs de fonctionnalité.",
|
||||
"summary.briefs_desc": "Distille cette réunion en une spécification prête pour un agent (nécessite un fournisseur LLM local) — remettez-la à un agent de codage, ou servez-la via le serveur MCP une fois celui-ci activé.",
|
||||
"summary.brief_repo_placeholder": "Dépôt cible (facultatif), par ex. acme/reporting-web",
|
||||
"summary.distilling": "Distillation…",
|
||||
"summary.create_brief": "Créer un brief de fonctionnalité",
|
||||
"summary.brief_mcp_title": "Disponible lorsque le serveur MCP est activé",
|
||||
"summary.copied": "Copié",
|
||||
"summary.copy_md": "Copier en Markdown",
|
||||
"summary.copy_json": "Copier en JSON",
|
||||
"summary.brief_problem": "Problème",
|
||||
"summary.brief_outcome": "Résultat souhaité",
|
||||
"summary.brief_criteria": "Critères d'acceptation",
|
||||
"summary.brief_none": "Rien de capturé.",
|
||||
"summary.brief_context": "Contexte",
|
||||
"summary.action_items_heading": "Actions à mener",
|
||||
"summary.ai_select": "Sélectionnez une réunion pour voir ses actions à mener.",
|
||||
"summary.ai_empty": "Aucune pour l'instant — ajoutez-en une ci-dessous, ou générez un résumé pour les extraire automatiquement.",
|
||||
"summary.confirmed": "Confirmée",
|
||||
"summary.ai_text_placeholder": "Action à mener…",
|
||||
"summary.ai_text_aria": "Texte de l'action à mener",
|
||||
"summary.owner": "Responsable",
|
||||
"summary.due_date": "Date d'échéance",
|
||||
"summary.reminder_title": "Planifier un rappel local pour cette date d'échéance",
|
||||
"summary.ai_delete_title": "Supprimer cette action à mener",
|
||||
"summary.ai_delete_aria": "Supprimer l'action à mener",
|
||||
"summary.add_action_item": "Ajouter une action à mener",
|
||||
"summary.save_action_items": "Enregistrer les actions à mener",
|
||||
"summary.calendar_heading": "Événement du calendrier",
|
||||
"summary.cal_select": "Sélectionnez une réunion pour la lier à un événement du calendrier.",
|
||||
"summary.event_search_placeholder": "Rechercher un titre d'événement…",
|
||||
"summary.linked_event": "Événement lié",
|
||||
"summary.change_event": "Changer d'événement…",
|
||||
"summary.link_event": "Lier un événement…",
|
||||
"summary.untitled_event": "(sans titre)",
|
||||
"summary.no_events_1": "Aucun événement importé pour l'instant — importez-en un",
|
||||
"summary.no_events_2": "depuis Paramètres → Calendrier.",
|
||||
"summary.no_events_match": "Aucun événement ne correspond à cette recherche/date — essayez d'en effacer une.",
|
||||
"summary.organizer_label": "Organisateur : {name}",
|
||||
"summary.participants_heading": "Participants",
|
||||
"summary.participants_hint": "Renseigné à partir de l'événement du calendrier lié.",
|
||||
"summary.speakers_heading": "Locuteurs",
|
||||
"summary.speakers_select": "Sélectionnez une réunion pour nommer ses locuteurs.",
|
||||
"summary.no_speakers": "Aucun locuteur détecté pour l'instant.",
|
||||
"summary.speaker_name_placeholder": "Nom du locuteur",
|
||||
"summary.save": "Enregistrer",
|
||||
"summary.cancel": "Annuler",
|
||||
"summary.name_speaker": "Nommer ce locuteur…",
|
||||
"summary.add_new_name": "+ Ajouter un nouveau nom…",
|
||||
|
||||
"settings.recording.title": "Enregistrement",
|
||||
"settings.recording.record_default": "Enregistrer les réunions par défaut",
|
||||
"settings.recording.save_audio_as": "enregistrer l'audio en",
|
||||
"settings.recording.default_hint": "Désactivé par défaut. Lorsqu'il est désactivé, l'audio n'est utilisé que pour produire la transcription et est supprimé lorsque la réunion est finalisée. Vous pouvez aussi activer l'enregistrement par réunion.",
|
||||
"settings.recording.consent_label": "Consentement : {status}.",
|
||||
"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.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."
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"nav.recording": "Enregistrement",
|
||||
"nav.hardware": "Matériel",
|
||||
"nav.storage": "Stockage",
|
||||
"nav.calendar": "Calendrier",
|
||||
"nav.sync": "Synchronisation",
|
||||
"nav.ai": "IA",
|
||||
"nav.mcp": "Serveur MCP",
|
||||
"nav.privacy": "Confidentialité",
|
||||
"nav.about": "À propos",
|
||||
"nav.language": "Langue",
|
||||
|
||||
"settings.language.title": "Langue",
|
||||
"settings.language.display": "Langue d'affichage",
|
||||
"settings.language.display_hint": "La langue de l'interface de l'application. Ajouter une langue est aussi simple que de déposer un seul fichier de traduction.",
|
||||
|
||||
"settings.transcription.title": "Langue de transcription",
|
||||
"settings.transcription.label": "Langue",
|
||||
"settings.transcription.auto": "Détection automatique",
|
||||
"settings.transcription.applies_hint": "S'applique au prochain enregistrement. La langue réellement utilisée est ensuite affichée sur chaque réunion.",
|
||||
"settings.transcription.english_only": "Anglais uniquement",
|
||||
"settings.transcription.model_english_only": "« {model} » est en anglais uniquement.",
|
||||
"settings.transcription.no_model": "Aucun modèle sélectionné.",
|
||||
"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é",
|
||||
"settings.hardware.auto_backend": "Automatique (le meilleur disponible)",
|
||||
"settings.hardware.not_available": "non disponible",
|
||||
"settings.hardware.fallback_hint": "Bascule automatiquement (NPU → NVIDIA → AMD → Intel → CPU) si le backend choisi ne se charge pas.",
|
||||
"settings.hardware.audio_devices": "Périphériques audio",
|
||||
"settings.hardware.recording_device": "Périphérique d'enregistrement",
|
||||
"settings.hardware.default_system_audio": "Audio système par défaut",
|
||||
"settings.hardware.recording_device_hint": "WhispAssist enregistre ce que ce périphérique restitue (loopback) — l'autre côté de l'appel. Choisissez une sortie précise si vous ne voulez pas qu'il suive le périphérique par défaut de Windows.",
|
||||
"settings.hardware.microphone": "Microphone",
|
||||
"settings.hardware.mic_off": "Désactivé — ne pas capturer mon microphone",
|
||||
"settings.hardware.default_mic": "Microphone par défaut",
|
||||
"settings.hardware.mic_hint": "Ajoute votre propre voix à la transcription en direct afin que les deux côtés de la réunion soient capturés. Reste sur votre appareil — rien n'est téléversé. Choisissez « Désactivé » pour ne transcrire que l'audio système ci-dessus.",
|
||||
"settings.hardware.npu_detected": "Accélérateur NPU détecté",
|
||||
"settings.hardware.ready": "Prêt",
|
||||
"settings.hardware.downloading": "Téléchargement…",
|
||||
"settings.hardware.package_needed": "Paquet requis",
|
||||
"settings.hardware.npu_hint": "Votre NPU nécessite un paquet d'accélération unique (modèle Whisper ONNX{extra}). Il se télécharge en arrière-plan ; vous pouvez aussi le démarrer maintenant.",
|
||||
"settings.hardware.plus_openvino": " + environnement d'exécution OpenVINO",
|
||||
"settings.hardware.download_npu": "Télécharger le paquet NPU",
|
||||
"settings.hardware.dml_title": "Accélération GPU (DirectML)",
|
||||
"settings.hardware.dml_hint": "Votre GPU peut être accéléré sans Vulkan via DirectML — un paquet unique (modèle Whisper ONNX{extra}).",
|
||||
"settings.hardware.plus_directml": " + environnement d'exécution DirectML",
|
||||
"settings.hardware.download_dml": "Télécharger le paquet DirectML",
|
||||
"settings.hardware.detection_unavailable": "Détection du matériel indisponible.",
|
||||
"settings.hardware.low_overhead": "Préréglage à faible charge",
|
||||
"settings.hardware.low_overhead_note": "CPU + plus petit modèle, pour une utilisation sur batterie/en arrière-plan",
|
||||
"settings.hardware.models": "Modèles",
|
||||
"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",
|
||||
"settings.hardware.download": "Télécharger",
|
||||
"settings.hardware.diarization": "Diarisation des locuteurs",
|
||||
"settings.hardware.diarization_hint": "Installez les deux modèles pour que les enregistrements terminés soient séparés par locuteur (Locuteur 1, Locuteur 2, …) au lieu d'une seule transcription continue. Fonctionne entièrement hors ligne, une fois par réunion après son arrêt. Sans eux, chaque ligne est attribuée à un seul locuteur.",
|
||||
|
||||
"settings.storage.title": "Stockage",
|
||||
"settings.storage.location_label": "Emplacement",
|
||||
"settings.storage.location_hint": "Changer l'emplacement de stockage n'est pas encore pris en charge — c'est ici que résident actuellement les réunions, les modèles et la base de données.",
|
||||
"settings.storage.retention_title": "Conservation",
|
||||
"settings.storage.retention_hint": "Supprime automatiquement les réunions les plus anciennes au-delà d'une limite. Vérifié une fois au démarrage ; vide signifie aucune limite. Ne touche jamais à une réunion en cours d'enregistrement.",
|
||||
"settings.storage.max_age": "Âge maximal (jours)",
|
||||
"settings.storage.max_size": "Taille maximale (Go)",
|
||||
"settings.storage.no_limit": "aucune limite",
|
||||
"settings.storage.export_import_title": "Exportation et importation",
|
||||
"settings.storage.export_hint_1": "Déplacez les enregistrements entre ordinateurs sous forme de dossiers de paquets portables (audio, transcription, notes, résumé et un",
|
||||
"settings.storage.export_hint_2": "manifeste). Exportez vers n'importe quel dossier — un lecteur synchronisé, une clé USB ou le point de montage local d'une cible de synchronisation — puis importez-le sur l'autre machine. Les réunions importées obtiennent un nouvel identifiant, de sorte qu'une réimportation n'écrase jamais rien.",
|
||||
"settings.storage.export_dialog_title": "Exporter toutes les réunions vers…",
|
||||
"settings.storage.import_dialog_title": "Importer un paquet (ou un dossier de paquets)…",
|
||||
"settings.storage.exporting": "Exportation…",
|
||||
"settings.storage.export_all": "Exporter toutes les réunions…",
|
||||
"settings.storage.importing": "Importation…",
|
||||
"settings.storage.import_all": "Importer des réunions…",
|
||||
"settings.storage.exported_one": "{n} réunion exportée.",
|
||||
"settings.storage.exported_many": "{n} réunions exportées.",
|
||||
"settings.storage.imported_one": "{n} réunion importée.",
|
||||
"settings.storage.imported_many": "{n} réunions importées.",
|
||||
|
||||
"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…",
|
||||
"settings.calendar.password": "Mot de passe",
|
||||
"settings.calendar.password_note": "rarement nécessaire",
|
||||
"settings.calendar.optional": "facultatif",
|
||||
"settings.calendar.import_range": "Plage d'importation",
|
||||
"settings.calendar.range_30": "30 derniers jours",
|
||||
"settings.calendar.range_90": "90 derniers jours",
|
||||
"settings.calendar.range_180": "6 derniers mois",
|
||||
"settings.calendar.range_365": "Dernière année",
|
||||
"settings.calendar.range_all": "Toute la période",
|
||||
"settings.calendar.range_hint": "Une boîte aux lettres ancienne peut contenir des années d'entrées récurrentes/de vacances — réduire la plage limite le calendrier importé à ce qui est réellement pertinent. S'applique à ce bouton d'importation ainsi qu'à la réimportation automatique au lancement.",
|
||||
"settings.calendar.importing": "Importation…",
|
||||
"settings.calendar.import": "Importer",
|
||||
"settings.calendar.import_progress": "{processed}/{total} événements",
|
||||
"settings.calendar.auto_reimport": "Réimporter ce fichier automatiquement au lancement",
|
||||
"settings.calendar.auto_reimport_hint": "S'exécute une fois au démarrage, pas sur une minuterie — la réimportation peut être répétée sans risque (les événements existants sont mis en correspondance et mis à jour, pas dupliqués).",
|
||||
"settings.calendar.import_failed": "Échec de l'importation : {error} — le fichier lui-même n'est pas modifié ; vérifiez le chemin et réessayez.",
|
||||
"settings.calendar.cleanup_title": "Nettoyer",
|
||||
"settings.calendar.cleanup_hint": "Les événements déjà rattachés à une réunion enregistrée sont toujours conservés, quelle que soit l'option choisie ci-dessous.",
|
||||
"settings.calendar.cleanup_30": "Plus anciens que 30 jours",
|
||||
"settings.calendar.cleanup_90": "Plus anciens que 90 jours",
|
||||
"settings.calendar.cleanup_180": "Plus anciens que 6 mois",
|
||||
"settings.calendar.cleanup_365": "Plus anciens que 1 an",
|
||||
"settings.calendar.cleanup_all": "Tout supprimer",
|
||||
"settings.calendar.cleaning": "Nettoyage…",
|
||||
"settings.calendar.cleanup_btn": "Nettoyer le calendrier",
|
||||
"settings.calendar.cleanup_result": "{deleted}{extra} supprimés.",
|
||||
"settings.calendar.cleanup_kept": ", {n} conservés (liés à une réunion)",
|
||||
"settings.calendar.cleanup_failed": "Échec du nettoyage : {error}",
|
||||
"settings.calendar.imported_events": "Événements importés",
|
||||
"settings.calendar.no_events": "Aucun événement importé pour l'instant.",
|
||||
"settings.calendar.search_title": "Rechercher un titre",
|
||||
"settings.calendar.search_placeholder": "Nom de la réunion…",
|
||||
"settings.calendar.date_label": "Date",
|
||||
"settings.calendar.events_count": "{shown} sur {total} événements",
|
||||
"settings.calendar.untitled": "(sans titre)",
|
||||
|
||||
"settings.dialog_title": "Paramètres",
|
||||
"settings.close": "Fermer",
|
||||
"settings.close_title": "Fermer (Échap)",
|
||||
|
||||
"settings.sync.stub": "Mode aperçu — le backend de synchronisation n'est pas encore implémenté (Phase 9). Les modifications ne sont conservées que dans l'interface.",
|
||||
"settings.sync.title": "Synchronisation et téléversement",
|
||||
"settings.sync.enable_label": "Activer le téléversement des artefacts de réunion vers les cibles configurées",
|
||||
"settings.sync.enable_hint": "Désactivé par défaut. Rien n'est téléversé sauf si ceci est activé et qu'une cible est activée. Les cibles auto-hébergées conservent les données sur votre propre serveur ; les clouds tiers sont clairement étiquetés.",
|
||||
"settings.sync.targets_title": "Cibles",
|
||||
"settings.sync.no_targets": "Aucune cible pour l'instant. Ajoutez-en une ci-dessous.",
|
||||
"settings.sync.third_party": "tiers",
|
||||
"settings.sync.your_server": "votre serveur",
|
||||
"settings.sync.edit": "Modifier",
|
||||
"settings.sync.remove": "Supprimer",
|
||||
"settings.sync.edit_target": "Modifier la cible",
|
||||
"settings.sync.add_target": "Ajouter une cible",
|
||||
"settings.sync.webdav_hint": "WebDAV couvre Nextcloud, ownCloud, Cloudreve, Seafile et Synology. (Seafile : activez SeafDAV côté serveur.)",
|
||||
"settings.sync.name": "Nom",
|
||||
"settings.sync.name_placeholder": "Nextcloud domestique",
|
||||
"settings.sync.provider": "Fournisseur",
|
||||
"settings.sync.server_url": "URL du serveur",
|
||||
"settings.sync.server_url_note_1": "Juste l'URL de votre serveur — WhispAssist ajoute",
|
||||
"settings.sync.server_url_note_2": "automatiquement.",
|
||||
"settings.sync.remote_folder": "Dossier distant",
|
||||
"settings.sync.username": "Nom d'utilisateur",
|
||||
"settings.sync.app_password": "Mot de passe d'application",
|
||||
"settings.sync.pw_keep": "laissez vide pour conserver le mot de passe actuel",
|
||||
"settings.sync.pw_store": "stocké dans le gestionnaire d'identifiants du système d'exploitation",
|
||||
"settings.sync.upload_legend": "Téléverser",
|
||||
"settings.sync.artifact_transcript": "transcription",
|
||||
"settings.sync.artifact_notes": "notes",
|
||||
"settings.sync.artifact_summary": "résumé",
|
||||
"settings.sync.artifact_recording": "enregistrement (.wav, si conservé)",
|
||||
"settings.sync.allow_plaintext": "Autoriser le http en clair pour une adresse LAN (non recommandé)",
|
||||
"settings.sync.encrypt_before": "Chiffrer avant le téléversement (la destination ne stocke que le texte chiffré)",
|
||||
"settings.sync.requires_vault": "— nécessite un coffre déverrouillé",
|
||||
"settings.sync.test_connection": "Tester la connexion",
|
||||
"settings.sync.save_changes": "Enregistrer les modifications",
|
||||
"settings.sync.add_target_btn": "Ajouter la cible",
|
||||
"settings.sync.cancel": "Annuler",
|
||||
"settings.sync.third_party_banner": "{kind} est un cloud tiers — le téléversement envoie vos données hors de votre appareil vers {kind}.",
|
||||
"settings.sync.link_account": "Lier le compte {kind}…",
|
||||
"settings.sync.oauth_hint": "Ouvre une connexion OAuth (redirection loopback) ; le jeton est stocké dans le gestionnaire d'identifiants du système d'exploitation.",
|
||||
"settings.sync.footnote": "Les identifiants ne sont jamais écrits dans les paramètres ni dans la base de données — uniquement dans le gestionnaire d'identifiants du système d'exploitation. TLS est requis pour les cibles hors LAN.",
|
||||
|
||||
"settings.ai.title": "Fournisseur de résumés IA",
|
||||
"settings.ai.intro_1": "Les résumés s'exécutent par défaut sur un LLM local. Pointez ceci vers Ollama sur ce PC ou une autre machine de votre LAN (par ex.",
|
||||
"settings.ai.intro_2": "— les deux comptent comme local, donc rien ne quitte votre réseau. Les fournisseurs hébergés (Anthropic) sont facultatifs, désactivés par défaut, et envoient la transcription à un tiers dès que vous en activez un.",
|
||||
"settings.ai.provider": "Fournisseur",
|
||||
"settings.ai.off": "Désactivé",
|
||||
"settings.ai.provider_ollama": "Ollama (local / LAN)",
|
||||
"settings.ai.provider_custom": "Personnalisé (compatible OpenAI)",
|
||||
"settings.ai.provider_anthropic": "Anthropic (Claude) — hébergé, quitte cet appareil",
|
||||
"settings.ai.endpoint": "Point de terminaison",
|
||||
"settings.ai.model": "Modèle",
|
||||
"settings.ai.api_key": "Clé API",
|
||||
"settings.ai.api_key_set_placeholder": "•••••••••••••••• (déjà définie — laissez vide pour la conserver)",
|
||||
"settings.ai.show_api_key": "Afficher la clé API",
|
||||
"settings.ai.hide_api_key": "Masquer la clé API",
|
||||
"settings.ai.anthropic_banner": "Anthropic est un service hébergé tiers — la transcription de cette réunion quitte votre appareil lorsque vous générez un résumé.",
|
||||
"settings.ai.endpoint_banner": "Ce point de terminaison n'est pas sur votre machine ni sur votre LAN — votre transcription quitterait votre réseau.",
|
||||
"settings.ai.saving": "Enregistrement…",
|
||||
"settings.ai.save": "Enregistrer",
|
||||
"settings.ai.test_connection": "Tester la connexion",
|
||||
"settings.ai.this_hosted_provider": "ce fournisseur hébergé",
|
||||
"settings.ai.reachable": "Joignable",
|
||||
"settings.ai.unreachable": "Injoignable",
|
||||
"settings.ai.model_count_one": "· {n} modèle",
|
||||
"settings.ai.model_count_many": "· {n} modèles",
|
||||
"settings.ai.on_lan": "· sur votre machine / LAN",
|
||||
"settings.ai.leaves_network": "· quitte votre réseau",
|
||||
"settings.ai.advanced_title": "Configuration avancée d'Ollama",
|
||||
"settings.ai.changed": "{n} modifiés",
|
||||
"settings.ai.reset_all": "Tout réinitialiser aux valeurs par défaut",
|
||||
"settings.ai.system_prompt": "Invite système",
|
||||
"settings.ai.system_prompt_placeholder": "par ex. Vous êtes un rédacteur de résumés de réunion concis.",
|
||||
"settings.ai.system_prompt_hint": "Ajoutée avant le format de sortie requis par WhispAssist, afin que vos instructions ne puissent pas casser l'analyse du résumé/des actions à mener.",
|
||||
"settings.ai.think": "Réfléchir",
|
||||
"settings.ai.think_help": "Effort de raisonnement (modèles de raisonnement uniquement).",
|
||||
"settings.ai.keep_alive": "Maintenir en vie",
|
||||
"settings.ai.keep_alive_help": "Durée pendant laquelle le modèle reste en RAM. par ex. 5m, 1h, 0 (décharger), -1 (indéfiniment).",
|
||||
"settings.ai.runtime_hardware": "Exécution et matériel",
|
||||
"settings.ai.rarely_needed": "— rarement nécessaire",
|
||||
"settings.ai.save_advanced": "Enregistrer les options avancées",
|
||||
"settings.ai.saved": "Enregistré",
|
||||
"settings.ai.turn_off": "Désactiver",
|
||||
"settings.ai.default_value": "Par défaut {value}.",
|
||||
"settings.ai.reset_default": "Réinitialiser à la valeur par défaut",
|
||||
"settings.ai.reset_field": "Réinitialiser {name}",
|
||||
|
||||
"settings.mcp.title": "Serveur MCP",
|
||||
"settings.mcp.banner_1": "Cela permet à votre propre agent de codage (Claude Code, Codex, Copilot, OpenCode, …) de récupérer le contexte de la réunion sur votre machine locale. Une fois connecté,",
|
||||
"settings.mcp.banner_agent": "cet agent",
|
||||
"settings.mcp.banner_2": "peut transmettre ce qu'il lit au cloud de son propre fournisseur de modèle — hors du contrôle de WhispAssist. WhispAssist lui-même n'envoie jamais ces données où que ce soit ; le serveur n'écoute que sur cet appareil (",
|
||||
"settings.mcp.banner_3": ") et chaque lecture est journalisée ci-dessous.",
|
||||
"settings.mcp.enable": "Activer le serveur MCP",
|
||||
"settings.mcp.enable_hint": "Désactivé par défaut. Loopback uniquement, protégé par jeton — rien n'est accessible depuis le réseau.",
|
||||
"settings.mcp.transport": "Transport",
|
||||
"settings.mcp.transport_http": "HTTP en flux continu",
|
||||
"settings.mcp.transport_stdio": "stdio (l'agent lance un processus)",
|
||||
"settings.mcp.port": "Port",
|
||||
"settings.mcp.transport_change_hint": "Pour changer de transport/port, désactivez d'abord le serveur, puis réactivez-le.",
|
||||
"settings.mcp.scope_title": "Portée",
|
||||
"settings.mcp.expose": "Exposer",
|
||||
"settings.mcp.scope_none": "Aucune — rien n'est partagé",
|
||||
"settings.mcp.scope_selected": "Sélectionnés — uniquement les briefs de fonctionnalité que vous avez marqués comme partagés",
|
||||
"settings.mcp.scope_all": "Tout — réunions, transcriptions, actions à mener et briefs partagés",
|
||||
"settings.mcp.expose_recordings": "Autoriser aussi les réunions avec un enregistrement sauvegardé (désactivé par défaut — la transcription d'une réunion enregistrée est retenue même en portée « Tout » jusqu'à ce que ceci soit activé)",
|
||||
"settings.mcp.token_title": "Nouveau jeton d'authentification — affiché une seule fois, copiez-le maintenant",
|
||||
"settings.mcp.token_hint": "Ceci ne sera plus affiché. Il est stocké dans le gestionnaire d'identifiants du système d'exploitation ; si vous le perdez, désactivez puis réactivez le serveur pour en générer un nouveau.",
|
||||
"settings.mcp.copied": "Copié",
|
||||
"settings.mcp.copy": "Copier",
|
||||
"settings.mcp.endpoint": "Point de terminaison",
|
||||
"settings.mcp.endpoint_hint": "Pointez la configuration du client MCP de votre agent vers {kind}, avec le jeton ci-dessus comme identifiant bearer.",
|
||||
"settings.mcp.kind_command": "commande",
|
||||
"settings.mcp.kind_url": "URL",
|
||||
"settings.mcp.access_log": "Journal d'accès",
|
||||
"settings.mcp.access_log_hint": "Chaque lecture d'outil effectuée par un agent, autorisée ou refusée (FR-MCP-5).",
|
||||
"settings.mcp.no_reads": "Aucun agent n'a encore rien lu.",
|
||||
"settings.mcp.log_meeting": "réunion {id}",
|
||||
"settings.mcp.refresh": "Actualiser",
|
||||
|
||||
"settings.privacy.title": "Confidentialité",
|
||||
"settings.privacy.intro": "Ce que WhispAssist est réellement autorisé à envoyer hors de cet appareil actuellement. Avec tout désactivé (le réglage par défaut), rien ne quitte l'appareil.",
|
||||
"settings.privacy.llm_endpoint": "Point de terminaison LLM",
|
||||
"settings.privacy.off": "désactivé",
|
||||
"settings.privacy.local_only": "local uniquement",
|
||||
"settings.privacy.leaves_device": "quitte cet appareil",
|
||||
"settings.privacy.sync_label": "Synchronisation",
|
||||
"settings.privacy.enabled": "activée",
|
||||
"settings.privacy.mcp_label": "Serveur MCP",
|
||||
"settings.privacy.mcp_on": "activé · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "Entrant sur loopback uniquement — n'ajoute rien à la liste de sortie ci-dessus. Un agent connecté peut néanmoins transmettre ce qu'il lit à son propre fournisseur de modèle ; voir l'onglet du serveur MCP.",
|
||||
"settings.privacy.egress_title": "Liste d'autorisation de sortie",
|
||||
"settings.privacy.no_egress": "Aucun hôte n'est autorisé — WA n'effectue aucune sortie de contenu.",
|
||||
"settings.privacy.sync_targets_title": "Cibles de synchronisation",
|
||||
"settings.privacy.third_party": "tiers",
|
||||
"settings.privacy.your_server": "votre serveur",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "sans TLS",
|
||||
"settings.privacy.refresh": "Actualiser",
|
||||
"settings.privacy.unavailable": "Autovérification de confidentialité indisponible.",
|
||||
"settings.privacy.vault_title": "Coffre de chiffrement",
|
||||
"settings.privacy.vault_intro": "Chiffrez les notes, transcriptions et résumés au repos avec un mot de passe. (Les fichiers audio ne sont pas encore chiffrés.)",
|
||||
"settings.privacy.vault_password": "Mot de passe du coffre",
|
||||
"settings.privacy.enable_vault": "Activer le coffre",
|
||||
"settings.privacy.vault_pw_hint": "Utilisez au moins 8 caractères. Si vous l'oubliez, le contenu chiffré ne pourra pas être récupéré.",
|
||||
"settings.privacy.vault_locked_1": "Le coffre est ",
|
||||
"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é",
|
||||
"settings.privacy.vault_unlocked_2": ". Les nouvelles notes, transcriptions et résumés sont chiffrés au repos.",
|
||||
"settings.privacy.lock_now": "Verrouiller maintenant",
|
||||
"settings.privacy.change_password": "Changer le mot de passe",
|
||||
"settings.privacy.current_password": "Mot de passe actuel",
|
||||
"settings.privacy.new_password": "Nouveau mot de passe",
|
||||
|
||||
"settings.about.title": "À propos",
|
||||
"settings.about.tagline": "Un assistant de réunion entièrement local, open source et natif Windows.",
|
||||
"settings.about.build_commit": "Commit de compilation",
|
||||
|
||||
"consent.heading": "Avant d'enregistrer",
|
||||
"consent.body": "Enregistrer des conversations sans le consentement des participants peut être illégal dans votre région. Vérifiez les lois locales sur l'enregistrement. Ceci est une mise en garde, pas un conseil juridique.",
|
||||
"consent.accept": "Je comprends — activer l'enregistrement",
|
||||
"consent.cancel": "Annuler",
|
||||
|
||||
"hosted.heading": "Avant d'utiliser {provider}",
|
||||
"hosted.body_1": "Générer avec",
|
||||
"hosted.body_2": "envoie la transcription de cette réunion sur leurs serveurs — elle quitte cet appareil et est soumise à leur politique de confidentialité. WhispAssist n'a aucun contrôle sur le traitement des données une fois qu'elles quittent votre appareil. Ceci est désactivé par défaut ; vous le choisissez maintenant.",
|
||||
"hosted.accept": "Je comprends — continuer",
|
||||
"hosted.cancel": "Annuler",
|
||||
|
||||
"theme.group": "Thème",
|
||||
"theme.system": "Suivre le système",
|
||||
"theme.light": "Clair",
|
||||
"theme.dark": "Sombre",
|
||||
|
||||
"import.dialog_label": "Ajouter une réunion depuis un fichier ou une URL",
|
||||
"import.title": "Ajouter une réunion",
|
||||
"import.close": "Fermer",
|
||||
"import.close_title": "Fermer (Échap)",
|
||||
"import.body": "Importez un enregistrement existant — un fichier audio/vidéo local ou un lien (YouTube, une page de streaming ou une URL média directe). Il est transcrit et diarisé comme un enregistrement en direct.",
|
||||
"import.file_or_url": "Fichier ou URL",
|
||||
"import.source_placeholder": "Collez une URL ou parcourez pour trouver un fichier…",
|
||||
"import.choose_file": "Choisir un fichier local",
|
||||
"import.browse": "Parcourir…",
|
||||
"import.title_label": "Titre",
|
||||
"import.optional": "facultatif",
|
||||
"import.title_placeholder": "Par défaut, le nom du fichier",
|
||||
"import.filter_av": "Audio / vidéo",
|
||||
"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}",
|
||||
|
||||
"levelmeter.system_mic": "Niveau d'entrée du système et du microphone",
|
||||
"levelmeter.system": "Niveau d'entrée du système",
|
||||
|
||||
"app.tagline": "local · privé",
|
||||
"app.note_template": "Modèle de notes",
|
||||
"app.no_template": "Aucun modèle",
|
||||
"app.add_meeting": "Ajouter une réunion",
|
||||
"app.add_meeting_title": "Ajouter une réunion depuis un fichier ou une URL",
|
||||
"app.record": "Enregistrer",
|
||||
"app.record_title": "Démarrer l'enregistrement (Ctrl+Maj+R)",
|
||||
"app.stop": "Arrêter",
|
||||
"app.stop_title": "Arrêter l'enregistrement (Ctrl+Maj+R)",
|
||||
"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",
|
||||
"app.not_saved": "non enregistré",
|
||||
"app.reconnecting": "reconnexion du périphérique audio…",
|
||||
"app.settings": "Paramètres",
|
||||
"app.settings_title": "Paramètres (Ctrl+,)",
|
||||
"app.discard_confirm": "Abandonner cet enregistrement ? Son audio et sa transcription seront supprimés.",
|
||||
"app.announce_started": "Enregistrement démarré",
|
||||
"app.announce_paused": "Enregistrement en pause",
|
||||
"app.announce_stopped": "Enregistrement arrêté",
|
||||
"app.consent_dialog": "Consentement à l'enregistrement",
|
||||
"app.vault_dialog": "Déverrouiller le coffre de chiffrement",
|
||||
"app.vault_title": "Déverrouiller le coffre de chiffrement",
|
||||
"app.vault_desc": "Vos réunions sont chiffrées au repos. Saisissez votre mot de passe pour les lire.",
|
||||
"app.vault_password": "Mot de passe du coffre",
|
||||
"app.vault_incorrect": "Mot de passe incorrect",
|
||||
"app.unlock": "Déverrouiller",
|
||||
"app.continue_locked": "Continuer verrouillé",
|
||||
"app.show_meetings": "Afficher la liste des réunions",
|
||||
"app.hide_meetings": "Masquer la liste des réunions",
|
||||
"app.show_summary": "Afficher le panneau de résumé",
|
||||
"app.hide_summary": "Masquer le panneau de résumé",
|
||||
"app.resize_meetings": "Redimensionner la liste des réunions",
|
||||
"app.resize_summary": "Redimensionner le panneau de résumé",
|
||||
|
||||
"meetings.search_placeholder": "Rechercher des réunions…",
|
||||
"meetings.search_aria": "Rechercher des réunions",
|
||||
"meetings.filter_tag_aria": "Filtrer par étiquette",
|
||||
"meetings.all_tags": "Toutes les étiquettes",
|
||||
"meetings.from_date_aria": "À partir de la date",
|
||||
"meetings.to_date_aria": "Jusqu'à la date",
|
||||
"meetings.bulk_format_aria": "Format d'exportation groupée",
|
||||
"meetings.bulk_export": "Exportation groupée",
|
||||
"meetings.bulk_export_title": "Exporter toutes les réunions correspondant aux filtres d'étiquette/date ci-dessus",
|
||||
"meetings.exporting": "Exportation…",
|
||||
"meetings.exported_one": "{n} réunion exportée.",
|
||||
"meetings.exported_many": "{n} réunions exportées.",
|
||||
"meetings.searching": "Recherche…",
|
||||
"meetings.loading": "Chargement des réunions…",
|
||||
"meetings.no_matches": "Aucun résultat.",
|
||||
"meetings.empty": "Aucune réunion pour l'instant — cliquez sur Enregistrer ci-dessus pour commencer.",
|
||||
"meetings.status.recording": "enregistrement",
|
||||
"meetings.status.transcribing": "transcription",
|
||||
"meetings.status.recovering": "récupération",
|
||||
"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",
|
||||
"transcript.hide": "Masquer la transcription",
|
||||
"transcript.empty": "Aucune transcription pour cette réunion.",
|
||||
"transcript.will_appear": "La transcription apparaîtra ici au fur et à mesure de l'enregistrement.",
|
||||
"transcript.play_from_here": "Lire à partir d'ici",
|
||||
"transcript.resize": "Redimensionner la transcription et les notes",
|
||||
"transcript.reprocess_placeholder": "Re-transcrire avec…",
|
||||
"transcript.reprocess_lang_aria": "Langue de retraitement",
|
||||
"transcript.reprocess_keep_lang": "Conserver la langue actuelle",
|
||||
"transcript.reprocess_go": "Lancer",
|
||||
"transcript.retranscribing": "Re-transcription…",
|
||||
"transcript.has_note": "Comporte une note",
|
||||
"transcript.note_placeholder": "Ajouter une note pour ce moment…",
|
||||
"transcript.note_aria": "Note pour cette ligne de transcription",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "Document Word",
|
||||
"transcript.filter_obsidian": "Note Obsidian",
|
||||
|
||||
"notes.heading": "Notes",
|
||||
"notes.show": "Afficher les notes",
|
||||
"notes.hide": "Masquer les notes",
|
||||
"notes.placeholder": "Notes…",
|
||||
"notes.live_placeholder": "Tapez des notes pendant que vous parlez…",
|
||||
"notes.toolbar_aria": "Mise en forme des notes",
|
||||
"notes.bold": "Gras",
|
||||
"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",
|
||||
"notes.preview": "Aperçu",
|
||||
"notes.export_md_title": "Exporter les notes en .md",
|
||||
"notes.export_pdf_title": "Exporter les notes en .pdf",
|
||||
"notes.export_docx_title": "Exporter les notes en .docx",
|
||||
"notes.export_bundle_title": "Exporter audio + transcription + notes vers un dossier",
|
||||
"notes.export_obsidian_title": "Exporter une note Obsidian (notes + résumé + transcription, sans audio)",
|
||||
|
||||
"summary.recording_heading": "Enregistrement",
|
||||
"summary.loading_recording": "Chargement de l'enregistrement…",
|
||||
"summary.sync_heading": "Synchronisation",
|
||||
"summary.uploading": "Téléversement…",
|
||||
"summary.upload_now": "Téléverser maintenant",
|
||||
"summary.no_uploads": "Aucun téléversement pour cette réunion pour l'instant.",
|
||||
"summary.retry": "réessayer",
|
||||
"summary.tags_heading": "Étiquettes",
|
||||
"summary.tags_select": "Sélectionnez une réunion pour l'étiqueter.",
|
||||
"summary.tag_add_placeholder": "Ajouter une étiquette…",
|
||||
"summary.tag_first_placeholder": "projet, client, sujet…",
|
||||
"summary.generating": "Génération…",
|
||||
"summary.generate_tags": "Générer des étiquettes",
|
||||
"summary.saving": "Enregistrement…",
|
||||
"summary.save_tags": "Enregistrer les étiquettes",
|
||||
"summary.summary_heading": "Résumé",
|
||||
"summary.ai_provider": "Fournisseur d'IA",
|
||||
"summary.provider_off": "Désactivé",
|
||||
"summary.provider_custom": "Personnalisé",
|
||||
"summary.local": "local",
|
||||
"summary.leaves_device": "quitte cet appareil",
|
||||
"summary.this_hosted_provider": "ce fournisseur hébergé",
|
||||
"summary.select_generate": "Sélectionnez une réunion pour générer un résumé.",
|
||||
"summary.decisions_heading": "Décisions",
|
||||
"summary.regenerate": "Régénérer",
|
||||
"summary.generated_hint": "Généré localement après la réunion (nécessite un fournisseur LLM local).",
|
||||
"summary.no_provider": "Aucun fournisseur d'IA configuré — activez-en un dans les Paramètres.",
|
||||
"summary.generate_summary": "Générer un résumé",
|
||||
"summary.briefs_heading": "Briefs de fonctionnalité",
|
||||
"summary.briefs_select": "Sélectionnez une réunion pour créer ou consulter des briefs de fonctionnalité.",
|
||||
"summary.briefs_desc": "Distille cette réunion en une spécification prête pour un agent (nécessite un fournisseur LLM local) — remettez-la à un agent de codage, ou servez-la via le serveur MCP une fois celui-ci activé.",
|
||||
"summary.brief_repo_placeholder": "Dépôt cible (facultatif), par ex. acme/reporting-web",
|
||||
"summary.distilling": "Distillation…",
|
||||
"summary.create_brief": "Créer un brief de fonctionnalité",
|
||||
"summary.brief_mcp_title": "Disponible lorsque le serveur MCP est activé",
|
||||
"summary.copied": "Copié",
|
||||
"summary.copy_md": "Copier en Markdown",
|
||||
"summary.copy_json": "Copier en JSON",
|
||||
"summary.brief_problem": "Problème",
|
||||
"summary.brief_outcome": "Résultat souhaité",
|
||||
"summary.brief_criteria": "Critères d'acceptation",
|
||||
"summary.brief_none": "Rien de capturé.",
|
||||
"summary.brief_context": "Contexte",
|
||||
"summary.action_items_heading": "Actions à mener",
|
||||
"summary.ai_select": "Sélectionnez une réunion pour voir ses actions à mener.",
|
||||
"summary.ai_empty": "Aucune pour l'instant — ajoutez-en une ci-dessous, ou générez un résumé pour les extraire automatiquement.",
|
||||
"summary.confirmed": "Confirmée",
|
||||
"summary.ai_text_placeholder": "Action à mener…",
|
||||
"summary.ai_text_aria": "Texte de l'action à mener",
|
||||
"summary.owner": "Responsable",
|
||||
"summary.due_date": "Date d'échéance",
|
||||
"summary.reminder_title": "Planifier un rappel local pour cette date d'échéance",
|
||||
"summary.ai_delete_title": "Supprimer cette action à mener",
|
||||
"summary.ai_delete_aria": "Supprimer l'action à mener",
|
||||
"summary.add_action_item": "Ajouter une action à mener",
|
||||
"summary.save_action_items": "Enregistrer les actions à mener",
|
||||
"summary.calendar_heading": "Événement du calendrier",
|
||||
"summary.cal_select": "Sélectionnez une réunion pour la lier à un événement du calendrier.",
|
||||
"summary.event_search_placeholder": "Rechercher un titre d'événement…",
|
||||
"summary.linked_event": "Événement lié",
|
||||
"summary.change_event": "Changer d'événement…",
|
||||
"summary.link_event": "Lier un événement…",
|
||||
"summary.untitled_event": "(sans titre)",
|
||||
"summary.no_events_1": "Aucun événement importé pour l'instant — importez-en un",
|
||||
"summary.no_events_2": "depuis Paramètres → Calendrier.",
|
||||
"summary.no_events_match": "Aucun événement ne correspond à cette recherche/date — essayez d'en effacer une.",
|
||||
"summary.organizer_label": "Organisateur : {name}",
|
||||
"summary.participants_heading": "Participants",
|
||||
"summary.participants_hint": "Renseigné à partir de l'événement du calendrier lié.",
|
||||
"summary.speakers_heading": "Locuteurs",
|
||||
"summary.speakers_select": "Sélectionnez une réunion pour nommer ses locuteurs.",
|
||||
"summary.no_speakers": "Aucun locuteur détecté pour l'instant.",
|
||||
"summary.speaker_name_placeholder": "Nom du locuteur",
|
||||
"summary.save": "Enregistrer",
|
||||
"summary.cancel": "Annuler",
|
||||
"summary.name_speaker": "Nommer ce locuteur…",
|
||||
"summary.add_new_name": "+ Ajouter un nouveau nom…",
|
||||
|
||||
"settings.recording.title": "Enregistrement",
|
||||
"settings.recording.record_default": "Enregistrer les réunions par défaut",
|
||||
"settings.recording.save_audio_as": "enregistrer l'audio en",
|
||||
"settings.recording.default_hint": "Désactivé par défaut. Lorsqu'il est désactivé, l'audio n'est utilisé que pour produire la transcription et est supprimé lorsque la réunion est finalisée. Vous pouvez aussi activer l'enregistrement par réunion.",
|
||||
"settings.recording.consent_label": "Consentement : {status}.",
|
||||
"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.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."
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"nav.recording": "रिकॉर्डिंग",
|
||||
"nav.hardware": "हार्डवेयर",
|
||||
"nav.storage": "संग्रहण",
|
||||
"nav.calendar": "कैलेंडर",
|
||||
"nav.sync": "सिंक",
|
||||
"nav.ai": "एआई",
|
||||
"nav.mcp": "MCP सर्वर",
|
||||
"nav.privacy": "गोपनीयता",
|
||||
"nav.about": "परिचय",
|
||||
"nav.language": "भाषा",
|
||||
|
||||
"settings.language.title": "भाषा",
|
||||
"settings.language.display": "प्रदर्शन भाषा",
|
||||
"settings.language.display_hint": "ऐप इंटरफ़ेस की भाषा। भाषा जोड़ना उतना ही आसान है जितना एक अनुवाद फ़ाइल डालना।",
|
||||
|
||||
"settings.transcription.title": "ट्रांसक्रिप्शन भाषा",
|
||||
"settings.transcription.label": "भाषा",
|
||||
"settings.transcription.auto": "स्वतः पहचानें",
|
||||
"settings.transcription.applies_hint": "अगली रिकॉर्डिंग पर लागू होता है। वास्तव में उपयोग की गई भाषा बाद में प्रत्येक मीटिंग पर दिखाई जाती है।",
|
||||
"settings.transcription.english_only": "केवल अंग्रेज़ी",
|
||||
"settings.transcription.model_english_only": "“{model}” केवल अंग्रेज़ी में है।",
|
||||
"settings.transcription.no_model": "कोई मॉडल चयनित नहीं।",
|
||||
"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": "पसंदीदा बैकएंड",
|
||||
"settings.hardware.auto_backend": "स्वतः (सर्वोत्तम उपलब्ध)",
|
||||
"settings.hardware.not_available": "उपलब्ध नहीं",
|
||||
"settings.hardware.fallback_hint": "यदि चयनित बैकएंड लोड नहीं होता तो स्वचालित रूप से फ़ॉलबैक करता है (NPU → NVIDIA → AMD → Intel → CPU)।",
|
||||
"settings.hardware.audio_devices": "ऑडियो डिवाइस",
|
||||
"settings.hardware.recording_device": "रिकॉर्डिंग डिवाइस",
|
||||
"settings.hardware.default_system_audio": "डिफ़ॉल्ट सिस्टम ऑडियो",
|
||||
"settings.hardware.recording_device_hint": "WhispAssist वह रिकॉर्ड करता है जो यह डिवाइस बजाता है (लूपबैक) — कॉल का दूसरा पक्ष। यदि आप नहीं चाहते कि यह Windows के सिस्टम डिफ़ॉल्ट का अनुसरण करे तो कोई विशिष्ट आउटपुट चुनें।",
|
||||
"settings.hardware.microphone": "माइक्रोफ़ोन",
|
||||
"settings.hardware.mic_off": "बंद — मेरा माइक्रोफ़ोन कैप्चर न करें",
|
||||
"settings.hardware.default_mic": "डिफ़ॉल्ट माइक्रोफ़ोन",
|
||||
"settings.hardware.mic_hint": "आपकी अपनी आवाज़ को लाइव ट्रांसक्रिप्ट में जोड़ता है ताकि मीटिंग के दोनों पक्ष कैप्चर हों। आपके डिवाइस पर रहता है — कुछ भी अपलोड नहीं होता। केवल ऊपर के सिस्टम ऑडियो को ट्रांसक्राइब करने के लिए “बंद” चुनें।",
|
||||
"settings.hardware.npu_detected": "NPU एक्सेलेरेटर पाया गया",
|
||||
"settings.hardware.ready": "तैयार",
|
||||
"settings.hardware.downloading": "डाउनलोड हो रहा है…",
|
||||
"settings.hardware.package_needed": "पैकेज आवश्यक",
|
||||
"settings.hardware.npu_hint": "आपके NPU को एक बार का एक्सेलेरेशन पैकेज (Whisper ONNX मॉडल{extra}) चाहिए। यह पृष्ठभूमि में डाउनलोड होता है; आप इसे अभी भी शुरू कर सकते हैं।",
|
||||
"settings.hardware.plus_openvino": " + OpenVINO रनटाइम",
|
||||
"settings.hardware.download_npu": "NPU पैकेज डाउनलोड करें",
|
||||
"settings.hardware.dml_title": "GPU एक्सेलेरेशन (DirectML)",
|
||||
"settings.hardware.dml_hint": "आपके GPU को Vulkan के बिना DirectML के माध्यम से एक्सेलेरेट किया जा सकता है — एक बार का पैकेज (Whisper ONNX मॉडल{extra})।",
|
||||
"settings.hardware.plus_directml": " + DirectML रनटाइम",
|
||||
"settings.hardware.download_dml": "DirectML पैकेज डाउनलोड करें",
|
||||
"settings.hardware.detection_unavailable": "हार्डवेयर पहचान उपलब्ध नहीं।",
|
||||
"settings.hardware.low_overhead": "कम ओवरहेड प्रीसेट",
|
||||
"settings.hardware.low_overhead_note": "CPU + सबसे छोटा मॉडल, बैटरी/पृष्ठभूमि उपयोग के लिए",
|
||||
"settings.hardware.models": "मॉडल",
|
||||
"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": "सक्रिय मॉडल को नहीं हटाया जा सकता",
|
||||
"settings.hardware.download": "डाउनलोड करें",
|
||||
"settings.hardware.diarization": "वक्ता डायराइज़ेशन",
|
||||
"settings.hardware.diarization_hint": "दोनों मॉडल इंस्टॉल करें ताकि पूर्ण रिकॉर्डिंग एक चालू ट्रांसक्रिप्ट के बजाय वक्ता के अनुसार (वक्ता 1, वक्ता 2, …) अलग हों। पूरी तरह ऑफ़लाइन चलता है, रुकने के बाद प्रति मीटिंग एक बार। इनके बिना, प्रत्येक पंक्ति एक ही वक्ता को दी जाती है।",
|
||||
|
||||
"settings.storage.title": "संग्रहण",
|
||||
"settings.storage.location_label": "स्थान",
|
||||
"settings.storage.location_hint": "संग्रहण स्थान बदलना अभी समर्थित नहीं है — यहीं वर्तमान में मीटिंग, मॉडल और डेटाबेस रहते हैं।",
|
||||
"settings.storage.retention_title": "प्रतिधारण",
|
||||
"settings.storage.retention_hint": "सीमा से अधिक सबसे पुरानी मीटिंग को स्वचालित रूप से हटाता है। स्टार्टअप पर एक बार जाँचा जाता है; खाली का अर्थ कोई सीमा नहीं। वर्तमान में रिकॉर्ड हो रही मीटिंग को कभी नहीं छूता।",
|
||||
"settings.storage.max_age": "अधिकतम आयु (दिन)",
|
||||
"settings.storage.max_size": "अधिकतम आकार (GB)",
|
||||
"settings.storage.no_limit": "कोई सीमा नहीं",
|
||||
"settings.storage.export_import_title": "निर्यात और आयात",
|
||||
"settings.storage.export_hint_1": "रिकॉर्डिंग को कंप्यूटरों के बीच पोर्टेबल बंडल फ़ोल्डर के रूप में ले जाएँ (ऑडियो, ट्रांसक्रिप्ट, नोट्स, सारांश और एक",
|
||||
"settings.storage.export_hint_2": "मैनिफ़ेस्ट)। किसी भी फ़ोल्डर में निर्यात करें — एक सिंक की गई ड्राइव, एक USB स्टिक, या किसी सिंक लक्ष्य के स्थानीय माउंट में — फिर दूसरी मशीन पर इसे आयात करें। आयातित मीटिंग को नया id मिलता है, इसलिए फिर से आयात करने से कभी कुछ अधिलेखित नहीं होता।",
|
||||
"settings.storage.export_dialog_title": "सभी मीटिंग को यहाँ निर्यात करें…",
|
||||
"settings.storage.import_dialog_title": "एक बंडल (या बंडलों का फ़ोल्डर) आयात करें…",
|
||||
"settings.storage.exporting": "निर्यात हो रहा है…",
|
||||
"settings.storage.export_all": "सभी मीटिंग निर्यात करें…",
|
||||
"settings.storage.importing": "आयात हो रहा है…",
|
||||
"settings.storage.import_all": "मीटिंग आयात करें…",
|
||||
"settings.storage.exported_one": "{n} मीटिंग निर्यात की गई।",
|
||||
"settings.storage.exported_many": "{n} मीटिंग निर्यात की गईं।",
|
||||
"settings.storage.imported_one": "{n} मीटिंग आयात की गई।",
|
||||
"settings.storage.imported_many": "{n} मीटिंग आयात की गईं।",
|
||||
|
||||
"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": "ब्राउज़ करें…",
|
||||
"settings.calendar.password": "पासवर्ड",
|
||||
"settings.calendar.password_note": "शायद ही कभी आवश्यक",
|
||||
"settings.calendar.optional": "वैकल्पिक",
|
||||
"settings.calendar.import_range": "आयात सीमा",
|
||||
"settings.calendar.range_30": "पिछले 30 दिन",
|
||||
"settings.calendar.range_90": "पिछले 90 दिन",
|
||||
"settings.calendar.range_180": "पिछले 6 महीने",
|
||||
"settings.calendar.range_365": "पिछला 1 वर्ष",
|
||||
"settings.calendar.range_all": "सभी समय",
|
||||
"settings.calendar.range_hint": "एक लंबे समय तक चलने वाले मेलबॉक्स में वर्षों की आवर्ती/अवकाश प्रविष्टियाँ हो सकती हैं — सीमा को संकुचित करने से आयातित कैलेंडर उसी तक सीमित रहता है जो वास्तव में प्रासंगिक है। यह इस आयात बटन और लॉन्च पर स्वचालित पुनः आयात दोनों पर लागू होता है।",
|
||||
"settings.calendar.importing": "आयात हो रहा है…",
|
||||
"settings.calendar.import": "आयात करें",
|
||||
"settings.calendar.import_progress": "{processed}/{total} ईवेंट",
|
||||
"settings.calendar.auto_reimport": "लॉन्च पर इस फ़ाइल को स्वचालित रूप से पुनः आयात करें",
|
||||
"settings.calendar.auto_reimport_hint": "स्टार्टअप पर एक बार चलता है, टाइमर पर नहीं — पुनः आयात दोहराना सुरक्षित है (मौजूदा ईवेंट मिलान कर अपडेट किए जाते हैं, डुप्लिकेट नहीं)।",
|
||||
"settings.calendar.import_failed": "आयात विफल: {error} — फ़ाइल स्वयं अछूती रहती है; पथ जाँचें और फिर से प्रयास करें।",
|
||||
"settings.calendar.cleanup_title": "साफ़ करें",
|
||||
"settings.calendar.cleanup_hint": "किसी रिकॉर्ड की गई मीटिंग से पहले से जुड़े ईवेंट हमेशा रखे जाते हैं, चाहे नीचे कोई भी विकल्प चुना जाए।",
|
||||
"settings.calendar.cleanup_30": "30 दिन से पुराने",
|
||||
"settings.calendar.cleanup_90": "90 दिन से पुराने",
|
||||
"settings.calendar.cleanup_180": "6 महीने से पुराने",
|
||||
"settings.calendar.cleanup_365": "1 वर्ष से पुराने",
|
||||
"settings.calendar.cleanup_all": "सभी हटाएँ",
|
||||
"settings.calendar.cleaning": "साफ़ किया जा रहा है…",
|
||||
"settings.calendar.cleanup_btn": "कैलेंडर साफ़ करें",
|
||||
"settings.calendar.cleanup_result": "{deleted}{extra} हटाए गए।",
|
||||
"settings.calendar.cleanup_kept": ", {n} रखे गए (किसी मीटिंग से जुड़े)",
|
||||
"settings.calendar.cleanup_failed": "साफ़ करना विफल: {error}",
|
||||
"settings.calendar.imported_events": "आयातित ईवेंट",
|
||||
"settings.calendar.no_events": "अभी तक कोई ईवेंट आयात नहीं किया गया।",
|
||||
"settings.calendar.search_title": "शीर्षक खोजें",
|
||||
"settings.calendar.search_placeholder": "मीटिंग का नाम…",
|
||||
"settings.calendar.date_label": "तारीख़",
|
||||
"settings.calendar.events_count": "{total} में से {shown} ईवेंट",
|
||||
"settings.calendar.untitled": "(अशीर्षक)",
|
||||
|
||||
"settings.dialog_title": "सेटिंग्स",
|
||||
"settings.close": "बंद करें",
|
||||
"settings.close_title": "बंद करें (Esc)",
|
||||
|
||||
"settings.sync.stub": "पूर्वावलोकन मोड — सिंक बैकएंड अभी लागू नहीं हुआ है (चरण 9)। परिवर्तन केवल इंटरफ़ेस में रखे जाते हैं।",
|
||||
"settings.sync.title": "सिंक और अपलोड",
|
||||
"settings.sync.enable_label": "कॉन्फ़िगर किए गए लक्ष्यों पर मीटिंग आर्टिफ़ैक्ट अपलोड करना सक्षम करें",
|
||||
"settings.sync.enable_hint": "डिफ़ॉल्ट रूप से बंद। जब तक यह चालू न हो और कोई लक्ष्य सक्षम न हो, कुछ भी अपलोड नहीं होता। स्व-होस्ट किए गए लक्ष्य डेटा को आपके अपने सर्वर पर रखते हैं; तृतीय-पक्ष क्लाउड स्पष्ट रूप से लेबल किए गए हैं।",
|
||||
"settings.sync.targets_title": "लक्ष्य",
|
||||
"settings.sync.no_targets": "अभी तक कोई लक्ष्य नहीं। नीचे एक जोड़ें।",
|
||||
"settings.sync.third_party": "तृतीय-पक्ष",
|
||||
"settings.sync.your_server": "आपका सर्वर",
|
||||
"settings.sync.edit": "संपादित करें",
|
||||
"settings.sync.remove": "हटाएँ",
|
||||
"settings.sync.edit_target": "लक्ष्य संपादित करें",
|
||||
"settings.sync.add_target": "एक लक्ष्य जोड़ें",
|
||||
"settings.sync.webdav_hint": "WebDAV Nextcloud, ownCloud, Cloudreve, Seafile और Synology को कवर करता है। (Seafile: सर्वर-साइड पर SeafDAV सक्षम करें।)",
|
||||
"settings.sync.name": "नाम",
|
||||
"settings.sync.name_placeholder": "होम Nextcloud",
|
||||
"settings.sync.provider": "प्रदाता",
|
||||
"settings.sync.server_url": "सर्वर URL",
|
||||
"settings.sync.server_url_note_1": "बस आपका सर्वर URL — WhispAssist स्वचालित रूप से",
|
||||
"settings.sync.server_url_note_2": "जोड़ता है।",
|
||||
"settings.sync.remote_folder": "रिमोट फ़ोल्डर",
|
||||
"settings.sync.username": "उपयोगकर्ता नाम",
|
||||
"settings.sync.app_password": "ऐप पासवर्ड",
|
||||
"settings.sync.pw_keep": "वर्तमान पासवर्ड रखने के लिए खाली छोड़ें",
|
||||
"settings.sync.pw_store": "OS क्रेडेंशियल स्टोर में संग्रहीत",
|
||||
"settings.sync.upload_legend": "अपलोड",
|
||||
"settings.sync.artifact_transcript": "ट्रांसक्रिप्ट",
|
||||
"settings.sync.artifact_notes": "नोट्स",
|
||||
"settings.sync.artifact_summary": "सारांश",
|
||||
"settings.sync.artifact_recording": "रिकॉर्डिंग (.wav, यदि रखी गई)",
|
||||
"settings.sync.allow_plaintext": "किसी LAN पते के लिए सादा http अनुमति दें (अनुशंसित नहीं)",
|
||||
"settings.sync.encrypt_before": "अपलोड से पहले एन्क्रिप्ट करें (गंतव्य केवल सिफरटेक्स्ट संग्रहीत करता है)",
|
||||
"settings.sync.requires_vault": "— एक अनलॉक किए गए वॉल्ट की आवश्यकता है",
|
||||
"settings.sync.test_connection": "कनेक्शन जाँचें",
|
||||
"settings.sync.save_changes": "परिवर्तन सहेजें",
|
||||
"settings.sync.add_target_btn": "लक्ष्य जोड़ें",
|
||||
"settings.sync.cancel": "रद्द करें",
|
||||
"settings.sync.third_party_banner": "{kind} एक तृतीय-पक्ष क्लाउड है — अपलोड करने से आपका डेटा आपके डिवाइस से बाहर {kind} पर भेजा जाता है।",
|
||||
"settings.sync.link_account": "{kind} खाता लिंक करें…",
|
||||
"settings.sync.oauth_hint": "एक OAuth साइन-इन खोलता है (लूपबैक रीडायरेक्ट); टोकन आपके OS क्रेडेंशियल स्टोर में संग्रहीत होता है।",
|
||||
"settings.sync.footnote": "क्रेडेंशियल कभी भी सेटिंग्स या डेटाबेस में नहीं लिखे जाते — केवल OS क्रेडेंशियल स्टोर में। गैर-LAN लक्ष्यों के लिए TLS आवश्यक है।",
|
||||
|
||||
"settings.ai.title": "एआई सारांश प्रदाता",
|
||||
"settings.ai.intro_1": "सारांश डिफ़ॉल्ट रूप से एक स्थानीय LLM पर चलते हैं। इसे इस PC या आपके LAN की किसी अन्य मशीन पर Ollama की ओर इंगित करें (जैसे",
|
||||
"settings.ai.intro_2": "— दोनों स्थानीय के रूप में गिने जाते हैं, इसलिए आपके नेटवर्क से कुछ भी बाहर नहीं जाता। होस्ट किए गए प्रदाता (Anthropic) वैकल्पिक हैं, डिफ़ॉल्ट रूप से बंद हैं, और जैसे ही आप किसी एक को चालू करते हैं ट्रांसक्रिप्ट को किसी तृतीय पक्ष को भेजते हैं।",
|
||||
"settings.ai.provider": "प्रदाता",
|
||||
"settings.ai.off": "बंद",
|
||||
"settings.ai.provider_ollama": "Ollama (स्थानीय / LAN)",
|
||||
"settings.ai.provider_custom": "कस्टम (OpenAI-संगत)",
|
||||
"settings.ai.provider_anthropic": "Anthropic (Claude) — होस्ट किया गया, इस डिवाइस से बाहर जाता है",
|
||||
"settings.ai.endpoint": "एंडपॉइंट",
|
||||
"settings.ai.model": "मॉडल",
|
||||
"settings.ai.api_key": "API कुंजी",
|
||||
"settings.ai.api_key_set_placeholder": "•••••••••••••••• (पहले से सेट — रखने के लिए खाली छोड़ें)",
|
||||
"settings.ai.show_api_key": "API कुंजी दिखाएँ",
|
||||
"settings.ai.hide_api_key": "API कुंजी छिपाएँ",
|
||||
"settings.ai.anthropic_banner": "Anthropic एक होस्ट की गई तृतीय-पक्ष सेवा है — जब आप सारांश उत्पन्न करते हैं तो इस मीटिंग का ट्रांसक्रिप्ट आपके डिवाइस से बाहर जाता है।",
|
||||
"settings.ai.endpoint_banner": "यह एंडपॉइंट आपकी मशीन या LAN पर नहीं है — आपका ट्रांसक्रिप्ट आपके नेटवर्क से बाहर चला जाएगा।",
|
||||
"settings.ai.saving": "सहेजा जा रहा है…",
|
||||
"settings.ai.save": "सहेजें",
|
||||
"settings.ai.test_connection": "कनेक्शन जाँचें",
|
||||
"settings.ai.this_hosted_provider": "यह होस्ट किया गया प्रदाता",
|
||||
"settings.ai.reachable": "पहुँच योग्य",
|
||||
"settings.ai.unreachable": "पहुँच से बाहर",
|
||||
"settings.ai.model_count_one": "· {n} मॉडल",
|
||||
"settings.ai.model_count_many": "· {n} मॉडल",
|
||||
"settings.ai.on_lan": "· आपकी मशीन / LAN पर",
|
||||
"settings.ai.leaves_network": "· आपके नेटवर्क से बाहर जाता है",
|
||||
"settings.ai.advanced_title": "उन्नत Ollama कॉन्फ़िगरेशन",
|
||||
"settings.ai.changed": "{n} बदले गए",
|
||||
"settings.ai.reset_all": "सभी को डिफ़ॉल्ट पर रीसेट करें",
|
||||
"settings.ai.system_prompt": "सिस्टम प्रॉम्प्ट",
|
||||
"settings.ai.system_prompt_placeholder": "जैसे आप एक संक्षिप्त मीटिंग सारांशकर्ता हैं।",
|
||||
"settings.ai.system_prompt_hint": "WhispAssist के आवश्यक आउटपुट प्रारूप से पहले जोड़ा जाता है, ताकि आपके निर्देश सारांश/कार्य-आइटम पार्सिंग को न तोड़ सकें।",
|
||||
"settings.ai.think": "सोचें",
|
||||
"settings.ai.think_help": "तर्क प्रयास (केवल तर्क मॉडल)।",
|
||||
"settings.ai.keep_alive": "सक्रिय रखें",
|
||||
"settings.ai.keep_alive_help": "मॉडल RAM में कितनी देर रहता है। जैसे 5m, 1h, 0 (अनलोड), -1 (हमेशा)।",
|
||||
"settings.ai.runtime_hardware": "रनटाइम और हार्डवेयर",
|
||||
"settings.ai.rarely_needed": "— शायद ही कभी आवश्यक",
|
||||
"settings.ai.save_advanced": "उन्नत सहेजें",
|
||||
"settings.ai.saved": "सहेजा गया",
|
||||
"settings.ai.turn_off": "बंद करें",
|
||||
"settings.ai.default_value": "डिफ़ॉल्ट {value}।",
|
||||
"settings.ai.reset_default": "डिफ़ॉल्ट पर रीसेट करें",
|
||||
"settings.ai.reset_field": "{name} रीसेट करें",
|
||||
|
||||
"settings.mcp.title": "MCP सर्वर",
|
||||
"settings.mcp.banner_1": "यह आपके अपने कोडिंग एजेंट (Claude Code, Codex, Copilot, OpenCode, …) को आपकी स्थानीय मशीन पर मीटिंग संदर्भ प्राप्त करने देता है। कनेक्ट होने के बाद,",
|
||||
"settings.mcp.banner_agent": "वह एजेंट",
|
||||
"settings.mcp.banner_2": "जो पढ़ता है उसे अपने स्वयं के मॉडल प्रदाता के क्लाउड में अग्रेषित कर सकता है — WhispAssist के नियंत्रण से बाहर। WhispAssist स्वयं यह डेटा कभी कहीं नहीं भेजता; सर्वर केवल इस डिवाइस पर सुनता है (",
|
||||
"settings.mcp.banner_3": ") और प्रत्येक रीड नीचे लॉग किया जाता है।",
|
||||
"settings.mcp.enable": "MCP सर्वर सक्षम करें",
|
||||
"settings.mcp.enable_hint": "डिफ़ॉल्ट रूप से बंद। केवल लूपबैक, टोकन-सुरक्षित — नेटवर्क से कुछ भी पहुँच योग्य नहीं।",
|
||||
"settings.mcp.transport": "ट्रांसपोर्ट",
|
||||
"settings.mcp.transport_http": "स्ट्रीमिंग HTTP",
|
||||
"settings.mcp.transport_stdio": "stdio (एजेंट एक प्रक्रिया उत्पन्न करता है)",
|
||||
"settings.mcp.port": "पोर्ट",
|
||||
"settings.mcp.transport_change_hint": "ट्रांसपोर्ट/पोर्ट बदलने के लिए, पहले सर्वर बंद करें, फिर वापस चालू करें।",
|
||||
"settings.mcp.scope_title": "दायरा",
|
||||
"settings.mcp.expose": "उजागर करें",
|
||||
"settings.mcp.scope_none": "कोई नहीं — कुछ भी साझा नहीं",
|
||||
"settings.mcp.scope_selected": "चयनित — केवल वे फ़ीचर ब्रीफ़ जिन्हें आपने साझा के रूप में चिह्नित किया है",
|
||||
"settings.mcp.scope_all": "सभी — मीटिंग, ट्रांसक्रिप्ट, कार्य-आइटम और साझा ब्रीफ़",
|
||||
"settings.mcp.expose_recordings": "सहेजी गई रिकॉर्डिंग वाली मीटिंग को भी अनुमति दें (डिफ़ॉल्ट रूप से बंद — जब तक यह चालू न हो, किसी रिकॉर्ड की गई मीटिंग का ट्रांसक्रिप्ट “सभी” दायरे में भी रोका जाता है)",
|
||||
"settings.mcp.token_title": "नया प्रमाणीकरण टोकन — एक बार दिखाया गया, इसे अभी कॉपी करें",
|
||||
"settings.mcp.token_hint": "यह फिर से नहीं दिखाया जाएगा। यह आपके OS क्रेडेंशियल स्टोर में संग्रहीत है; यदि आप इसे खो देते हैं, तो नया बनाने के लिए सर्वर बंद करें और वापस चालू करें।",
|
||||
"settings.mcp.copied": "कॉपी किया गया",
|
||||
"settings.mcp.copy": "कॉपी करें",
|
||||
"settings.mcp.endpoint": "एंडपॉइंट",
|
||||
"settings.mcp.endpoint_hint": "अपने एजेंट के MCP क्लाइंट कॉन्फ़िगरेशन को {kind} की ओर इंगित करें, ऊपर के टोकन को bearer क्रेडेंशियल के रूप में उपयोग करते हुए।",
|
||||
"settings.mcp.kind_command": "कमांड",
|
||||
"settings.mcp.kind_url": "URL",
|
||||
"settings.mcp.access_log": "एक्सेस लॉग",
|
||||
"settings.mcp.access_log_hint": "एजेंट द्वारा किया गया प्रत्येक टूल रीड, अनुमत या अस्वीकृत (FR-MCP-5)।",
|
||||
"settings.mcp.no_reads": "अभी तक किसी एजेंट ने कुछ नहीं पढ़ा है।",
|
||||
"settings.mcp.log_meeting": "मीटिंग {id}",
|
||||
"settings.mcp.refresh": "रिफ़्रेश करें",
|
||||
|
||||
"settings.privacy.title": "गोपनीयता",
|
||||
"settings.privacy.intro": "WhispAssist को अभी वास्तव में इस डिवाइस से बाहर क्या भेजने की अनुमति है। सब कुछ बंद होने पर (डिफ़ॉल्ट), डिवाइस से कुछ भी बाहर नहीं जाता।",
|
||||
"settings.privacy.llm_endpoint": "LLM एंडपॉइंट",
|
||||
"settings.privacy.off": "बंद",
|
||||
"settings.privacy.local_only": "केवल स्थानीय",
|
||||
"settings.privacy.leaves_device": "इस डिवाइस से बाहर जाता है",
|
||||
"settings.privacy.sync_label": "सिंक",
|
||||
"settings.privacy.enabled": "सक्षम",
|
||||
"settings.privacy.mcp_label": "MCP सर्वर",
|
||||
"settings.privacy.mcp_on": "चालू · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "केवल लूपबैक पर इनबाउंड — यह ऊपर की आउटगोइंग सूची में कुछ नहीं जोड़ता। एक कनेक्ट किया गया एजेंट फिर भी जो पढ़ता है उसे अपने स्वयं के मॉडल प्रदाता को अग्रेषित कर सकता है; MCP सर्वर टैब देखें।",
|
||||
"settings.privacy.egress_title": "आउटगोइंग अनुमति सूची",
|
||||
"settings.privacy.no_egress": "कोई होस्ट अनुमत सूची में नहीं है — WA कोई सामग्री आउटगोइंग नहीं करता।",
|
||||
"settings.privacy.sync_targets_title": "सिंक लक्ष्य",
|
||||
"settings.privacy.third_party": "तृतीय-पक्ष",
|
||||
"settings.privacy.your_server": "आपका सर्वर",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "कोई TLS नहीं",
|
||||
"settings.privacy.refresh": "रिफ़्रेश करें",
|
||||
"settings.privacy.unavailable": "गोपनीयता स्व-जाँच उपलब्ध नहीं।",
|
||||
"settings.privacy.vault_title": "एन्क्रिप्शन वॉल्ट",
|
||||
"settings.privacy.vault_intro": "एक पासवर्ड के साथ नोट्स, ट्रांसक्रिप्ट और सारांश को स्थिर अवस्था में एन्क्रिप्ट करें। (ऑडियो फ़ाइलें अभी एन्क्रिप्ट नहीं की जातीं।)",
|
||||
"settings.privacy.vault_password": "वॉल्ट पासवर्ड",
|
||||
"settings.privacy.enable_vault": "वॉल्ट सक्षम करें",
|
||||
"settings.privacy.vault_pw_hint": "कम से कम 8 वर्ण उपयोग करें। यदि आप इसे भूल जाते हैं, तो एन्क्रिप्ट की गई सामग्री पुनर्प्राप्त नहीं की जा सकती।",
|
||||
"settings.privacy.vault_locked_1": "वॉल्ट ",
|
||||
"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": "अनलॉक",
|
||||
"settings.privacy.vault_unlocked_2": " है। नए नोट्स, ट्रांसक्रिप्ट और सारांश स्थिर अवस्था में एन्क्रिप्ट किए जाते हैं।",
|
||||
"settings.privacy.lock_now": "अभी लॉक करें",
|
||||
"settings.privacy.change_password": "पासवर्ड बदलें",
|
||||
"settings.privacy.current_password": "वर्तमान पासवर्ड",
|
||||
"settings.privacy.new_password": "नया पासवर्ड",
|
||||
|
||||
"settings.about.title": "परिचय",
|
||||
"settings.about.tagline": "एक पूर्णतः स्थानीय, ओपन-सोर्स, Windows-नेटिव मीटिंग सहायक।",
|
||||
"settings.about.build_commit": "बिल्ड कमिट",
|
||||
|
||||
"consent.heading": "रिकॉर्ड करने से पहले",
|
||||
"consent.body": "प्रतिभागियों की सहमति के बिना बातचीत रिकॉर्ड करना आपके क्षेत्र में अवैध हो सकता है। अपने स्थानीय रिकॉर्डिंग कानूनों की जाँच करें। यह एक सावधानी है, कानूनी सलाह नहीं।",
|
||||
"consent.accept": "मैं समझता हूँ — रिकॉर्डिंग सक्षम करें",
|
||||
"consent.cancel": "रद्द करें",
|
||||
|
||||
"hosted.heading": "{provider} का उपयोग करने से पहले",
|
||||
"hosted.body_1": "के साथ उत्पन्न करना",
|
||||
"hosted.body_2": "इस मीटिंग के ट्रांसक्रिप्ट को उनके सर्वर पर भेजता है — यह इस डिवाइस से बाहर जाता है और उनकी गोपनीयता नीति के अधीन है। एक बार डेटा आपके डिवाइस से बाहर जाने पर WhispAssist का डेटा हैंडलिंग पर कोई नियंत्रण नहीं रहता। यह डिफ़ॉल्ट रूप से बंद है; आप इसे अभी चुन रहे हैं।",
|
||||
"hosted.accept": "मैं समझता हूँ — जारी रखें",
|
||||
"hosted.cancel": "रद्द करें",
|
||||
|
||||
"theme.group": "थीम",
|
||||
"theme.system": "सिस्टम से मिलाएँ",
|
||||
"theme.light": "हल्का",
|
||||
"theme.dark": "गहरा",
|
||||
|
||||
"import.dialog_label": "किसी फ़ाइल या URL से मीटिंग जोड़ें",
|
||||
"import.title": "मीटिंग जोड़ें",
|
||||
"import.close": "बंद करें",
|
||||
"import.close_title": "बंद करें (Esc)",
|
||||
"import.body": "एक मौजूदा रिकॉर्डिंग आयात करें — एक स्थानीय ऑडियो/वीडियो फ़ाइल, या एक लिंक (YouTube, एक स्ट्रीमिंग पेज, या एक सीधा मीडिया URL)। इसे लाइव रिकॉर्डिंग की तरह ही ट्रांसक्राइब और डायराइज़ किया जाता है।",
|
||||
"import.file_or_url": "फ़ाइल या URL",
|
||||
"import.source_placeholder": "एक URL पेस्ट करें, या फ़ाइल के लिए ब्राउज़ करें…",
|
||||
"import.choose_file": "एक स्थानीय फ़ाइल चुनें",
|
||||
"import.browse": "ब्राउज़ करें…",
|
||||
"import.title_label": "शीर्षक",
|
||||
"import.optional": "वैकल्पिक",
|
||||
"import.title_placeholder": "डिफ़ॉल्ट रूप से फ़ाइल का नाम",
|
||||
"import.filter_av": "ऑडियो / वीडियो",
|
||||
"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} हटाएँ",
|
||||
|
||||
"levelmeter.system_mic": "सिस्टम और माइक्रोफ़ोन इनपुट स्तर",
|
||||
"levelmeter.system": "सिस्टम इनपुट स्तर",
|
||||
|
||||
"app.tagline": "स्थानीय · निजी",
|
||||
"app.note_template": "नोट टेम्पलेट",
|
||||
"app.no_template": "कोई टेम्पलेट नहीं",
|
||||
"app.add_meeting": "मीटिंग जोड़ें",
|
||||
"app.add_meeting_title": "किसी फ़ाइल या URL से मीटिंग जोड़ें",
|
||||
"app.record": "रिकॉर्ड करें",
|
||||
"app.record_title": "रिकॉर्डिंग शुरू करें (Ctrl+Shift+R)",
|
||||
"app.stop": "रोकें",
|
||||
"app.stop_title": "रिकॉर्डिंग रोकें (Ctrl+Shift+R)",
|
||||
"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": "सहेजा जा रहा है",
|
||||
"app.not_saved": "सहेजा नहीं गया",
|
||||
"app.reconnecting": "ऑडियो डिवाइस फिर से कनेक्ट हो रहा है…",
|
||||
"app.settings": "सेटिंग्स",
|
||||
"app.settings_title": "सेटिंग्स (Ctrl+,)",
|
||||
"app.discard_confirm": "इस रिकॉर्डिंग को छोड़ें? इसका ऑडियो और ट्रांसक्रिप्ट हटा दिया जाएगा।",
|
||||
"app.announce_started": "रिकॉर्डिंग शुरू हुई",
|
||||
"app.announce_paused": "रिकॉर्डिंग रोकी गई",
|
||||
"app.announce_stopped": "रिकॉर्डिंग रुकी",
|
||||
"app.consent_dialog": "रिकॉर्डिंग सहमति",
|
||||
"app.vault_dialog": "एन्क्रिप्शन वॉल्ट अनलॉक करें",
|
||||
"app.vault_title": "एन्क्रिप्शन वॉल्ट अनलॉक करें",
|
||||
"app.vault_desc": "आपकी मीटिंग स्थिर अवस्था में एन्क्रिप्ट की गई हैं। उन्हें पढ़ने के लिए अपना पासवर्ड दर्ज करें।",
|
||||
"app.vault_password": "वॉल्ट पासवर्ड",
|
||||
"app.vault_incorrect": "गलत पासवर्ड",
|
||||
"app.unlock": "अनलॉक करें",
|
||||
"app.continue_locked": "लॉक रहते हुए जारी रखें",
|
||||
"app.show_meetings": "मीटिंग सूची दिखाएँ",
|
||||
"app.hide_meetings": "मीटिंग सूची छिपाएँ",
|
||||
"app.show_summary": "सारांश पैनल दिखाएँ",
|
||||
"app.hide_summary": "सारांश पैनल छिपाएँ",
|
||||
"app.resize_meetings": "मीटिंग सूची का आकार बदलें",
|
||||
"app.resize_summary": "सारांश पैनल का आकार बदलें",
|
||||
|
||||
"meetings.search_placeholder": "मीटिंग खोजें…",
|
||||
"meetings.search_aria": "मीटिंग खोजें",
|
||||
"meetings.filter_tag_aria": "टैग के अनुसार फ़िल्टर करें",
|
||||
"meetings.all_tags": "सभी टैग",
|
||||
"meetings.from_date_aria": "तारीख़ से",
|
||||
"meetings.to_date_aria": "तारीख़ तक",
|
||||
"meetings.bulk_format_aria": "बल्क निर्यात प्रारूप",
|
||||
"meetings.bulk_export": "बल्क निर्यात",
|
||||
"meetings.bulk_export_title": "ऊपर के टैग/तारीख़ फ़िल्टर से मेल खाने वाली प्रत्येक मीटिंग निर्यात करें",
|
||||
"meetings.exporting": "निर्यात हो रहा है…",
|
||||
"meetings.exported_one": "{n} मीटिंग निर्यात की गई।",
|
||||
"meetings.exported_many": "{n} मीटिंग निर्यात की गईं।",
|
||||
"meetings.searching": "खोज हो रही है…",
|
||||
"meetings.loading": "मीटिंग लोड हो रही हैं…",
|
||||
"meetings.no_matches": "कोई मिलान नहीं।",
|
||||
"meetings.empty": "अभी तक कोई मीटिंग नहीं — शुरू करने के लिए ऊपर रिकॉर्ड पर क्लिक करें।",
|
||||
"meetings.status.recording": "रिकॉर्ड हो रहा है",
|
||||
"meetings.status.transcribing": "ट्रांसक्राइब हो रहा है",
|
||||
"meetings.status.recovering": "पुनर्प्राप्त हो रहा है",
|
||||
"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": "ट्रांसक्रिप्ट दिखाएँ",
|
||||
"transcript.hide": "ट्रांसक्रिप्ट छिपाएँ",
|
||||
"transcript.empty": "इस मीटिंग के लिए कोई ट्रांसक्रिप्ट नहीं।",
|
||||
"transcript.will_appear": "जैसे-जैसे आप रिकॉर्ड करते हैं ट्रांसक्रिप्ट यहाँ दिखाई देगा।",
|
||||
"transcript.play_from_here": "यहाँ से चलाएँ",
|
||||
"transcript.resize": "ट्रांसक्रिप्ट और नोट्स का आकार बदलें",
|
||||
"transcript.reprocess_placeholder": "इसके साथ फिर से ट्रांसक्राइब करें…",
|
||||
"transcript.reprocess_lang_aria": "पुनर्प्रसंस्करण भाषा",
|
||||
"transcript.reprocess_keep_lang": "वर्तमान भाषा रखें",
|
||||
"transcript.reprocess_go": "चलाएँ",
|
||||
"transcript.retranscribing": "फिर से ट्रांसक्राइब हो रहा है…",
|
||||
"transcript.has_note": "एक नोट है",
|
||||
"transcript.note_placeholder": "इस पल के लिए एक नोट जोड़ें…",
|
||||
"transcript.note_aria": "इस ट्रांसक्रिप्ट पंक्ति के लिए नोट",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "Word दस्तावेज़",
|
||||
"transcript.filter_obsidian": "Obsidian नोट",
|
||||
|
||||
"notes.heading": "नोट्स",
|
||||
"notes.show": "नोट्स दिखाएँ",
|
||||
"notes.hide": "नोट्स छिपाएँ",
|
||||
"notes.placeholder": "नोट्स…",
|
||||
"notes.live_placeholder": "बात करते समय नोट्स टाइप करें…",
|
||||
"notes.toolbar_aria": "नोट्स स्वरूपण",
|
||||
"notes.bold": "बोल्ड",
|
||||
"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": "संपादक",
|
||||
"notes.preview": "पूर्वावलोकन",
|
||||
"notes.export_md_title": "नोट्स को .md के रूप में निर्यात करें",
|
||||
"notes.export_pdf_title": "नोट्स को .pdf के रूप में निर्यात करें",
|
||||
"notes.export_docx_title": "नोट्स को .docx के रूप में निर्यात करें",
|
||||
"notes.export_bundle_title": "ऑडियो + ट्रांसक्रिप्ट + नोट्स को एक फ़ोल्डर में निर्यात करें",
|
||||
"notes.export_obsidian_title": "एक Obsidian नोट निर्यात करें (नोट्स + सारांश + ट्रांसक्रिप्ट, कोई ऑडियो नहीं)",
|
||||
|
||||
"summary.recording_heading": "रिकॉर्डिंग",
|
||||
"summary.loading_recording": "रिकॉर्डिंग लोड हो रही है…",
|
||||
"summary.sync_heading": "सिंक",
|
||||
"summary.uploading": "अपलोड हो रहा है…",
|
||||
"summary.upload_now": "अभी अपलोड करें",
|
||||
"summary.no_uploads": "इस मीटिंग के लिए अभी तक कोई अपलोड नहीं।",
|
||||
"summary.retry": "पुनः प्रयास करें",
|
||||
"summary.tags_heading": "टैग",
|
||||
"summary.tags_select": "किसी मीटिंग को टैग करने के लिए उसे चुनें।",
|
||||
"summary.tag_add_placeholder": "टैग जोड़ें…",
|
||||
"summary.tag_first_placeholder": "प्रोजेक्ट, ग्राहक, विषय…",
|
||||
"summary.generating": "उत्पन्न हो रहा है…",
|
||||
"summary.generate_tags": "टैग उत्पन्न करें",
|
||||
"summary.saving": "सहेजा जा रहा है…",
|
||||
"summary.save_tags": "टैग सहेजें",
|
||||
"summary.summary_heading": "सारांश",
|
||||
"summary.ai_provider": "एआई प्रदाता",
|
||||
"summary.provider_off": "बंद",
|
||||
"summary.provider_custom": "कस्टम",
|
||||
"summary.local": "स्थानीय",
|
||||
"summary.leaves_device": "इस डिवाइस से बाहर जाता है",
|
||||
"summary.this_hosted_provider": "यह होस्ट किया गया प्रदाता",
|
||||
"summary.select_generate": "सारांश उत्पन्न करने के लिए एक मीटिंग चुनें।",
|
||||
"summary.decisions_heading": "निर्णय",
|
||||
"summary.regenerate": "फिर से उत्पन्न करें",
|
||||
"summary.generated_hint": "मीटिंग के बाद स्थानीय रूप से उत्पन्न (एक स्थानीय LLM प्रदाता की आवश्यकता है)।",
|
||||
"summary.no_provider": "कोई एआई प्रदाता कॉन्फ़िगर नहीं — सेटिंग्स में एक सक्षम करें।",
|
||||
"summary.generate_summary": "सारांश उत्पन्न करें",
|
||||
"summary.briefs_heading": "फ़ीचर ब्रीफ़",
|
||||
"summary.briefs_select": "फ़ीचर ब्रीफ़ बनाने या देखने के लिए एक मीटिंग चुनें।",
|
||||
"summary.briefs_desc": "इस मीटिंग को एजेंट के लिए तैयार एक विनिर्देश में परिशोधित करता है (एक स्थानीय LLM प्रदाता की आवश्यकता है) — इसे किसी कोडिंग एजेंट को दें, या MCP सर्वर चालू होने पर उसके माध्यम से परोसें।",
|
||||
"summary.brief_repo_placeholder": "लक्ष्य रेपो (वैकल्पिक), जैसे acme/reporting-web",
|
||||
"summary.distilling": "परिशोधित हो रहा है…",
|
||||
"summary.create_brief": "फ़ीचर ब्रीफ़ बनाएँ",
|
||||
"summary.brief_mcp_title": "MCP सर्वर चालू होने पर उपलब्ध",
|
||||
"summary.copied": "कॉपी किया गया",
|
||||
"summary.copy_md": "Markdown के रूप में कॉपी करें",
|
||||
"summary.copy_json": "JSON के रूप में कॉपी करें",
|
||||
"summary.brief_problem": "समस्या",
|
||||
"summary.brief_outcome": "वांछित परिणाम",
|
||||
"summary.brief_criteria": "स्वीकृति मानदंड",
|
||||
"summary.brief_none": "कुछ भी कैप्चर नहीं किया गया।",
|
||||
"summary.brief_context": "संदर्भ",
|
||||
"summary.action_items_heading": "कार्य-आइटम",
|
||||
"summary.ai_select": "इसके कार्य-आइटम देखने के लिए एक मीटिंग चुनें।",
|
||||
"summary.ai_empty": "अभी तक कोई नहीं — नीचे एक जोड़ें, या उन्हें स्वचालित रूप से निकालने के लिए एक सारांश उत्पन्न करें।",
|
||||
"summary.confirmed": "पुष्ट",
|
||||
"summary.ai_text_placeholder": "कार्य-आइटम…",
|
||||
"summary.ai_text_aria": "कार्य-आइटम पाठ",
|
||||
"summary.owner": "स्वामी",
|
||||
"summary.due_date": "नियत तारीख़",
|
||||
"summary.reminder_title": "इस नियत तारीख़ के लिए एक स्थानीय अनुस्मारक शेड्यूल करें",
|
||||
"summary.ai_delete_title": "इस कार्य-आइटम को हटाएँ",
|
||||
"summary.ai_delete_aria": "कार्य-आइटम हटाएँ",
|
||||
"summary.add_action_item": "कार्य-आइटम जोड़ें",
|
||||
"summary.save_action_items": "कार्य-आइटम सहेजें",
|
||||
"summary.calendar_heading": "कैलेंडर ईवेंट",
|
||||
"summary.cal_select": "किसी मीटिंग को कैलेंडर ईवेंट से लिंक करने के लिए उसे चुनें।",
|
||||
"summary.event_search_placeholder": "ईवेंट शीर्षक खोजें…",
|
||||
"summary.linked_event": "लिंक किया गया ईवेंट",
|
||||
"summary.change_event": "ईवेंट बदलें…",
|
||||
"summary.link_event": "एक ईवेंट लिंक करें…",
|
||||
"summary.untitled_event": "(अशीर्षक)",
|
||||
"summary.no_events_1": "अभी तक कोई ईवेंट आयात नहीं — एक",
|
||||
"summary.no_events_2": "सेटिंग्स → कैलेंडर से आयात करें।",
|
||||
"summary.no_events_match": "कोई ईवेंट इस खोज/तारीख़ से मेल नहीं खाता — किसी एक को साफ़ करने का प्रयास करें।",
|
||||
"summary.organizer_label": "आयोजक: {name}",
|
||||
"summary.participants_heading": "प्रतिभागी",
|
||||
"summary.participants_hint": "लिंक किए गए कैलेंडर ईवेंट से भरा जाता है।",
|
||||
"summary.speakers_heading": "वक्ता",
|
||||
"summary.speakers_select": "इसके वक्ताओं को नाम देने के लिए एक मीटिंग चुनें।",
|
||||
"summary.no_speakers": "अभी तक कोई वक्ता नहीं पाया गया।",
|
||||
"summary.speaker_name_placeholder": "वक्ता का नाम",
|
||||
"summary.save": "सहेजें",
|
||||
"summary.cancel": "रद्द करें",
|
||||
"summary.name_speaker": "इस वक्ता को नाम दें…",
|
||||
"summary.add_new_name": "+ नया नाम जोड़ें…",
|
||||
|
||||
"settings.recording.title": "रिकॉर्डिंग",
|
||||
"settings.recording.record_default": "डिफ़ॉल्ट रूप से मीटिंग रिकॉर्ड करें",
|
||||
"settings.recording.save_audio_as": "ऑडियो को इस रूप में सहेजें",
|
||||
"settings.recording.default_hint": "डिफ़ॉल्ट रूप से बंद। जब बंद हो, तो ऑडियो का उपयोग केवल ट्रांसक्रिप्ट बनाने के लिए किया जाता है और मीटिंग अंतिम रूप देने पर हटा दिया जाता है। आप प्रति मीटिंग रिकॉर्डिंग भी टॉगल कर सकते हैं।",
|
||||
"settings.recording.consent_label": "सहमति: {status}।",
|
||||
"settings.recording.consent_ack": "स्वीकृत",
|
||||
"settings.recording.consent_not": "अभी तक स्वीकृत नहीं",
|
||||
"settings.recording.auto_label": "जब कोई कैलेंडर ईवेंट शुरू होता है तो स्वचालित रूप से रिकॉर्डिंग शुरू करें",
|
||||
"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,118 @@
|
||||
// Minimal hand-rolled i18n — no dependency. A reactive `locale` (persisted to
|
||||
// localStorage, UI ephemera like the layout store) plus a `t(key)` lookup over
|
||||
// per-language JSON dictionaries. English is the fallback for any missing key,
|
||||
// so a partial translation degrades to English rather than showing raw keys.
|
||||
//
|
||||
// Adding a language: create `<code>.json` next to en.json, import it, and add
|
||||
// it to DICTS + LOCALES below. Translating that one file is the whole job.
|
||||
|
||||
import en from "./en.json";
|
||||
import de from "./de.json";
|
||||
import esES from "./es-ES.json";
|
||||
import esMX from "./es-MX.json";
|
||||
import frFR from "./fr-FR.json";
|
||||
import frCA from "./fr-CA.json";
|
||||
import ptPT from "./pt-PT.json";
|
||||
import ptBR from "./pt-BR.json";
|
||||
import ru from "./ru.json";
|
||||
import sv from "./sv.json";
|
||||
import nb from "./nb.json";
|
||||
import fi from "./fi.json";
|
||||
import zhCN from "./zh-CN.json";
|
||||
import ko from "./ko.json";
|
||||
import hi from "./hi.json";
|
||||
import bn from "./bn.json";
|
||||
import ta from "./ta.json";
|
||||
import si from "./si.json";
|
||||
import ar from "./ar.json";
|
||||
import ur from "./ur.json";
|
||||
|
||||
type Dict = Record<string, string>;
|
||||
|
||||
// Register languages here. en is the source-of-truth key set + fallback.
|
||||
const DICTS: Record<string, Dict> = {
|
||||
en, de, "es-ES": esES, "es-MX": esMX, "fr-FR": frFR, "fr-CA": frCA,
|
||||
"pt-PT": ptPT, "pt-BR": ptBR, ru, sv, nb, fi, "zh-CN": zhCN, ko,
|
||||
hi, bn, ta, si, ar, ur,
|
||||
};
|
||||
|
||||
// Right-to-left scripts; used to flip document direction on locale change.
|
||||
const RTL = new Set(["ar", "ur"]);
|
||||
|
||||
// `label` is the language's endonym so users find their own language in the list.
|
||||
export const LOCALES: { code: string; label: string }[] = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "ar", label: "العربية" },
|
||||
{ code: "bn", label: "বাংলা" },
|
||||
{ code: "de", label: "Deutsch" },
|
||||
{ code: "es-ES", label: "Español (España)" },
|
||||
{ code: "es-MX", label: "Español (México)" },
|
||||
{ code: "fi", label: "Suomi" },
|
||||
{ code: "fr-FR", label: "Français (France)" },
|
||||
{ code: "fr-CA", label: "Français (Canada)" },
|
||||
{ code: "hi", label: "हिन्दी" },
|
||||
{ code: "ko", label: "한국어" },
|
||||
{ code: "nb", label: "Norsk bokmål" },
|
||||
{ code: "pt-BR", label: "Português (Brasil)" },
|
||||
{ code: "pt-PT", label: "Português (Portugal)" },
|
||||
{ code: "ru", label: "Русский" },
|
||||
{ code: "si", label: "සිංහල" },
|
||||
{ code: "sv", label: "Svenska" },
|
||||
{ code: "ta", label: "தமிழ்" },
|
||||
{ code: "ur", label: "اردو" },
|
||||
{ code: "zh-CN", label: "中文(简体)" },
|
||||
];
|
||||
|
||||
const KEY = "wa-locale-v1";
|
||||
|
||||
function load(): string {
|
||||
try {
|
||||
const saved = localStorage.getItem(KEY);
|
||||
if (saved && saved in DICTS) return saved;
|
||||
} catch {
|
||||
/* localStorage unavailable — fall through to default */
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
// Flip the document's text direction so RTL locales (Arabic, Urdu) lay out
|
||||
// correctly. Safe outside a browser (SSR/tests): guarded by `document`.
|
||||
function applyDir(code: string) {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.dir = RTL.has(code) ? "rtl" : "ltr";
|
||||
document.documentElement.lang = code;
|
||||
}
|
||||
|
||||
class I18n {
|
||||
locale = $state(load());
|
||||
|
||||
constructor() {
|
||||
applyDir(this.locale);
|
||||
}
|
||||
|
||||
setLocale(code: string) {
|
||||
if (!(code in DICTS)) return;
|
||||
this.locale = code;
|
||||
applyDir(code);
|
||||
try {
|
||||
localStorage.setItem(KEY, code);
|
||||
} catch {
|
||||
/* non-fatal: preference just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up `key` in the active locale, falling back to English then the key
|
||||
* itself. `vars` fills `{name}` placeholders. Reads `locale` so components
|
||||
* that call `t()` in markup re-render when the language changes. */
|
||||
t = (key: string, vars?: Record<string, string | number>): string => {
|
||||
const dict = DICTS[this.locale] ?? en;
|
||||
let s = dict[key] ?? (en as Dict)[key] ?? key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v));
|
||||
}
|
||||
return s;
|
||||
};
|
||||
}
|
||||
|
||||
export const i18n = new I18n();
|
||||
export const t = i18n.t;
|
||||
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"nav.recording": "녹음",
|
||||
"nav.hardware": "하드웨어",
|
||||
"nav.storage": "저장소",
|
||||
"nav.calendar": "캘린더",
|
||||
"nav.sync": "동기화",
|
||||
"nav.ai": "AI",
|
||||
"nav.mcp": "MCP 서버",
|
||||
"nav.privacy": "개인정보 보호",
|
||||
"nav.about": "정보",
|
||||
"nav.language": "언어",
|
||||
|
||||
"settings.language.title": "언어",
|
||||
"settings.language.display": "표시 언어",
|
||||
"settings.language.display_hint": "앱 인터페이스의 언어입니다. 언어를 추가하는 것은 번역 파일 하나를 넣는 것만큼 간단합니다.",
|
||||
|
||||
"settings.transcription.title": "전사 언어",
|
||||
"settings.transcription.label": "언어",
|
||||
"settings.transcription.auto": "자동 감지",
|
||||
"settings.transcription.applies_hint": "다음 녹음에 적용됩니다. 실제로 사용된 언어는 이후 각 회의에 표시됩니다.",
|
||||
"settings.transcription.english_only": "영어 전용",
|
||||
"settings.transcription.model_english_only": "“{model}”은(는) 영어 전용입니다.",
|
||||
"settings.transcription.no_model": "선택된 모델이 없습니다.",
|
||||
"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": "선호 백엔드",
|
||||
"settings.hardware.auto_backend": "자동(사용 가능한 최적)",
|
||||
"settings.hardware.not_available": "사용 불가",
|
||||
"settings.hardware.fallback_hint": "선택한 백엔드를 로드하지 못하면 자동으로 대체됩니다(NPU → NVIDIA → AMD → Intel → CPU).",
|
||||
"settings.hardware.audio_devices": "오디오 장치",
|
||||
"settings.hardware.recording_device": "녹음 장치",
|
||||
"settings.hardware.default_system_audio": "기본 시스템 오디오",
|
||||
"settings.hardware.recording_device_hint": "WhispAssist는 이 장치가 재생하는 것(루프백)—통화의 상대편—을 녹음합니다. Windows 시스템 기본값을 따르지 않게 하려면 특정 출력을 선택하세요.",
|
||||
"settings.hardware.microphone": "마이크",
|
||||
"settings.hardware.mic_off": "끔 — 내 마이크를 캡처하지 않음",
|
||||
"settings.hardware.default_mic": "기본 마이크",
|
||||
"settings.hardware.mic_hint": "회의의 양쪽이 모두 캡처되도록 실시간 전사에 본인의 음성을 추가합니다. 장치에 남아 있으며 아무것도 업로드되지 않습니다. 위의 시스템 오디오만 전사하려면 “끔”을 선택하세요.",
|
||||
"settings.hardware.npu_detected": "NPU 가속기 감지됨",
|
||||
"settings.hardware.ready": "준비됨",
|
||||
"settings.hardware.downloading": "다운로드 중…",
|
||||
"settings.hardware.package_needed": "패키지 필요",
|
||||
"settings.hardware.npu_hint": "NPU에는 일회성 가속 패키지(Whisper ONNX 모델{extra})가 필요합니다. 백그라운드에서 다운로드되며 지금 시작할 수도 있습니다.",
|
||||
"settings.hardware.plus_openvino": " + OpenVINO 런타임",
|
||||
"settings.hardware.download_npu": "NPU 패키지 다운로드",
|
||||
"settings.hardware.dml_title": "GPU 가속(DirectML)",
|
||||
"settings.hardware.dml_hint": "GPU는 Vulkan 없이 DirectML을 통해 가속할 수 있습니다 — 일회성 패키지(Whisper ONNX 모델{extra}).",
|
||||
"settings.hardware.plus_directml": " + DirectML 런타임",
|
||||
"settings.hardware.download_dml": "DirectML 패키지 다운로드",
|
||||
"settings.hardware.detection_unavailable": "하드웨어 감지를 사용할 수 없습니다.",
|
||||
"settings.hardware.low_overhead": "낮은 오버헤드 사전 설정",
|
||||
"settings.hardware.low_overhead_note": "CPU + 가장 작은 모델, 배터리/백그라운드 사용용",
|
||||
"settings.hardware.models": "모델",
|
||||
"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": "활성 모델은 제거할 수 없습니다",
|
||||
"settings.hardware.download": "다운로드",
|
||||
"settings.hardware.diarization": "화자 분리",
|
||||
"settings.hardware.diarization_hint": "두 모델을 모두 설치하면 완료된 녹음이 하나의 연속 전사가 아니라 화자별(화자 1, 화자 2, …)로 분리됩니다. 완전히 오프라인으로 실행되며, 중지 후 회의당 한 번 실행됩니다. 이들이 없으면 모든 줄이 단일 화자에게 귀속됩니다.",
|
||||
|
||||
"settings.storage.title": "저장소",
|
||||
"settings.storage.location_label": "위치",
|
||||
"settings.storage.location_hint": "저장 위치 변경은 아직 지원되지 않습니다 — 현재 회의, 모델, 데이터베이스가 여기에 있습니다.",
|
||||
"settings.storage.retention_title": "보존",
|
||||
"settings.storage.retention_hint": "한도를 초과하는 가장 오래된 회의를 자동으로 삭제합니다. 시작 시 한 번 확인하며, 비워 두면 한도가 없습니다. 현재 녹음 중인 회의는 절대 건드리지 않습니다.",
|
||||
"settings.storage.max_age": "최대 보관 기간(일)",
|
||||
"settings.storage.max_size": "최대 크기(GB)",
|
||||
"settings.storage.no_limit": "한도 없음",
|
||||
"settings.storage.export_import_title": "내보내기 및 가져오기",
|
||||
"settings.storage.export_hint_1": "녹음을 이동식 번들 폴더(오디오, 전사, 노트, 요약 및",
|
||||
"settings.storage.export_hint_2": "매니페스트)로 컴퓨터 간에 이동하세요. 아무 폴더 — 동기화된 드라이브, USB 메모리, 동기화 대상의 로컬 마운트 지점 — 로 내보낸 다음 다른 컴퓨터에서 가져오세요. 가져온 회의는 새 id를 받으므로 다시 가져와도 아무것도 덮어쓰지 않습니다.",
|
||||
"settings.storage.export_dialog_title": "모든 회의를 다음으로 내보내기…",
|
||||
"settings.storage.import_dialog_title": "번들(또는 번들 폴더) 가져오기…",
|
||||
"settings.storage.exporting": "내보내는 중…",
|
||||
"settings.storage.export_all": "모든 회의 내보내기…",
|
||||
"settings.storage.importing": "가져오는 중…",
|
||||
"settings.storage.import_all": "회의 가져오기…",
|
||||
"settings.storage.exported_one": "회의 {n}개를 내보냈습니다.",
|
||||
"settings.storage.exported_many": "회의 {n}개를 내보냈습니다.",
|
||||
"settings.storage.imported_one": "회의 {n}개를 가져왔습니다.",
|
||||
"settings.storage.imported_many": "회의 {n}개를 가져왔습니다.",
|
||||
|
||||
"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": "찾아보기…",
|
||||
"settings.calendar.password": "비밀번호",
|
||||
"settings.calendar.password_note": "거의 필요 없음",
|
||||
"settings.calendar.optional": "선택 사항",
|
||||
"settings.calendar.import_range": "가져오기 범위",
|
||||
"settings.calendar.range_30": "최근 30일",
|
||||
"settings.calendar.range_90": "최근 90일",
|
||||
"settings.calendar.range_180": "최근 6개월",
|
||||
"settings.calendar.range_365": "최근 1년",
|
||||
"settings.calendar.range_all": "전체 기간",
|
||||
"settings.calendar.range_hint": "오래된 사서함에는 수년간의 반복/휴일 항목이 있을 수 있습니다 — 범위를 좁히면 가져온 캘린더가 실제로 관련 있는 것만 유지됩니다. 이 가져오기 버튼과 시작 시 자동 재가져오기 모두에 적용됩니다.",
|
||||
"settings.calendar.importing": "가져오는 중…",
|
||||
"settings.calendar.import": "가져오기",
|
||||
"settings.calendar.import_progress": "이벤트 {processed}/{total}개",
|
||||
"settings.calendar.auto_reimport": "시작 시 이 파일을 자동으로 다시 가져오기",
|
||||
"settings.calendar.auto_reimport_hint": "타이머가 아니라 시작 시 한 번 실행됩니다 — 재가져오기는 안전하게 반복할 수 있습니다(기존 이벤트는 매칭되어 업데이트되며 중복되지 않습니다).",
|
||||
"settings.calendar.import_failed": "가져오기 실패: {error} — 파일 자체는 손대지 않습니다. 경로를 확인하고 다시 시도하세요.",
|
||||
"settings.calendar.cleanup_title": "정리",
|
||||
"settings.calendar.cleanup_hint": "이미 녹음된 회의에 연결된 이벤트는 아래에서 어떤 옵션을 선택하든 항상 유지됩니다.",
|
||||
"settings.calendar.cleanup_30": "30일 이상 지난 항목",
|
||||
"settings.calendar.cleanup_90": "90일 이상 지난 항목",
|
||||
"settings.calendar.cleanup_180": "6개월 이상 지난 항목",
|
||||
"settings.calendar.cleanup_365": "1년 이상 지난 항목",
|
||||
"settings.calendar.cleanup_all": "모두 삭제",
|
||||
"settings.calendar.cleaning": "정리 중…",
|
||||
"settings.calendar.cleanup_btn": "캘린더 정리",
|
||||
"settings.calendar.cleanup_result": "{deleted}{extra} 삭제됨.",
|
||||
"settings.calendar.cleanup_kept": ", {n}개 유지됨(회의에 연결됨)",
|
||||
"settings.calendar.cleanup_failed": "정리 실패: {error}",
|
||||
"settings.calendar.imported_events": "가져온 이벤트",
|
||||
"settings.calendar.no_events": "아직 가져온 이벤트가 없습니다.",
|
||||
"settings.calendar.search_title": "제목 검색",
|
||||
"settings.calendar.search_placeholder": "회의 이름…",
|
||||
"settings.calendar.date_label": "날짜",
|
||||
"settings.calendar.events_count": "이벤트 {total}개 중 {shown}개",
|
||||
"settings.calendar.untitled": "(제목 없음)",
|
||||
|
||||
"settings.dialog_title": "설정",
|
||||
"settings.close": "닫기",
|
||||
"settings.close_title": "닫기(Esc)",
|
||||
|
||||
"settings.sync.stub": "미리 보기 모드 — 동기화 백엔드는 아직 구현되지 않았습니다(9단계). 변경 사항은 인터페이스에만 유지됩니다.",
|
||||
"settings.sync.title": "동기화 및 업로드",
|
||||
"settings.sync.enable_label": "구성된 대상으로 회의 아티팩트 업로드 활성화",
|
||||
"settings.sync.enable_hint": "기본적으로 꺼져 있습니다. 이 항목이 켜져 있고 대상이 활성화되어 있지 않으면 아무것도 업로드되지 않습니다. 자체 호스팅 대상은 데이터를 본인의 서버에 보관하며, 타사 클라우드는 명확히 표시됩니다.",
|
||||
"settings.sync.targets_title": "대상",
|
||||
"settings.sync.no_targets": "아직 대상이 없습니다. 아래에서 하나 추가하세요.",
|
||||
"settings.sync.third_party": "타사",
|
||||
"settings.sync.your_server": "내 서버",
|
||||
"settings.sync.edit": "편집",
|
||||
"settings.sync.remove": "제거",
|
||||
"settings.sync.edit_target": "대상 편집",
|
||||
"settings.sync.add_target": "대상 추가",
|
||||
"settings.sync.webdav_hint": "WebDAV는 Nextcloud, ownCloud, Cloudreve, Seafile, Synology를 지원합니다. (Seafile: 서버 측에서 SeafDAV를 활성화하세요.)",
|
||||
"settings.sync.name": "이름",
|
||||
"settings.sync.name_placeholder": "홈 Nextcloud",
|
||||
"settings.sync.provider": "공급자",
|
||||
"settings.sync.server_url": "서버 URL",
|
||||
"settings.sync.server_url_note_1": "서버 URL만 입력하세요 — WhispAssist가",
|
||||
"settings.sync.server_url_note_2": "을(를) 자동으로 추가합니다.",
|
||||
"settings.sync.remote_folder": "원격 폴더",
|
||||
"settings.sync.username": "사용자 이름",
|
||||
"settings.sync.app_password": "앱 비밀번호",
|
||||
"settings.sync.pw_keep": "현재 비밀번호를 유지하려면 비워 두세요",
|
||||
"settings.sync.pw_store": "운영 체제 자격 증명 저장소에 저장됨",
|
||||
"settings.sync.upload_legend": "업로드",
|
||||
"settings.sync.artifact_transcript": "전사",
|
||||
"settings.sync.artifact_notes": "노트",
|
||||
"settings.sync.artifact_summary": "요약",
|
||||
"settings.sync.artifact_recording": "녹음(.wav, 보존된 경우)",
|
||||
"settings.sync.allow_plaintext": "LAN 주소에 대해 평문 http 허용(권장하지 않음)",
|
||||
"settings.sync.encrypt_before": "업로드 전 암호화(대상은 암호문만 저장)",
|
||||
"settings.sync.requires_vault": "— 잠금 해제된 보관소가 필요합니다",
|
||||
"settings.sync.test_connection": "연결 테스트",
|
||||
"settings.sync.save_changes": "변경 사항 저장",
|
||||
"settings.sync.add_target_btn": "대상 추가",
|
||||
"settings.sync.cancel": "취소",
|
||||
"settings.sync.third_party_banner": "{kind}은(는) 타사 클라우드입니다 — 업로드하면 데이터가 장치를 벗어나 {kind}(으)로 전송됩니다.",
|
||||
"settings.sync.link_account": "{kind} 계정 연결…",
|
||||
"settings.sync.oauth_hint": "OAuth 로그인(루프백 리디렉션)을 엽니다. 토큰은 운영 체제 자격 증명 저장소에 저장됩니다.",
|
||||
"settings.sync.footnote": "자격 증명은 설정이나 데이터베이스에 절대 기록되지 않습니다 — 운영 체제 자격 증명 저장소에만 저장됩니다. LAN 이외의 대상에는 TLS가 필요합니다.",
|
||||
|
||||
"settings.ai.title": "AI 요약 공급자",
|
||||
"settings.ai.intro_1": "요약은 기본적으로 로컬 LLM에서 실행됩니다. 이 PC 또는 LAN의 다른 컴퓨터에 있는 Ollama를 가리키도록 설정하세요(예:",
|
||||
"settings.ai.intro_2": "— 둘 다 로컬로 간주되므로 네트워크를 벗어나는 것이 없습니다. 호스팅 공급자(Anthropic)는 선택 사항이며 기본적으로 꺼져 있고, 하나를 켜는 순간 전사를 제3자에게 전송합니다.",
|
||||
"settings.ai.provider": "공급자",
|
||||
"settings.ai.off": "끔",
|
||||
"settings.ai.provider_ollama": "Ollama(로컬 / LAN)",
|
||||
"settings.ai.provider_custom": "사용자 지정(OpenAI 호환)",
|
||||
"settings.ai.provider_anthropic": "Anthropic(Claude) — 호스팅됨, 이 장치를 벗어남",
|
||||
"settings.ai.endpoint": "엔드포인트",
|
||||
"settings.ai.model": "모델",
|
||||
"settings.ai.api_key": "API 키",
|
||||
"settings.ai.api_key_set_placeholder": "••••••••••••••••(이미 설정됨 — 유지하려면 비워 두세요)",
|
||||
"settings.ai.show_api_key": "API 키 표시",
|
||||
"settings.ai.hide_api_key": "API 키 숨기기",
|
||||
"settings.ai.anthropic_banner": "Anthropic은 호스팅된 타사 서비스입니다 — 요약을 생성하면 이 회의의 전사가 장치를 벗어납니다.",
|
||||
"settings.ai.endpoint_banner": "이 엔드포인트는 내 컴퓨터나 LAN에 없습니다 — 전사가 네트워크를 벗어나게 됩니다.",
|
||||
"settings.ai.saving": "저장 중…",
|
||||
"settings.ai.save": "저장",
|
||||
"settings.ai.test_connection": "연결 테스트",
|
||||
"settings.ai.this_hosted_provider": "이 호스팅 공급자",
|
||||
"settings.ai.reachable": "연결 가능",
|
||||
"settings.ai.unreachable": "연결 불가",
|
||||
"settings.ai.model_count_one": "· 모델 {n}개",
|
||||
"settings.ai.model_count_many": "· 모델 {n}개",
|
||||
"settings.ai.on_lan": "· 내 컴퓨터 / LAN에서",
|
||||
"settings.ai.leaves_network": "· 네트워크를 벗어남",
|
||||
"settings.ai.advanced_title": "Ollama 고급 구성",
|
||||
"settings.ai.changed": "{n}개 변경됨",
|
||||
"settings.ai.reset_all": "모두 기본값으로 재설정",
|
||||
"settings.ai.system_prompt": "시스템 프롬프트",
|
||||
"settings.ai.system_prompt_placeholder": "예: 당신은 간결한 회의 요약가입니다.",
|
||||
"settings.ai.system_prompt_hint": "WhispAssist에 필요한 출력 형식 앞에 추가되므로, 지침이 요약/작업 항목 구문 분석을 깨뜨릴 수 없습니다.",
|
||||
"settings.ai.think": "사고",
|
||||
"settings.ai.think_help": "추론 노력(추론 모델 전용).",
|
||||
"settings.ai.keep_alive": "활성 유지",
|
||||
"settings.ai.keep_alive_help": "모델이 RAM에 유지되는 시간. 예: 5m, 1h, 0(언로드), -1(영구).",
|
||||
"settings.ai.runtime_hardware": "런타임 및 하드웨어",
|
||||
"settings.ai.rarely_needed": "— 거의 필요하지 않음",
|
||||
"settings.ai.save_advanced": "고급 저장",
|
||||
"settings.ai.saved": "저장됨",
|
||||
"settings.ai.turn_off": "끄기",
|
||||
"settings.ai.default_value": "기본값 {value}.",
|
||||
"settings.ai.reset_default": "기본값으로 재설정",
|
||||
"settings.ai.reset_field": "{name} 재설정",
|
||||
|
||||
"settings.mcp.title": "MCP 서버",
|
||||
"settings.mcp.banner_1": "이를 통해 본인의 코딩 에이전트(Claude Code, Codex, Copilot, OpenCode 등)가 로컬 컴퓨터에서 회의 컨텍스트를 가져올 수 있습니다. 연결되면",
|
||||
"settings.mcp.banner_agent": "해당 에이전트",
|
||||
"settings.mcp.banner_2": "가 읽은 내용을 자체 모델 공급자의 클라우드로 전달할 수 있습니다 — WhispAssist의 제어를 벗어납니다. WhispAssist 자체는 이 데이터를 어디에도 전송하지 않으며, 서버는 이 장치에서만 수신 대기합니다(",
|
||||
"settings.mcp.banner_3": "). 모든 읽기는 아래에 기록됩니다.",
|
||||
"settings.mcp.enable": "MCP 서버 활성화",
|
||||
"settings.mcp.enable_hint": "기본적으로 꺼져 있습니다. 루프백 전용, 토큰 보호 — 네트워크에서 접근할 수 있는 것이 없습니다.",
|
||||
"settings.mcp.transport": "전송",
|
||||
"settings.mcp.transport_http": "스트리밍 HTTP",
|
||||
"settings.mcp.transport_stdio": "stdio(에이전트가 프로세스를 생성함)",
|
||||
"settings.mcp.port": "포트",
|
||||
"settings.mcp.transport_change_hint": "전송/포트를 변경하려면 먼저 서버를 끈 다음 다시 켜세요.",
|
||||
"settings.mcp.scope_title": "범위",
|
||||
"settings.mcp.expose": "노출",
|
||||
"settings.mcp.scope_none": "없음 — 아무것도 공유하지 않음",
|
||||
"settings.mcp.scope_selected": "선택됨 — 공유로 표시한 기능 브리프만",
|
||||
"settings.mcp.scope_all": "전체 — 회의, 전사, 작업 항목 및 공유 브리프",
|
||||
"settings.mcp.expose_recordings": "저장된 녹음이 있는 회의도 허용(기본적으로 꺼짐 — 이 항목이 켜질 때까지 녹음된 회의의 전사는 “전체” 범위에서도 보류됩니다)",
|
||||
"settings.mcp.token_title": "새 인증 토큰 — 한 번만 표시되므로 지금 복사하세요",
|
||||
"settings.mcp.token_hint": "이 내용은 다시 표시되지 않습니다. 운영 체제 자격 증명 저장소에 저장됩니다. 분실한 경우 서버를 껐다가 다시 켜 새 토큰을 발급하세요.",
|
||||
"settings.mcp.copied": "복사됨",
|
||||
"settings.mcp.copy": "복사",
|
||||
"settings.mcp.endpoint": "엔드포인트",
|
||||
"settings.mcp.endpoint_hint": "에이전트의 MCP 클라이언트 구성을 {kind}(으)로 지정하고, 위의 토큰을 bearer 자격 증명으로 사용하세요.",
|
||||
"settings.mcp.kind_command": "명령",
|
||||
"settings.mcp.kind_url": "URL",
|
||||
"settings.mcp.access_log": "접근 로그",
|
||||
"settings.mcp.access_log_hint": "에이전트가 수행한 모든 도구 읽기(허용 또는 거부)(FR-MCP-5).",
|
||||
"settings.mcp.no_reads": "아직 아무 에이전트도 읽지 않았습니다.",
|
||||
"settings.mcp.log_meeting": "회의 {id}",
|
||||
"settings.mcp.refresh": "새로 고침",
|
||||
|
||||
"settings.privacy.title": "개인정보 보호",
|
||||
"settings.privacy.intro": "WhispAssist가 현재 이 장치 밖으로 실제로 보낼 수 있도록 허용된 내용입니다. 모두 꺼져 있으면(기본값) 장치를 벗어나는 것이 전혀 없습니다.",
|
||||
"settings.privacy.llm_endpoint": "LLM 엔드포인트",
|
||||
"settings.privacy.off": "끔",
|
||||
"settings.privacy.local_only": "로컬 전용",
|
||||
"settings.privacy.leaves_device": "이 장치를 벗어남",
|
||||
"settings.privacy.sync_label": "동기화",
|
||||
"settings.privacy.enabled": "활성화됨",
|
||||
"settings.privacy.mcp_label": "MCP 서버",
|
||||
"settings.privacy.mcp_on": "켬 · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "루프백에서만 수신 — 위의 송신 목록에 아무것도 추가하지 않습니다. 연결된 에이전트는 여전히 읽은 내용을 자체 모델 공급자에게 전달할 수 있습니다. MCP 서버 탭을 참조하세요.",
|
||||
"settings.privacy.egress_title": "송신 허용 목록",
|
||||
"settings.privacy.no_egress": "허용 목록에 있는 호스트가 없습니다 — WA는 콘텐츠 송신을 하지 않습니다.",
|
||||
"settings.privacy.sync_targets_title": "동기화 대상",
|
||||
"settings.privacy.third_party": "타사",
|
||||
"settings.privacy.your_server": "내 서버",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "TLS 없음",
|
||||
"settings.privacy.refresh": "새로 고침",
|
||||
"settings.privacy.unavailable": "개인정보 자체 점검을 사용할 수 없습니다.",
|
||||
"settings.privacy.vault_title": "암호화 보관소",
|
||||
"settings.privacy.vault_intro": "비밀번호로 노트, 전사, 요약을 저장 시 암호화합니다. (오디오 파일은 아직 암호화되지 않습니다.)",
|
||||
"settings.privacy.vault_password": "보관소 비밀번호",
|
||||
"settings.privacy.enable_vault": "보관소 활성화",
|
||||
"settings.privacy.vault_pw_hint": "8자 이상 사용하세요. 잊어버리면 암호화된 콘텐츠를 복구할 수 없습니다.",
|
||||
"settings.privacy.vault_locked_1": "보관소가 ",
|
||||
"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": "잠금 해제됨",
|
||||
"settings.privacy.vault_unlocked_2": " 상태입니다. 새 노트, 전사, 요약은 저장 시 암호화됩니다.",
|
||||
"settings.privacy.lock_now": "지금 잠그기",
|
||||
"settings.privacy.change_password": "비밀번호 변경",
|
||||
"settings.privacy.current_password": "현재 비밀번호",
|
||||
"settings.privacy.new_password": "새 비밀번호",
|
||||
|
||||
"settings.about.title": "정보",
|
||||
"settings.about.tagline": "완전히 로컬이며 오픈 소스인 Windows 네이티브 회의 도우미입니다.",
|
||||
"settings.about.build_commit": "빌드 커밋",
|
||||
|
||||
"consent.heading": "녹음하기 전에",
|
||||
"consent.body": "참가자의 동의 없이 대화를 녹음하는 것은 귀하의 지역에서 불법일 수 있습니다. 현지 녹음 법률을 확인하세요. 이것은 주의 사항이며 법률 자문이 아닙니다.",
|
||||
"consent.accept": "이해했습니다 — 녹음 활성화",
|
||||
"consent.cancel": "취소",
|
||||
|
||||
"hosted.heading": "{provider} 사용 전",
|
||||
"hosted.body_1": "다음으로 생성하면",
|
||||
"hosted.body_2": "이 회의의 전사가 그들의 서버로 전송됩니다 — 이 장치를 벗어나며 그들의 개인정보 처리방침이 적용됩니다. 데이터가 장치를 벗어난 후에는 WhispAssist가 데이터 처리에 대해 아무런 제어권이 없습니다. 이 항목은 기본적으로 꺼져 있으며, 지금 선택하는 것입니다.",
|
||||
"hosted.accept": "이해했습니다 — 계속",
|
||||
"hosted.cancel": "취소",
|
||||
|
||||
"theme.group": "테마",
|
||||
"theme.system": "시스템에 맞춤",
|
||||
"theme.light": "밝게",
|
||||
"theme.dark": "어둡게",
|
||||
|
||||
"import.dialog_label": "파일 또는 URL에서 회의 추가",
|
||||
"import.title": "회의 추가",
|
||||
"import.close": "닫기",
|
||||
"import.close_title": "닫기(Esc)",
|
||||
"import.body": "기존 녹음을 가져옵니다 — 로컬 오디오/비디오 파일 또는 링크(YouTube, 스트리밍 페이지 또는 직접 미디어 URL). 실시간 녹음과 마찬가지로 전사되고 화자 분리됩니다.",
|
||||
"import.file_or_url": "파일 또는 URL",
|
||||
"import.source_placeholder": "URL을 붙여넣거나 파일을 찾아보세요…",
|
||||
"import.choose_file": "로컬 파일 선택",
|
||||
"import.browse": "찾아보기…",
|
||||
"import.title_label": "제목",
|
||||
"import.optional": "선택 사항",
|
||||
"import.title_placeholder": "기본값은 파일 이름",
|
||||
"import.filter_av": "오디오 / 비디오",
|
||||
"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} 태그 제거",
|
||||
|
||||
"levelmeter.system_mic": "시스템 및 마이크 입력 레벨",
|
||||
"levelmeter.system": "시스템 입력 레벨",
|
||||
|
||||
"app.tagline": "로컬 · 비공개",
|
||||
"app.note_template": "노트 템플릿",
|
||||
"app.no_template": "템플릿 없음",
|
||||
"app.add_meeting": "회의 추가",
|
||||
"app.add_meeting_title": "파일 또는 URL에서 회의 추가",
|
||||
"app.record": "녹음",
|
||||
"app.record_title": "녹음 시작(Ctrl+Shift+R)",
|
||||
"app.stop": "중지",
|
||||
"app.stop_title": "녹음 중지(Ctrl+Shift+R)",
|
||||
"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": "저장 중",
|
||||
"app.not_saved": "저장되지 않음",
|
||||
"app.reconnecting": "오디오 장치를 다시 연결하는 중…",
|
||||
"app.settings": "설정",
|
||||
"app.settings_title": "설정(Ctrl+,)",
|
||||
"app.discard_confirm": "이 녹음을 취소하시겠습니까? 오디오와 전사가 삭제됩니다.",
|
||||
"app.announce_started": "녹음 시작됨",
|
||||
"app.announce_paused": "녹음 일시 중지됨",
|
||||
"app.announce_stopped": "녹음 중지됨",
|
||||
"app.consent_dialog": "녹음 동의",
|
||||
"app.vault_dialog": "암호화 보관소 잠금 해제",
|
||||
"app.vault_title": "암호화 보관소 잠금 해제",
|
||||
"app.vault_desc": "회의가 저장 시 암호화되어 있습니다. 읽으려면 비밀번호를 입력하세요.",
|
||||
"app.vault_password": "보관소 비밀번호",
|
||||
"app.vault_incorrect": "비밀번호가 올바르지 않습니다",
|
||||
"app.unlock": "잠금 해제",
|
||||
"app.continue_locked": "잠긴 상태로 계속",
|
||||
"app.show_meetings": "회의 목록 표시",
|
||||
"app.hide_meetings": "회의 목록 숨기기",
|
||||
"app.show_summary": "요약 패널 표시",
|
||||
"app.hide_summary": "요약 패널 숨기기",
|
||||
"app.resize_meetings": "회의 목록 크기 조정",
|
||||
"app.resize_summary": "요약 패널 크기 조정",
|
||||
|
||||
"meetings.search_placeholder": "회의 검색…",
|
||||
"meetings.search_aria": "회의 검색",
|
||||
"meetings.filter_tag_aria": "태그로 필터링",
|
||||
"meetings.all_tags": "모든 태그",
|
||||
"meetings.from_date_aria": "시작 날짜",
|
||||
"meetings.to_date_aria": "종료 날짜",
|
||||
"meetings.bulk_format_aria": "일괄 내보내기 형식",
|
||||
"meetings.bulk_export": "일괄 내보내기",
|
||||
"meetings.bulk_export_title": "위의 태그/날짜 필터와 일치하는 모든 회의 내보내기",
|
||||
"meetings.exporting": "내보내는 중…",
|
||||
"meetings.exported_one": "회의 {n}개를 내보냈습니다.",
|
||||
"meetings.exported_many": "회의 {n}개를 내보냈습니다.",
|
||||
"meetings.searching": "검색 중…",
|
||||
"meetings.loading": "회의를 불러오는 중…",
|
||||
"meetings.no_matches": "일치하는 항목이 없습니다.",
|
||||
"meetings.empty": "아직 회의가 없습니다 — 위의 녹음을 클릭하여 시작하세요.",
|
||||
"meetings.status.recording": "녹음 중",
|
||||
"meetings.status.transcribing": "전사 중",
|
||||
"meetings.status.recovering": "복구 중",
|
||||
"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": "전사 표시",
|
||||
"transcript.hide": "전사 숨기기",
|
||||
"transcript.empty": "이 회의에 대한 전사가 없습니다.",
|
||||
"transcript.will_appear": "녹음하는 동안 전사가 여기에 표시됩니다.",
|
||||
"transcript.play_from_here": "여기서부터 재생",
|
||||
"transcript.resize": "전사 및 노트 크기 조정",
|
||||
"transcript.reprocess_placeholder": "다음으로 다시 전사…",
|
||||
"transcript.reprocess_lang_aria": "재처리 언어",
|
||||
"transcript.reprocess_keep_lang": "현재 언어 유지",
|
||||
"transcript.reprocess_go": "실행",
|
||||
"transcript.retranscribing": "다시 전사하는 중…",
|
||||
"transcript.has_note": "노트 있음",
|
||||
"transcript.note_placeholder": "이 순간에 대한 노트 추가…",
|
||||
"transcript.note_aria": "이 전사 줄에 대한 노트",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "Word 문서",
|
||||
"transcript.filter_obsidian": "Obsidian 노트",
|
||||
|
||||
"notes.heading": "노트",
|
||||
"notes.show": "노트 표시",
|
||||
"notes.hide": "노트 숨기기",
|
||||
"notes.placeholder": "노트…",
|
||||
"notes.live_placeholder": "말하면서 노트를 입력하세요…",
|
||||
"notes.toolbar_aria": "노트 서식",
|
||||
"notes.bold": "굵게",
|
||||
"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": "편집기",
|
||||
"notes.preview": "미리 보기",
|
||||
"notes.export_md_title": "노트를 .md로 내보내기",
|
||||
"notes.export_pdf_title": "노트를 .pdf로 내보내기",
|
||||
"notes.export_docx_title": "노트를 .docx로 내보내기",
|
||||
"notes.export_bundle_title": "오디오 + 전사 + 노트를 폴더로 내보내기",
|
||||
"notes.export_obsidian_title": "하나의 Obsidian 노트 내보내기(노트 + 요약 + 전사, 오디오 제외)",
|
||||
|
||||
"summary.recording_heading": "녹음",
|
||||
"summary.loading_recording": "녹음을 불러오는 중…",
|
||||
"summary.sync_heading": "동기화",
|
||||
"summary.uploading": "업로드 중…",
|
||||
"summary.upload_now": "지금 업로드",
|
||||
"summary.no_uploads": "이 회의에 대한 업로드가 아직 없습니다.",
|
||||
"summary.retry": "재시도",
|
||||
"summary.tags_heading": "태그",
|
||||
"summary.tags_select": "태그를 지정하려면 회의를 선택하세요.",
|
||||
"summary.tag_add_placeholder": "태그 추가…",
|
||||
"summary.tag_first_placeholder": "프로젝트, 고객, 주제…",
|
||||
"summary.generating": "생성 중…",
|
||||
"summary.generate_tags": "태그 생성",
|
||||
"summary.saving": "저장 중…",
|
||||
"summary.save_tags": "태그 저장",
|
||||
"summary.summary_heading": "요약",
|
||||
"summary.ai_provider": "AI 공급자",
|
||||
"summary.provider_off": "끔",
|
||||
"summary.provider_custom": "사용자 지정",
|
||||
"summary.local": "로컬",
|
||||
"summary.leaves_device": "이 장치를 벗어남",
|
||||
"summary.this_hosted_provider": "이 호스팅 공급자",
|
||||
"summary.select_generate": "요약을 생성하려면 회의를 선택하세요.",
|
||||
"summary.decisions_heading": "결정 사항",
|
||||
"summary.regenerate": "다시 생성",
|
||||
"summary.generated_hint": "회의 후 로컬에서 생성됨(로컬 LLM 공급자 필요).",
|
||||
"summary.no_provider": "구성된 AI 공급자가 없습니다 — 설정에서 하나 활성화하세요.",
|
||||
"summary.generate_summary": "요약 생성",
|
||||
"summary.briefs_heading": "기능 브리프",
|
||||
"summary.briefs_select": "기능 브리프를 만들거나 보려면 회의를 선택하세요.",
|
||||
"summary.briefs_desc": "이 회의를 에이전트가 사용할 수 있는 사양으로 정제합니다(로컬 LLM 공급자 필요) — 코딩 에이전트에 전달하거나, MCP 서버가 켜지면 이를 통해 제공하세요.",
|
||||
"summary.brief_repo_placeholder": "대상 저장소(선택 사항), 예: acme/reporting-web",
|
||||
"summary.distilling": "정제 중…",
|
||||
"summary.create_brief": "기능 브리프 만들기",
|
||||
"summary.brief_mcp_title": "MCP 서버가 켜져 있을 때 사용 가능",
|
||||
"summary.copied": "복사됨",
|
||||
"summary.copy_md": "Markdown으로 복사",
|
||||
"summary.copy_json": "JSON으로 복사",
|
||||
"summary.brief_problem": "문제",
|
||||
"summary.brief_outcome": "원하는 결과",
|
||||
"summary.brief_criteria": "수락 기준",
|
||||
"summary.brief_none": "캡처된 내용 없음.",
|
||||
"summary.brief_context": "컨텍스트",
|
||||
"summary.action_items_heading": "작업 항목",
|
||||
"summary.ai_select": "작업 항목을 보려면 회의를 선택하세요.",
|
||||
"summary.ai_empty": "아직 없습니다 — 아래에서 하나 추가하거나 요약을 생성하여 자동으로 추출하세요.",
|
||||
"summary.confirmed": "확인됨",
|
||||
"summary.ai_text_placeholder": "작업 항목…",
|
||||
"summary.ai_text_aria": "작업 항목 텍스트",
|
||||
"summary.owner": "담당자",
|
||||
"summary.due_date": "기한",
|
||||
"summary.reminder_title": "이 기한에 대한 로컬 알림 예약",
|
||||
"summary.ai_delete_title": "이 작업 항목 삭제",
|
||||
"summary.ai_delete_aria": "작업 항목 삭제",
|
||||
"summary.add_action_item": "작업 항목 추가",
|
||||
"summary.save_action_items": "작업 항목 저장",
|
||||
"summary.calendar_heading": "캘린더 이벤트",
|
||||
"summary.cal_select": "회의를 캘린더 이벤트에 연결하려면 회의를 선택하세요.",
|
||||
"summary.event_search_placeholder": "이벤트 제목 검색…",
|
||||
"summary.linked_event": "연결된 이벤트",
|
||||
"summary.change_event": "이벤트 변경…",
|
||||
"summary.link_event": "이벤트 연결…",
|
||||
"summary.untitled_event": "(제목 없음)",
|
||||
"summary.no_events_1": "아직 가져온 이벤트가 없습니다 — ",
|
||||
"summary.no_events_2": "설정 → 캘린더에서 가져오세요.",
|
||||
"summary.no_events_match": "이 검색/날짜와 일치하는 이벤트가 없습니다 — 하나를 지워 보세요.",
|
||||
"summary.organizer_label": "주최자: {name}",
|
||||
"summary.participants_heading": "참가자",
|
||||
"summary.participants_hint": "연결된 캘린더 이벤트에서 채워집니다.",
|
||||
"summary.speakers_heading": "화자",
|
||||
"summary.speakers_select": "화자 이름을 지정하려면 회의를 선택하세요.",
|
||||
"summary.no_speakers": "아직 감지된 화자가 없습니다.",
|
||||
"summary.speaker_name_placeholder": "화자 이름",
|
||||
"summary.save": "저장",
|
||||
"summary.cancel": "취소",
|
||||
"summary.name_speaker": "이 화자 이름 지정…",
|
||||
"summary.add_new_name": "+ 새 이름 추가…",
|
||||
|
||||
"settings.recording.title": "녹음",
|
||||
"settings.recording.record_default": "기본적으로 회의 녹음",
|
||||
"settings.recording.save_audio_as": "오디오를 다음으로 저장",
|
||||
"settings.recording.default_hint": "기본적으로 꺼져 있습니다. 꺼져 있으면 오디오는 전사를 생성하는 데만 사용되며 회의가 완료되면 삭제됩니다. 회의별로 녹음을 전환할 수도 있습니다.",
|
||||
"settings.recording.consent_label": "동의: {status}.",
|
||||
"settings.recording.consent_ack": "확인됨",
|
||||
"settings.recording.consent_not": "아직 확인되지 않음",
|
||||
"settings.recording.auto_label": "캘린더 이벤트가 시작될 때 자동으로 녹음 시작",
|
||||
"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,588 @@
|
||||
{
|
||||
"nav.recording": "Opptak",
|
||||
"nav.hardware": "Maskinvare",
|
||||
"nav.storage": "Lagring",
|
||||
"nav.calendar": "Kalender",
|
||||
"nav.sync": "Synkronisering",
|
||||
"nav.ai": "KI",
|
||||
"nav.mcp": "MCP-server",
|
||||
"nav.privacy": "Personvern",
|
||||
"nav.about": "Om",
|
||||
"nav.language": "Språk",
|
||||
|
||||
"settings.language.title": "Språk",
|
||||
"settings.language.display": "Visningsspråk",
|
||||
"settings.language.display_hint": "Språket for appens grensesnitt. Å legge til et språk er like enkelt som å slippe inn én enkelt oversettelsesfil.",
|
||||
|
||||
"settings.transcription.title": "Transkripsjonsspråk",
|
||||
"settings.transcription.label": "Språk",
|
||||
"settings.transcription.auto": "Oppdag automatisk",
|
||||
"settings.transcription.applies_hint": "Gjelder neste opptak. Språket som faktisk ble brukt vises deretter på hvert møte.",
|
||||
"settings.transcription.english_only": "Kun engelsk",
|
||||
"settings.transcription.model_english_only": "«{model}» er kun på engelsk.",
|
||||
"settings.transcription.no_model": "Ingen modell valgt.",
|
||||
"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",
|
||||
"settings.hardware.auto_backend": "Automatisk (best tilgjengelig)",
|
||||
"settings.hardware.not_available": "ikke tilgjengelig",
|
||||
"settings.hardware.fallback_hint": "Faller automatisk tilbake (NPU → NVIDIA → AMD → Intel → CPU) hvis den valgte backenden ikke lastes inn.",
|
||||
"settings.hardware.audio_devices": "Lydenheter",
|
||||
"settings.hardware.recording_device": "Opptaksenhet",
|
||||
"settings.hardware.default_system_audio": "Standard systemlyd",
|
||||
"settings.hardware.recording_device_hint": "WhispAssist tar opp det denne enheten spiller av (loopback) — den andre siden av samtalen. Velg en bestemt utgang hvis du ikke vil at den skal følge Windows' systemstandard.",
|
||||
"settings.hardware.microphone": "Mikrofon",
|
||||
"settings.hardware.mic_off": "Av — ikke fang min mikrofon",
|
||||
"settings.hardware.default_mic": "Standardmikrofon",
|
||||
"settings.hardware.mic_hint": "Legger til din egen stemme i den direkte transkripsjonen slik at begge sider av møtet fanges. Blir på enheten din — ingenting lastes opp. Velg «Av» for kun å transkribere systemlyden ovenfor.",
|
||||
"settings.hardware.npu_detected": "NPU-akselerator oppdaget",
|
||||
"settings.hardware.ready": "Klar",
|
||||
"settings.hardware.downloading": "Laster ned…",
|
||||
"settings.hardware.package_needed": "Pakke kreves",
|
||||
"settings.hardware.npu_hint": "NPU-en din trenger en engangs akselerasjonspakke (Whisper ONNX-modell{extra}). Den lastes ned i bakgrunnen; du kan også starte den nå.",
|
||||
"settings.hardware.plus_openvino": " + OpenVINO-kjøretid",
|
||||
"settings.hardware.download_npu": "Last ned NPU-pakke",
|
||||
"settings.hardware.dml_title": "GPU-akselerasjon (DirectML)",
|
||||
"settings.hardware.dml_hint": "GPU-en din kan akselereres uten Vulkan via DirectML — en engangspakke (Whisper ONNX-modell{extra}).",
|
||||
"settings.hardware.plus_directml": " + DirectML-kjøretid",
|
||||
"settings.hardware.download_dml": "Last ned DirectML-pakke",
|
||||
"settings.hardware.detection_unavailable": "Maskinvaregjenkjenning utilgjengelig.",
|
||||
"settings.hardware.low_overhead": "Forhåndsinnstilling for lav belastning",
|
||||
"settings.hardware.low_overhead_note": "CPU + minste modell, for batteri-/bakgrunnsbruk",
|
||||
"settings.hardware.models": "Modeller",
|
||||
"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",
|
||||
"settings.hardware.download": "Last ned",
|
||||
"settings.hardware.diarization": "Talerdiarisering",
|
||||
"settings.hardware.diarization_hint": "Installer begge modellene for å få ferdige opptak delt opp per taler (Taler 1, Taler 2, …) i stedet for én sammenhengende transkripsjon. Kjører helt frakoblet, én gang per møte etter at det stopper. Uten dem tilskrives hver linje én enkelt taler.",
|
||||
|
||||
"settings.storage.title": "Lagring",
|
||||
"settings.storage.location_label": "Plassering",
|
||||
"settings.storage.location_hint": "Å endre lagringsplassering støttes ikke ennå — det er her møter, modeller og databasen for øyeblikket ligger.",
|
||||
"settings.storage.retention_title": "Oppbevaring",
|
||||
"settings.storage.retention_hint": "Sletter automatisk de eldste møtene over en grense. Sjekkes én gang ved oppstart; tomt betyr ingen grense. Berører aldri et møte som tas opp for øyeblikket.",
|
||||
"settings.storage.max_age": "Maksalder (dager)",
|
||||
"settings.storage.max_size": "Maksstørrelse (GB)",
|
||||
"settings.storage.no_limit": "ingen grense",
|
||||
"settings.storage.export_import_title": "Eksport og import",
|
||||
"settings.storage.export_hint_1": "Flytt opptak mellom datamaskiner som bærbare pakkemapper (lyd, transkripsjon, notater, sammendrag og et",
|
||||
"settings.storage.export_hint_2": "manifest). Eksporter til en hvilken som helst mappe — en synkronisert stasjon, en USB-pinne eller et synkroniseringsmåls lokale monteringspunkt — og importer det så på den andre maskinen. Importerte møter får en ny id, så re-import overskriver aldri noe.",
|
||||
"settings.storage.export_dialog_title": "Eksporter alle møter til…",
|
||||
"settings.storage.import_dialog_title": "Importer en pakke (eller en mappe med pakker)…",
|
||||
"settings.storage.exporting": "Eksporterer…",
|
||||
"settings.storage.export_all": "Eksporter alle møter…",
|
||||
"settings.storage.importing": "Importerer…",
|
||||
"settings.storage.import_all": "Importer møter…",
|
||||
"settings.storage.exported_one": "Eksporterte {n} møte.",
|
||||
"settings.storage.exported_many": "Eksporterte {n} møter.",
|
||||
"settings.storage.imported_one": "Importerte {n} møte.",
|
||||
"settings.storage.imported_many": "Importerte {n} møter.",
|
||||
|
||||
"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…",
|
||||
"settings.calendar.password": "Passord",
|
||||
"settings.calendar.password_note": "sjelden nødvendig",
|
||||
"settings.calendar.optional": "valgfritt",
|
||||
"settings.calendar.import_range": "Importområde",
|
||||
"settings.calendar.range_30": "Siste 30 dager",
|
||||
"settings.calendar.range_90": "Siste 90 dager",
|
||||
"settings.calendar.range_180": "Siste 6 måneder",
|
||||
"settings.calendar.range_365": "Siste år",
|
||||
"settings.calendar.range_all": "All tid",
|
||||
"settings.calendar.range_hint": "En langlivet postkasse kan inneholde år med gjentakende/helligdagsoppføringer — å snevre inn området holder den importerte kalenderen til det som faktisk er relevant. Gjelder både denne importknappen og automatisk re-import ved oppstart.",
|
||||
"settings.calendar.importing": "Importerer…",
|
||||
"settings.calendar.import": "Importer",
|
||||
"settings.calendar.import_progress": "{processed}/{total} hendelser",
|
||||
"settings.calendar.auto_reimport": "Importer denne filen automatisk ved oppstart",
|
||||
"settings.calendar.auto_reimport_hint": "Kjører én gang ved oppstart, ikke på en tidtaker — re-import er trygt å gjenta (eksisterende hendelser matches og oppdateres, ikke dupliseres).",
|
||||
"settings.calendar.import_failed": "Importen mislyktes: {error} — selve filen er urørt; sjekk banen og prøv igjen.",
|
||||
"settings.calendar.cleanup_title": "Rydd opp",
|
||||
"settings.calendar.cleanup_hint": "Hendelser som allerede er knyttet til et innspilt møte beholdes alltid, uansett hvilket alternativ som velges nedenfor.",
|
||||
"settings.calendar.cleanup_30": "Eldre enn 30 dager",
|
||||
"settings.calendar.cleanup_90": "Eldre enn 90 dager",
|
||||
"settings.calendar.cleanup_180": "Eldre enn 6 måneder",
|
||||
"settings.calendar.cleanup_365": "Eldre enn 1 år",
|
||||
"settings.calendar.cleanup_all": "Slett alle",
|
||||
"settings.calendar.cleaning": "Rydder…",
|
||||
"settings.calendar.cleanup_btn": "Rydd opp i kalenderen",
|
||||
"settings.calendar.cleanup_result": "Slettet {deleted}{extra}.",
|
||||
"settings.calendar.cleanup_kept": ", beholdt {n} (knyttet til et møte)",
|
||||
"settings.calendar.cleanup_failed": "Oppryddingen mislyktes: {error}",
|
||||
"settings.calendar.imported_events": "Importerte hendelser",
|
||||
"settings.calendar.no_events": "Ingen hendelser importert ennå.",
|
||||
"settings.calendar.search_title": "Søk tittel",
|
||||
"settings.calendar.search_placeholder": "Møtenavn…",
|
||||
"settings.calendar.date_label": "Dato",
|
||||
"settings.calendar.events_count": "{shown} av {total} hendelser",
|
||||
"settings.calendar.untitled": "(uten tittel)",
|
||||
|
||||
"settings.dialog_title": "Innstillinger",
|
||||
"settings.close": "Lukk",
|
||||
"settings.close_title": "Lukk (Esc)",
|
||||
|
||||
"settings.sync.stub": "Forhåndsvisningsmodus — synkroniseringsbackenden er ikke implementert ennå (Fase 9). Endringer beholdes kun i grensesnittet.",
|
||||
"settings.sync.title": "Synkronisering og opplasting",
|
||||
"settings.sync.enable_label": "Aktiver opplasting av møteartefakter til konfigurerte mål",
|
||||
"settings.sync.enable_hint": "Av som standard. Ingenting lastes opp med mindre dette er på og et mål er aktivert. Selvhostede mål holder data på din egen server; tredjepartsskyer er tydelig merket.",
|
||||
"settings.sync.targets_title": "Mål",
|
||||
"settings.sync.no_targets": "Ingen mål ennå. Legg til ett nedenfor.",
|
||||
"settings.sync.third_party": "tredjepart",
|
||||
"settings.sync.your_server": "din server",
|
||||
"settings.sync.edit": "Rediger",
|
||||
"settings.sync.remove": "Fjern",
|
||||
"settings.sync.edit_target": "Rediger mål",
|
||||
"settings.sync.add_target": "Legg til et mål",
|
||||
"settings.sync.webdav_hint": "WebDAV dekker Nextcloud, ownCloud, Cloudreve, Seafile og Synology. (Seafile: aktiver SeafDAV på serversiden.)",
|
||||
"settings.sync.name": "Navn",
|
||||
"settings.sync.name_placeholder": "Hjemme-Nextcloud",
|
||||
"settings.sync.provider": "Leverandør",
|
||||
"settings.sync.server_url": "Server-URL",
|
||||
"settings.sync.server_url_note_1": "Bare server-URL-en din — WhispAssist legger til",
|
||||
"settings.sync.server_url_note_2": "automatisk.",
|
||||
"settings.sync.remote_folder": "Ekstern mappe",
|
||||
"settings.sync.username": "Brukernavn",
|
||||
"settings.sync.app_password": "App-passord",
|
||||
"settings.sync.pw_keep": "la stå tomt for å beholde nåværende passord",
|
||||
"settings.sync.pw_store": "lagret i operativsystemets legitimasjonslager",
|
||||
"settings.sync.upload_legend": "Last opp",
|
||||
"settings.sync.artifact_transcript": "transkripsjon",
|
||||
"settings.sync.artifact_notes": "notater",
|
||||
"settings.sync.artifact_summary": "sammendrag",
|
||||
"settings.sync.artifact_recording": "opptak (.wav, hvis beholdt)",
|
||||
"settings.sync.allow_plaintext": "Tillat klartekst-http for en LAN-adresse (ikke anbefalt)",
|
||||
"settings.sync.encrypt_before": "Krypter før opplasting (destinasjonen lagrer kun kryptert tekst)",
|
||||
"settings.sync.requires_vault": "— krever et opplåst hvelv",
|
||||
"settings.sync.test_connection": "Test tilkobling",
|
||||
"settings.sync.save_changes": "Lagre endringer",
|
||||
"settings.sync.add_target_btn": "Legg til mål",
|
||||
"settings.sync.cancel": "Avbryt",
|
||||
"settings.sync.third_party_banner": "{kind} er en tredjepartssky — opplasting sender dataene dine bort fra enheten din til {kind}.",
|
||||
"settings.sync.link_account": "Koble til {kind}-konto…",
|
||||
"settings.sync.oauth_hint": "Åpner en OAuth-innlogging (loopback-omdirigering); tokenet lagres i operativsystemets legitimasjonslager.",
|
||||
"settings.sync.footnote": "Legitimasjon skrives aldri til innstillingene eller databasen — kun til operativsystemets legitimasjonslager. TLS kreves for mål utenfor LAN.",
|
||||
|
||||
"settings.ai.title": "KI-sammendragsleverandør",
|
||||
"settings.ai.intro_1": "Sammendrag kjører på en lokal LLM som standard. Pek dette mot Ollama på denne PC-en eller en annen maskin i LAN-et ditt (f.eks.",
|
||||
"settings.ai.intro_2": "— begge teller som lokalt, så ingenting forlater nettverket ditt. Hostede leverandører (Anthropic) er valgfrie, av som standard, og sender transkripsjonen til en tredjepart så snart du aktiverer en.",
|
||||
"settings.ai.provider": "Leverandør",
|
||||
"settings.ai.off": "Av",
|
||||
"settings.ai.provider_ollama": "Ollama (lokal / LAN)",
|
||||
"settings.ai.provider_custom": "Egendefinert (OpenAI-kompatibel)",
|
||||
"settings.ai.provider_anthropic": "Anthropic (Claude) — hostet, forlater denne enheten",
|
||||
"settings.ai.endpoint": "Endepunkt",
|
||||
"settings.ai.model": "Modell",
|
||||
"settings.ai.api_key": "API-nøkkel",
|
||||
"settings.ai.api_key_set_placeholder": "•••••••••••••••• (allerede angitt — la stå tomt for å beholde den)",
|
||||
"settings.ai.show_api_key": "Vis API-nøkkel",
|
||||
"settings.ai.hide_api_key": "Skjul API-nøkkel",
|
||||
"settings.ai.anthropic_banner": "Anthropic er en hostet tredjepartstjeneste — dette møtets transkripsjon forlater enheten din når du genererer et sammendrag.",
|
||||
"settings.ai.endpoint_banner": "Dette endepunktet er ikke på maskinen din eller LAN-et ditt — transkripsjonen din ville forlate nettverket ditt.",
|
||||
"settings.ai.saving": "Lagrer…",
|
||||
"settings.ai.save": "Lagre",
|
||||
"settings.ai.test_connection": "Test tilkobling",
|
||||
"settings.ai.this_hosted_provider": "denne hostede leverandøren",
|
||||
"settings.ai.reachable": "Tilgjengelig",
|
||||
"settings.ai.unreachable": "Utilgjengelig",
|
||||
"settings.ai.model_count_one": "· {n} modell",
|
||||
"settings.ai.model_count_many": "· {n} modeller",
|
||||
"settings.ai.on_lan": "· på maskinen din / LAN",
|
||||
"settings.ai.leaves_network": "· forlater nettverket ditt",
|
||||
"settings.ai.advanced_title": "Avansert Ollama-konfigurasjon",
|
||||
"settings.ai.changed": "{n} endret",
|
||||
"settings.ai.reset_all": "Tilbakestill alt til standard",
|
||||
"settings.ai.system_prompt": "Systemprompt",
|
||||
"settings.ai.system_prompt_placeholder": "f.eks. Du er en kortfattet møtesammenfatter.",
|
||||
"settings.ai.system_prompt_hint": "Legges til før WhispAssists påkrevde utdataformat, slik at instruksjonene dine ikke kan bryte tolkingen av sammendrag/oppgavepunkter.",
|
||||
"settings.ai.think": "Tenk",
|
||||
"settings.ai.think_help": "Resonneringsinnsats (kun resonneringsmodeller).",
|
||||
"settings.ai.keep_alive": "Hold i live",
|
||||
"settings.ai.keep_alive_help": "Hvor lenge modellen blir i RAM. f.eks. 5m, 1h, 0 (last ut), -1 (for alltid).",
|
||||
"settings.ai.runtime_hardware": "Kjøretid og maskinvare",
|
||||
"settings.ai.rarely_needed": "— sjelden nødvendig",
|
||||
"settings.ai.save_advanced": "Lagre avansert",
|
||||
"settings.ai.saved": "Lagret",
|
||||
"settings.ai.turn_off": "Slå av",
|
||||
"settings.ai.default_value": "Standard {value}.",
|
||||
"settings.ai.reset_default": "Tilbakestill til standard",
|
||||
"settings.ai.reset_field": "Tilbakestill {name}",
|
||||
|
||||
"settings.mcp.title": "MCP-server",
|
||||
"settings.mcp.banner_1": "Dette lar din egen kodeagent (Claude Code, Codex, Copilot, OpenCode, …) hente møtekontekst på din lokale maskin. Når den er koblet til,",
|
||||
"settings.mcp.banner_agent": "kan den agenten",
|
||||
"settings.mcp.banner_2": "videresende det den leser til sin egen modellleverandørs sky — utenfor WhispAssists kontroll. WhispAssist selv sender aldri disse dataene noe sted; serveren lytter kun på denne enheten (",
|
||||
"settings.mcp.banner_3": ") og hver lesing logges nedenfor.",
|
||||
"settings.mcp.enable": "Aktiver MCP-serveren",
|
||||
"settings.mcp.enable_hint": "Av som standard. Kun loopback, token-beskyttet — ingenting er tilgjengelig fra nettverket.",
|
||||
"settings.mcp.transport": "Transport",
|
||||
"settings.mcp.transport_http": "Strømmende HTTP",
|
||||
"settings.mcp.transport_stdio": "stdio (agenten starter en prosess)",
|
||||
"settings.mcp.port": "Port",
|
||||
"settings.mcp.transport_change_hint": "For å endre transport/port, slå av serveren først, og deretter på igjen.",
|
||||
"settings.mcp.scope_title": "Omfang",
|
||||
"settings.mcp.expose": "Eksponer",
|
||||
"settings.mcp.scope_none": "Ingen — ingenting deles",
|
||||
"settings.mcp.scope_selected": "Valgte — kun funksjonsbriefer du har merket som delte",
|
||||
"settings.mcp.scope_all": "Alle — møter, transkripsjoner, oppgavepunkter og delte briefer",
|
||||
"settings.mcp.expose_recordings": "Tillat også møter med et lagret opptak (av som standard — et innspilt møtes transkripsjon holdes tilbake selv i «Alle»-omfanget til dette er på)",
|
||||
"settings.mcp.token_title": "Nytt autentiseringstoken — vises én gang, kopier det nå",
|
||||
"settings.mcp.token_hint": "Dette vises ikke igjen. Det lagres i operativsystemets legitimasjonslager; hvis du mister det, slå av og på serveren igjen for å lage et nytt.",
|
||||
"settings.mcp.copied": "Kopiert",
|
||||
"settings.mcp.copy": "Kopier",
|
||||
"settings.mcp.endpoint": "Endepunkt",
|
||||
"settings.mcp.endpoint_hint": "Pek agentens MCP-klientkonfigurasjon mot {kind}, med tokenet ovenfor som bearer-legitimasjon.",
|
||||
"settings.mcp.kind_command": "kommando",
|
||||
"settings.mcp.kind_url": "URL",
|
||||
"settings.mcp.access_log": "Tilgangslogg",
|
||||
"settings.mcp.access_log_hint": "Hver verktøylesing en agent gjør, tillatt eller nektet (FR-MCP-5).",
|
||||
"settings.mcp.no_reads": "Ingen agent har lest noe ennå.",
|
||||
"settings.mcp.log_meeting": "møte {id}",
|
||||
"settings.mcp.refresh": "Oppdater",
|
||||
|
||||
"settings.privacy.title": "Personvern",
|
||||
"settings.privacy.intro": "Hva WhispAssist faktisk har lov til å sende bort fra denne enheten akkurat nå. Med alt av (standard) forlater ingenting enheten i det hele tatt.",
|
||||
"settings.privacy.llm_endpoint": "LLM-endepunkt",
|
||||
"settings.privacy.off": "av",
|
||||
"settings.privacy.local_only": "kun lokal",
|
||||
"settings.privacy.leaves_device": "forlater denne enheten",
|
||||
"settings.privacy.sync_label": "Synkronisering",
|
||||
"settings.privacy.enabled": "aktivert",
|
||||
"settings.privacy.mcp_label": "MCP-server",
|
||||
"settings.privacy.mcp_on": "på · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "Innkommende kun på loopback — legger ingenting til utgangslisten ovenfor. En tilkoblet agent kan likevel videresende det den leser til sin egen modellleverandør; se MCP-server-fanen.",
|
||||
"settings.privacy.egress_title": "Tillatelsesliste for utgående",
|
||||
"settings.privacy.no_egress": "Ingen verter er tillatt — WA gjør ingen utgående overføring av innhold.",
|
||||
"settings.privacy.sync_targets_title": "Synkroniseringsmål",
|
||||
"settings.privacy.third_party": "tredjepart",
|
||||
"settings.privacy.your_server": "din server",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "ingen TLS",
|
||||
"settings.privacy.refresh": "Oppdater",
|
||||
"settings.privacy.unavailable": "Personvern-selvsjekk utilgjengelig.",
|
||||
"settings.privacy.vault_title": "Krypteringshvelv",
|
||||
"settings.privacy.vault_intro": "Krypter notater, transkripsjoner og sammendrag i hvile med et passord. (Lydfiler krypteres ikke ennå.)",
|
||||
"settings.privacy.vault_password": "Hvelvpassord",
|
||||
"settings.privacy.enable_vault": "Aktiver hvelv",
|
||||
"settings.privacy.vault_pw_hint": "Bruk minst 8 tegn. Hvis du glemmer det, kan kryptert innhold ikke gjenopprettes.",
|
||||
"settings.privacy.vault_locked_1": "Hvelvet er ",
|
||||
"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",
|
||||
"settings.privacy.vault_unlocked_2": ". Nye notater, transkripsjoner og sammendrag krypteres i hvile.",
|
||||
"settings.privacy.lock_now": "Lås nå",
|
||||
"settings.privacy.change_password": "Endre passord",
|
||||
"settings.privacy.current_password": "Nåværende passord",
|
||||
"settings.privacy.new_password": "Nytt passord",
|
||||
|
||||
"settings.about.title": "Om",
|
||||
"settings.about.tagline": "En helt lokal, åpen kildekode, Windows-innebygd møteassistent.",
|
||||
"settings.about.build_commit": "Build-commit",
|
||||
|
||||
"consent.heading": "Før du tar opp",
|
||||
"consent.body": "Å ta opp samtaler uten deltakernes samtykke kan være ulovlig i din region. Sjekk dine lokale opptakslover. Dette er en advarsel, ikke juridisk rådgivning.",
|
||||
"consent.accept": "Jeg forstår — aktiver opptak",
|
||||
"consent.cancel": "Avbryt",
|
||||
|
||||
"hosted.heading": "Før du bruker {provider}",
|
||||
"hosted.body_1": "Å generere med",
|
||||
"hosted.body_2": "sender dette møtets transkripsjon til deres servere — den forlater denne enheten og er underlagt deres personvernerklæring. WhispAssist har ingen kontroll over datahåndteringen når den først forlater enheten din. Dette er av som standard; du velger det nå.",
|
||||
"hosted.accept": "Jeg forstår — fortsett",
|
||||
"hosted.cancel": "Avbryt",
|
||||
|
||||
"theme.group": "Tema",
|
||||
"theme.system": "Følg systemet",
|
||||
"theme.light": "Lyst",
|
||||
"theme.dark": "Mørkt",
|
||||
|
||||
"import.dialog_label": "Legg til et møte fra en fil eller URL",
|
||||
"import.title": "Legg til et møte",
|
||||
"import.close": "Lukk",
|
||||
"import.close_title": "Lukk (Esc)",
|
||||
"import.body": "Importer et eksisterende opptak — en lokal lyd-/videofil eller en lenke (YouTube, en strømmeside eller en direkte medie-URL). Det transkriberes og diariseres akkurat som et direkteopptak.",
|
||||
"import.file_or_url": "Fil eller URL",
|
||||
"import.source_placeholder": "Lim inn en URL, eller bla etter en fil…",
|
||||
"import.choose_file": "Velg en lokal fil",
|
||||
"import.browse": "Bla gjennom…",
|
||||
"import.title_label": "Tittel",
|
||||
"import.optional": "valgfritt",
|
||||
"import.title_placeholder": "Standard er filnavnet",
|
||||
"import.filter_av": "Lyd / video",
|
||||
"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}",
|
||||
|
||||
"levelmeter.system_mic": "Inngangsnivå for system og mikrofon",
|
||||
"levelmeter.system": "Inngangsnivå for system",
|
||||
|
||||
"app.tagline": "lokal · privat",
|
||||
"app.note_template": "Notatmal",
|
||||
"app.no_template": "Ingen mal",
|
||||
"app.add_meeting": "Legg til møte",
|
||||
"app.add_meeting_title": "Legg til et møte fra en fil eller URL",
|
||||
"app.record": "Ta opp",
|
||||
"app.record_title": "Start opptak (Ctrl+Shift+R)",
|
||||
"app.stop": "Stopp",
|
||||
"app.stop_title": "Stopp opptak (Ctrl+Shift+R)",
|
||||
"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",
|
||||
"app.not_saved": "ikke lagret",
|
||||
"app.reconnecting": "kobler til lydenhet på nytt…",
|
||||
"app.settings": "Innstillinger",
|
||||
"app.settings_title": "Innstillinger (Ctrl+,)",
|
||||
"app.discard_confirm": "Forkast dette opptaket? Lyden og transkripsjonen vil bli slettet.",
|
||||
"app.announce_started": "Opptak startet",
|
||||
"app.announce_paused": "Opptak satt på pause",
|
||||
"app.announce_stopped": "Opptak stoppet",
|
||||
"app.consent_dialog": "Samtykke til opptak",
|
||||
"app.vault_dialog": "Lås opp krypteringshvelvet",
|
||||
"app.vault_title": "Lås opp krypteringshvelvet",
|
||||
"app.vault_desc": "Møtene dine er kryptert i hvile. Skriv inn passordet ditt for å lese dem.",
|
||||
"app.vault_password": "Hvelvpassord",
|
||||
"app.vault_incorrect": "Feil passord",
|
||||
"app.unlock": "Lås opp",
|
||||
"app.continue_locked": "Fortsett låst",
|
||||
"app.show_meetings": "Vis møteliste",
|
||||
"app.hide_meetings": "Skjul møteliste",
|
||||
"app.show_summary": "Vis sammendragspanel",
|
||||
"app.hide_summary": "Skjul sammendragspanel",
|
||||
"app.resize_meetings": "Endre størrelse på møtelisten",
|
||||
"app.resize_summary": "Endre størrelse på sammendragspanelet",
|
||||
|
||||
"meetings.search_placeholder": "Søk i møter…",
|
||||
"meetings.search_aria": "Søk i møter",
|
||||
"meetings.filter_tag_aria": "Filtrer etter tagg",
|
||||
"meetings.all_tags": "Alle tagger",
|
||||
"meetings.from_date_aria": "Fra dato",
|
||||
"meetings.to_date_aria": "Til dato",
|
||||
"meetings.bulk_format_aria": "Format for masseeksport",
|
||||
"meetings.bulk_export": "Masseeksport",
|
||||
"meetings.bulk_export_title": "Eksporter alle møter som samsvarer med tagg-/datofiltrene ovenfor",
|
||||
"meetings.exporting": "Eksporterer…",
|
||||
"meetings.exported_one": "Eksporterte {n} møte.",
|
||||
"meetings.exported_many": "Eksporterte {n} møter.",
|
||||
"meetings.searching": "Søker…",
|
||||
"meetings.loading": "Laster inn møter…",
|
||||
"meetings.no_matches": "Ingen treff.",
|
||||
"meetings.empty": "Ingen møter ennå — klikk Ta opp ovenfor for å starte.",
|
||||
"meetings.status.recording": "tar opp",
|
||||
"meetings.status.transcribing": "transkriberer",
|
||||
"meetings.status.recovering": "gjenoppretter",
|
||||
"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",
|
||||
"transcript.hide": "Skjul transkripsjon",
|
||||
"transcript.empty": "Ingen transkripsjon for dette møtet.",
|
||||
"transcript.will_appear": "Transkripsjonen vil vises her mens du tar opp.",
|
||||
"transcript.play_from_here": "Spill av herfra",
|
||||
"transcript.resize": "Endre størrelse på transkripsjon og notater",
|
||||
"transcript.reprocess_placeholder": "Transkriber på nytt med…",
|
||||
"transcript.reprocess_lang_aria": "Språk for ombehandling",
|
||||
"transcript.reprocess_keep_lang": "Behold nåværende språk",
|
||||
"transcript.reprocess_go": "Kjør",
|
||||
"transcript.retranscribing": "Transkriberer på nytt…",
|
||||
"transcript.has_note": "Har et notat",
|
||||
"transcript.note_placeholder": "Legg til et notat for dette øyeblikket…",
|
||||
"transcript.note_aria": "Notat for denne transkripsjonslinjen",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "Word-dokument",
|
||||
"transcript.filter_obsidian": "Obsidian-notat",
|
||||
|
||||
"notes.heading": "Notater",
|
||||
"notes.show": "Vis notater",
|
||||
"notes.hide": "Skjul notater",
|
||||
"notes.placeholder": "Notater…",
|
||||
"notes.live_placeholder": "Skriv notater mens du snakker…",
|
||||
"notes.toolbar_aria": "Notatformatering",
|
||||
"notes.bold": "Fet",
|
||||
"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",
|
||||
"notes.preview": "Forhåndsvisning",
|
||||
"notes.export_md_title": "Eksporter notater som .md",
|
||||
"notes.export_pdf_title": "Eksporter notater som .pdf",
|
||||
"notes.export_docx_title": "Eksporter notater som .docx",
|
||||
"notes.export_bundle_title": "Eksporter lyd + transkripsjon + notater til en mappe",
|
||||
"notes.export_obsidian_title": "Eksporter ett Obsidian-notat (notater + sammendrag + transkripsjon, ingen lyd)",
|
||||
|
||||
"summary.recording_heading": "Opptak",
|
||||
"summary.loading_recording": "Laster inn opptak…",
|
||||
"summary.sync_heading": "Synkronisering",
|
||||
"summary.uploading": "Laster opp…",
|
||||
"summary.upload_now": "Last opp nå",
|
||||
"summary.no_uploads": "Ingen opplastinger for dette møtet ennå.",
|
||||
"summary.retry": "prøv igjen",
|
||||
"summary.tags_heading": "Tagger",
|
||||
"summary.tags_select": "Velg et møte for å tagge det.",
|
||||
"summary.tag_add_placeholder": "Legg til tagg…",
|
||||
"summary.tag_first_placeholder": "prosjekt, kunde, tema…",
|
||||
"summary.generating": "Genererer…",
|
||||
"summary.generate_tags": "Generer tagger",
|
||||
"summary.saving": "Lagrer…",
|
||||
"summary.save_tags": "Lagre tagger",
|
||||
"summary.summary_heading": "Sammendrag",
|
||||
"summary.ai_provider": "KI-leverandør",
|
||||
"summary.provider_off": "Av",
|
||||
"summary.provider_custom": "Egendefinert",
|
||||
"summary.local": "lokal",
|
||||
"summary.leaves_device": "forlater denne enheten",
|
||||
"summary.this_hosted_provider": "denne hostede leverandøren",
|
||||
"summary.select_generate": "Velg et møte for å generere et sammendrag.",
|
||||
"summary.decisions_heading": "Beslutninger",
|
||||
"summary.regenerate": "Generer på nytt",
|
||||
"summary.generated_hint": "Generert lokalt etter møtet (krever en lokal LLM-leverandør).",
|
||||
"summary.no_provider": "Ingen KI-leverandør konfigurert — aktiver en i Innstillinger.",
|
||||
"summary.generate_summary": "Generer sammendrag",
|
||||
"summary.briefs_heading": "Funksjonsbriefer",
|
||||
"summary.briefs_select": "Velg et møte for å opprette eller vise funksjonsbriefer.",
|
||||
"summary.briefs_desc": "Destillerer dette møtet til en agentklar spesifikasjon (krever en lokal LLM-leverandør) — gi den til en kodeagent, eller server den over MCP-serveren når den er på.",
|
||||
"summary.brief_repo_placeholder": "Målrepo (valgfritt), f.eks. acme/reporting-web",
|
||||
"summary.distilling": "Destillerer…",
|
||||
"summary.create_brief": "Opprett funksjonsbrief",
|
||||
"summary.brief_mcp_title": "Tilgjengelig når MCP-serveren er på",
|
||||
"summary.copied": "Kopiert",
|
||||
"summary.copy_md": "Kopier som Markdown",
|
||||
"summary.copy_json": "Kopier som JSON",
|
||||
"summary.brief_problem": "Problem",
|
||||
"summary.brief_outcome": "Ønsket resultat",
|
||||
"summary.brief_criteria": "Akseptkriterier",
|
||||
"summary.brief_none": "Ingenting fanget.",
|
||||
"summary.brief_context": "Kontekst",
|
||||
"summary.action_items_heading": "Oppgavepunkter",
|
||||
"summary.ai_select": "Velg et møte for å se oppgavepunktene.",
|
||||
"summary.ai_empty": "Ingen ennå — legg til ett nedenfor, eller generer et sammendrag for å hente dem automatisk.",
|
||||
"summary.confirmed": "Bekreftet",
|
||||
"summary.ai_text_placeholder": "Oppgavepunkt…",
|
||||
"summary.ai_text_aria": "Tekst for oppgavepunkt",
|
||||
"summary.owner": "Ansvarlig",
|
||||
"summary.due_date": "Forfallsdato",
|
||||
"summary.reminder_title": "Planlegg en lokal påminnelse for denne forfallsdatoen",
|
||||
"summary.ai_delete_title": "Slett dette oppgavepunktet",
|
||||
"summary.ai_delete_aria": "Slett oppgavepunkt",
|
||||
"summary.add_action_item": "Legg til oppgavepunkt",
|
||||
"summary.save_action_items": "Lagre oppgavepunkter",
|
||||
"summary.calendar_heading": "Kalenderhendelse",
|
||||
"summary.cal_select": "Velg et møte for å koble det til en kalenderhendelse.",
|
||||
"summary.event_search_placeholder": "Søk hendelsestittel…",
|
||||
"summary.linked_event": "Koblet hendelse",
|
||||
"summary.change_event": "Endre hendelse…",
|
||||
"summary.link_event": "Koble til en hendelse…",
|
||||
"summary.untitled_event": "(uten tittel)",
|
||||
"summary.no_events_1": "Ingen hendelser importert ennå — importer en",
|
||||
"summary.no_events_2": "fra Innstillinger → Kalender.",
|
||||
"summary.no_events_match": "Ingen hendelser samsvarer med dette søket/datoen — prøv å fjerne ett.",
|
||||
"summary.organizer_label": "Arrangør: {name}",
|
||||
"summary.participants_heading": "Deltakere",
|
||||
"summary.participants_hint": "Fylles ut fra den koblede kalenderhendelsen.",
|
||||
"summary.speakers_heading": "Talere",
|
||||
"summary.speakers_select": "Velg et møte for å navngi talerne.",
|
||||
"summary.no_speakers": "Ingen talere oppdaget ennå.",
|
||||
"summary.speaker_name_placeholder": "Talerens navn",
|
||||
"summary.save": "Lagre",
|
||||
"summary.cancel": "Avbryt",
|
||||
"summary.name_speaker": "Navngi denne taleren…",
|
||||
"summary.add_new_name": "+ Legg til nytt navn…",
|
||||
|
||||
"settings.recording.title": "Opptak",
|
||||
"settings.recording.record_default": "Ta opp møter som standard",
|
||||
"settings.recording.save_audio_as": "lagre lyd som",
|
||||
"settings.recording.default_hint": "Av som standard. Når det er av, brukes lyd kun til å produsere transkripsjonen og slettes når møtet fullføres. Du kan også veksle opptak per møte.",
|
||||
"settings.recording.consent_label": "Samtykke: {status}.",
|
||||
"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.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."
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user