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 | ||
|
|
246fb60731 | ||
|
|
233c6fa8cd | ||
|
|
2eff32662b | ||
|
|
f0480244bf | ||
|
|
2a0379ce43 | ||
|
|
91677476cc | ||
|
|
76a4f8ce08 | ||
|
|
2603e12b36 | ||
|
|
90ba14cc2d | ||
|
|
7106dc1584 | ||
|
|
84b9087de7 | ||
|
|
1634820523 | ||
|
|
57127cfa07 | ||
|
|
49893d5874 | ||
|
|
a14ca871e1 | ||
|
|
2fda4041ff | ||
|
|
0ecba7f055 | ||
|
|
ea79fb656d | ||
|
|
a7eba7fd82 | ||
|
|
d08d03077c | ||
|
|
3b0f27f5d9 | ||
|
|
f2db826e8c | ||
|
|
a5d81bd9e4 | ||
|
|
010941e620 | ||
|
|
90580da0af | ||
|
|
e715c09ce0 | ||
|
|
da25ccaec6 | ||
|
|
e7f628ad7a | ||
|
|
25d0232256 | ||
|
|
34b4f4ae09 | ||
|
|
96b30ed99a | ||
|
|
a37f766817 | ||
|
|
4cdeb8f475 | ||
|
|
5828023651 | ||
|
|
3202f884af | ||
|
|
996c7edc99 | ||
|
|
bb688418bf | ||
|
|
97b5f67049 | ||
|
|
55ddaad544 | ||
|
|
75f7d19ade | ||
|
|
2fb5fd6f30 | ||
|
|
b8dc5fc37f | ||
|
|
38b9cb2910 | ||
|
|
137605934c | ||
|
|
5b281531fb | ||
|
|
2759a0e49a | ||
|
|
8e3e22c0e5 | ||
|
|
bb1d1730f8 | ||
|
|
16fcb154f3 | ||
|
|
cf4232cbae | ||
|
|
4a30674008 | ||
|
|
e16d9e5ade | ||
|
|
1a31cac65b | ||
|
|
ba1b577734 | ||
|
|
fb406d62da | ||
|
|
68c32ffa06 | ||
|
|
2efe29558a | ||
|
|
3cd267f737 | ||
|
|
f9e75b7d17 | ||
|
|
ae9e8398c5 | ||
|
|
4f80c14f72 | ||
|
|
e68e888c07 | ||
|
|
7e646b3e0f | ||
|
|
00de9d3f68 | ||
|
|
e6a2109c9c | ||
|
|
67ac4c3813 | ||
|
|
9d757be415 | ||
|
|
871b11d75e | ||
|
|
75ec24ddf6 | ||
|
|
4637ce8aac | ||
|
|
6f07f28cef | ||
|
|
fa8a7f2552 | ||
|
|
8f98332cdd | ||
|
|
2ca4151444 | ||
|
|
66aae232c5 |
@@ -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"
|
||||
@@ -41,3 +41,12 @@ Thumbs.db
|
||||
# NPU runtime bundle: a large binary artifact hosted as a Gitea package, not
|
||||
# committed. The folder's README is tracked; the zip is produced locally.
|
||||
packaging/npu-runtime/*.zip
|
||||
|
||||
# Vulkan loader staged next to the exe / into src-tauri by build.rs for the
|
||||
# --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,35 +11,46 @@ 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.1.5).** Capture, transcription (CPU / Intel NPU / Vulkan
|
||||
> GPU), speaker diarization, storage + crash recovery, local-LLM summaries, opt-in recording,
|
||||
> at-rest encryption, and self-hosted sync are implemented and ship as signed **MSI + NSIS**
|
||||
> installers. Outlook `.pst`/calendar context and the coding-agent (MCP) handoff are 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 |
|
||||
| **Transcription** | тШБя╕П cloud | тЬЕ local (Whisper) | тЬЕ local (whisper.cpp) |
|
||||
| **NPU / GPU auto-acceleration** | n/a (cloud) | тЪая╕П CPU/GPU, manual | тЬЕ **NPUтЖТNVIDIAтЖТAMDтЖТIntelтЖТCPU** ladder, one binary |
|
||||
| **Speaker diarization** | тЬЕ cloud | тЪая╕П limited | тЬЕ offline (sherpa-onnx) |
|
||||
| **Summaries / notes AI** | тШБя╕П cloud LLM | тЬЕ local (Ollama) / BYO | тЬЕ local (Ollama, localhost **or LAN**) + optional hosted |
|
||||
| **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 |
|
||||
| **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 |
|
||||
| **License** | Proprietary | Open source (MIT) | Open source (MIT / Apache-2.0) |
|
||||
| **Cost** | Subscription | Free | Free |
|
||||
| | **Granola** | **Meetily** | **WhispAssist** |
|
||||
| ---------------------------------------------- | ----------------------------------------------- | --------------------------------- | ----------------------------------------------------------- |
|
||||
| **Positioning** | Cloud AI notepad | Open-source self-hosted assistant | Local, Windows-native assistant |
|
||||
| **Platform** | macOS, Windows | macOS, Windows, Linux | Windows 10/11 |
|
||||
| **Bot-free system-audio capture** | тЬЕ | тЬЕ | тЬЕ WASAPI loopback |
|
||||
| **Transcription** | тШБя╕П cloud | тЬЕ local (Whisper) | тЬЕ local (whisper.cpp) |
|
||||
| **NPU / GPU auto-acceleration** | n/a (cloud) | тЪая╕П CPU/GPU, manual | тЬЕ **NPUтЖТNVIDIAтЖТAMDтЖТIntelтЖТCPU** ladder, one binary |
|
||||
| **Speaker diarization** | тЬЕ cloud | тЪая╕П limited | тЬЕ offline (sherpa-onnx) |
|
||||
| **Summaries / notes AI** | тШБя╕П cloud LLM | тЬЕ local (Ollama) / BYO | тЬЕ local (Ollama, localhost **or LAN**) + optional hosted |
|
||||
| **Interface localization (UI language)** | тЪая╕П English-only UI (multilingual transcription) | тЪая╕П English-only UI (more planned) | тЬЕ **20 languages** + RTL (Arabic/Urdu) |
|
||||
| **Default data egress** | тШБя╕П audio + notes to cloud | ЁЯФТ local (cloud optional) | ЁЯФТ **none** тАФ everything off by default, allowlist-enforced |
|
||||
| **Calendar / Outlook `.pst` context** | тЬЕ cloud calendar | тЭМ | тЬЕ local `.pst` import + **auto-record on events** |
|
||||
| **Import an existing recording (file/URL)** | тЭМ live capture only | тЭМ | тЬЕ any file or URL (ffmpeg + yt-dlp) |
|
||||
| **Opt-in recording + consent notice** | тЪая╕П partial | тЭМ | тЬЕ off by default, one-time consent |
|
||||
| **At-rest encryption** | тШБя╕П server-side | тЭМ | тЬЕ vault: Argon2id + XChaCha20-Poly1305 |
|
||||
| **Self-hosted sync w/ client-side encryption** | тЭМ | тЪая╕П | тЬЕ WebDAV + OAuth, **encrypt-before-upload** |
|
||||
| **Coding-agent (MCP) server** | тЬЕ **cloud** MCP (notes via their servers) | тЭМ | тЬЕ **local, loopback-only**, token-gated, adds no egress |
|
||||
| **License** | Proprietary | Open source (MIT) | Open source (MIT / Apache-2.0) |
|
||||
| **Cost** | Subscription | Free | Free |
|
||||
|
||||
<sub>Comparison reflects each project's public positioning as of mid-2026. Granola 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
|
||||
@@ -44,37 +59,86 @@ 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.1.5)
|
||||
## What's built (v0.7.3)
|
||||
|
||||
- **Bot-free capture** тАФ WASAPI loopback records the system mix (all participants) with no
|
||||
meeting bot and 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.).
|
||||
- **Storage & crash recovery** тАФ SQLite + on-disk audio/transcripts under
|
||||
`%LOCALAPPDATA%\WhispAssist`. Audio is the source of truth; notes and transcripts regenerate
|
||||
after a crash.
|
||||
- **Opt-in recording** тАФ off by default; `.wav` retained only when you turn it on, after a
|
||||
one-time consent notice.
|
||||
- **Opt-in recording + in-app playback** тАФ off by default; `.wav` retained only when you turn it
|
||||
on, after a one-time consent notice. Play a saved recording back in the app тАФ encrypted
|
||||
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, 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,
|
||||
Seafile, Synology) plus OneDrive/Dropbox/Box (OAuth 2.0 PKCE); durable retry queue with
|
||||
backoff; **client-side encryption before upload** so the destination holds only ciphertext.
|
||||
Credentials live only in the OS credential store.
|
||||
backoff and **live per-item upload progress**; **client-side encryption before upload** so the
|
||||
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).
|
||||
- **Installers** тАФ signed MSI and NSIS `-setup.exe`.
|
||||
- **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)
|
||||
|
||||
@@ -85,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
|
||||
@@ -110,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)
|
||||
@@ -121,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}
|
||||
@@ -142,18 +223,28 @@ npm install
|
||||
npm run tauri dev # CPU/NPU build
|
||||
```
|
||||
|
||||
**GPU (Vulkan) build.** whisper.cpp's GPU backends are compiled in (not downloaded at runtime),
|
||||
so a GPU build needs a one-time toolchain setup тАФ the **Vulkan SDK**, a **Ninja** generator, and
|
||||
a short target dir (to dodge Windows' 260-char path limit in the shader build):
|
||||
**Release build (single universal installer).** whisper.cpp's GPU backends are compiled in (not
|
||||
downloaded at runtime), so the release build needs a one-time toolchain setup тАФ the **Vulkan SDK**,
|
||||
a **Ninja** generator, and a short target dir (to dodge Windows' 260-char path limit in the shader
|
||||
build):
|
||||
|
||||
```bash
|
||||
# after: Vulkan SDK installed, ninja.exe on PATH, vcvars64 loaded
|
||||
set VULKAN_SDK=C:\VulkanSDK\1.4.350.0
|
||||
set CMAKE_GENERATOR=Ninja
|
||||
set CARGO_TARGET_DIR=C:\wt
|
||||
npm run tauri build -- --features vulkan
|
||||
npm run tauri build -- --features vulkan --config src-tauri/tauri.vulkan.conf.json
|
||||
```
|
||||
|
||||
This one build covers **every** machine: Vulkan accelerates all GPUs (NVIDIA/AMD/Intel), the Intel
|
||||
NPU path works via the runtime OpenVINO download, and CPU is the fallback. The `--features vulkan`
|
||||
binary links `vulkan-1.dll`, so `build.rs` stages the redistributable Vulkan **loader** (from
|
||||
`VULKAN_SDK\Bin`, or System32) next to the exe and `tauri.vulkan.conf.json` bundles it into the
|
||||
installer тАФ the app then launches even on a machine with no GPU driver (it reports zero Vulkan
|
||||
devices and decodes on the CPU). DirectML is intentionally not offered here because Vulkan already
|
||||
covers those GPUs; it's the GPU path only in the plain `npm run tauri build` (no Vulkan) variant,
|
||||
kept as an internal fallback.
|
||||
|
||||
CUDA (NVIDIA-only, faster) is planned as an optional variant. The full, gotcha-annotated build
|
||||
recipe lives in the project notes.
|
||||
|
||||
|
||||
@@ -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 |
@@ -16,6 +16,9 @@ Each requirement has a stable ID used across the roadmap, tests, and commits. Pr
|
||||
| FR-CAP-4 | M | 1 | Show an unambiguous "recording active" indicator (in-app banner + tray icon). |
|
||||
| FR-CAP-5 | S | 7 | Render a live input waveform / level meter while recording. |
|
||||
| FR-CAP-6 | S | 7 | Handle audio device changes mid-recording without losing the session. |
|
||||
| FR-CAP-7 | S | 1 | Optionally capture the user's **microphone** alongside loopback, mixing it into **both** the live transcript and the saved recording (default ON, local-only/no egress, selectable device + "off"). |
|
||||
| FR-CAP-8 | S | 1 | Write the retained recording as **16-bit PCM** at the device's native rate/channels тАФ roughly half the size of the 32-bit-float mix, with no material quality loss for speech. |
|
||||
| FR-CAP-9 | S | 1 | **Cancel** an in-progress recording: stop capture, delete working files, and remove the meeting from the DB entirely (for one started by mistake тАФ no finalize/transcript/sync). |
|
||||
|
||||
### Recording retention & consent (REC) тАФ see ADR-0009
|
||||
|
||||
@@ -25,6 +28,7 @@ Each requirement has a stable ID used across the roadmap, tests, and commits. Pr
|
||||
| FR-REC-2 | M | 1 | Before retaining a recording for the first time (and shown near the toggle thereafter), display a consent notice: recording without participants' consent may be illegal in some regions; advise checking local laws. Require a one-time acknowledgment; store it. This is a caution, not legal advice. |
|
||||
| FR-REC-3 | M | 1 | When retention is on, the UI indicates the meeting is being **saved** (in addition to the "recording active" indicator, FR-CAP-4). |
|
||||
| FR-REC-4 | M | 2 | Deleting working audio on finalize happens only **after** the transcript is successfully finalized; never race with crash recovery (audio stays source of truth until then). |
|
||||
| FR-REC-5 | S | 2 | Play a meeting's retained `.wav` back in the app (decrypting a vault-sealed recording on demand for playback). |
|
||||
|
||||
### Hardware acceleration (HW)
|
||||
|
||||
@@ -152,6 +156,7 @@ the local LLM endpoint, and it is **off by default**. Primary targets are self-h
|
||||
| FR-SYNC-8 | M | 9 | Surface sync state in the UI and label targets: self-hosted/primary as "your server"; third-party clouds carry a clear "data leaves your device to a third party" banner. |
|
||||
| FR-SYNC-9 | S | 9 | **Secondary** targets via provider APIs + OAuth 2.0 (PKCE, loopback redirect): **OneDrive** (MS Graph), **Dropbox**, **Box**. |
|
||||
| FR-SYNC-10 | C | 9 | Optional client-side encryption of artifacts before upload (ties to FR-SEC-3): destination holds only ciphertext. |
|
||||
| FR-SYNC-11 | S | 9 | Show **live per-item upload progress** while syncing (stream the upload body and report bytes sent per artifact). |
|
||||
|
||||
### UX, accessibility, recovery (UX)
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ touches files, DB, or network directly тАФ only Tauri commands/events (`04-api-c
|
||||
|
||||
| Service | Responsibility | Primary crate(s) |
|
||||
|---|---|---|
|
||||
| `audio` | WASAPI loopback capture; PCM ring buffer; write WAV to disk; pause/resume | `wasapi`, `hound` |
|
||||
| `audio` | WASAPI loopback capture (+ optional microphone, mixed into the transcript stream, FR-CAP-7); PCM ring buffer; write WAV to disk; pause/resume | `wasapi`, `hound` |
|
||||
| `hardware` | Enumerate NPU/GPU/CPU; rank backends; report capabilities | `ort`, DXGI via `windows` |
|
||||
| `transcription` | Load model on a backend; stream segments (whisper.cpp) or NPU (ONNX) | `whisper-rs`, `ort` |
|
||||
| `diarization` | Post-process audio тЖТ speaker spans; align to segments; merge | `sherpa-onnx` (FFI) |
|
||||
@@ -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
|
||||
|
||||
+100
-10
@@ -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).
|
||||
@@ -125,17 +198,23 @@ privacy_self_check(): {
|
||||
## 2. Tauri events (Rust тЖТ frontend)
|
||||
|
||||
```ts
|
||||
"recording://state" { meetingId, state: "recording"|"paused"|"stopped", elapsedMs }
|
||||
"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 }
|
||||
@@ -154,10 +233,15 @@ indicative (async where I/O-bound).
|
||||
pub trait AudioCapture: Send + Sync {
|
||||
/// Begin WASAPI loopback capture, writing PCM to `wav_path`; frames also pushed to `sink`.
|
||||
fn start(&self, wav_path: &Path, sink: FrameSink) -> Result<CaptureHandle, AudioError>;
|
||||
/// Capture the user's microphone (FR-CAP-7); frames pushed to `sink`, no WAV.
|
||||
fn start_microphone(&self, device_id: Option<&str>, sink: FrameSink) -> Result<CaptureHandle, AudioError>;
|
||||
fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError>;
|
||||
fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError>;
|
||||
fn stop(&self, h: CaptureHandle) -> Result<CaptureSummary, AudioError>;
|
||||
}
|
||||
// When the mic is enabled, `spawn_mixer` sums the loopback + mic 16kHz-mono
|
||||
// frames into the single transcription stream (`list_input_devices` enumerates
|
||||
// mic devices, mirroring `list_audio_devices` for render devices).
|
||||
|
||||
// hardware/mod.rs
|
||||
pub trait HardwareDetector: Send + Sync {
|
||||
@@ -200,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
|
||||
@@ -210,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.1.6",
|
||||
"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
+139
-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,15 +6088,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "whispassist"
|
||||
version = "0.1.6"
|
||||
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",
|
||||
@@ -5999,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",
|
||||
@@ -6691,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"
|
||||
|
||||
+36
-4
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "whispassist"
|
||||
version = "0.1.6"
|
||||
version = "0.7.5"
|
||||
description = "Privacy-first, fully local Windows meeting assistant"
|
||||
authors = ["WhispAssist contributors"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -23,10 +23,13 @@ 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"] }
|
||||
# Local<->UTC, DST-aware тАФ needed for calendar recurrence (T6.2); already in
|
||||
# the dependency tree transitively (sqlx), this just promotes it to direct.
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
|
||||
# storage
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate"] }
|
||||
@@ -44,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).
|
||||
@@ -59,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.
|
||||
@@ -77,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",
|
||||
@@ -102,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)
|
||||
|
||||
@@ -14,5 +14,51 @@ fn main() {
|
||||
println!("cargo:rustc-env=WA_GIT_HASH={hash}");
|
||||
println!("cargo:rerun-if-changed=../.git/logs/HEAD");
|
||||
|
||||
// The `vulkan` build links `vulkan-1.dll` at load time, so the exe won't
|
||||
// launch on a machine that lacks the Vulkan loader (no GPU driver / bare VM).
|
||||
// Bundling the redistributable loader (Apache-2.0) next to the exe makes the
|
||||
// single universal installer start everywhere тАФ with no GPU it simply reports
|
||||
// zero devices and we fall back to CPU. Copied both next to the built exe (so
|
||||
// `tauri dev`/`cargo run` work) and into the crate dir where the bundler picks
|
||||
// it up as a resource (see tauri.vulkan.conf.json).
|
||||
if std::env::var_os("CARGO_FEATURE_VULKAN").is_some() {
|
||||
stage_vulkan_loader();
|
||||
}
|
||||
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
/// Locate `vulkan-1.dll` (Vulkan SDK first, then System32) and copy it beside
|
||||
/// the compiled exe and into the crate dir for bundling. Warns rather than fails
|
||||
/// so a dev build on a machine with the loader already on PATH still succeeds.
|
||||
fn stage_vulkan_loader() {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
let source = std::env::var_os("VULKAN_SDK")
|
||||
.map(|sdk| Path::new(&sdk).join("Bin").join("vulkan-1.dll"))
|
||||
.filter(|p| p.exists())
|
||||
.or_else(|| {
|
||||
let sys = PathBuf::from(r"C:\Windows\System32\vulkan-1.dll");
|
||||
sys.exists().then_some(sys)
|
||||
});
|
||||
let Some(source) = source else {
|
||||
println!(
|
||||
"cargo:warning=vulkan feature is on but vulkan-1.dll wasn't found \
|
||||
(set VULKAN_SDK); the installer won't bundle the Vulkan loader"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
// Beside the exe: OUT_DIR is target/<profile>/build/<pkg>-<hash>/out, so three
|
||||
// parents up is target/<profile> (correct even under CARGO_TARGET_DIR=C:\wt).
|
||||
if let Some(out_dir) = std::env::var_os("OUT_DIR") {
|
||||
if let Some(exe_dir) = Path::new(&out_dir).ancestors().nth(3) {
|
||||
let _ = std::fs::copy(&source, exe_dir.join("vulkan-1.dll"));
|
||||
}
|
||||
}
|
||||
// Into the crate dir for the bundler resource.
|
||||
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
|
||||
if let Err(e) = std::fs::copy(&source, Path::new(&manifest_dir).join("vulkan-1.dll")) {
|
||||
println!("cargo:warning=failed to stage vulkan-1.dll for bundling: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
+1211
-90
File diff suppressed because it is too large
Load Diff
@@ -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(_))));
|
||||
}
|
||||
}
|
||||
+869
-25
@@ -9,6 +9,8 @@
|
||||
//! writes to it.
|
||||
|
||||
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;
|
||||
|
||||
@@ -22,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 {
|
||||
@@ -53,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
|
||||
@@ -69,19 +109,35 @@ fn run_readpst(pst_path: &str, out_dir: &Path) -> Result<(), CalError> {
|
||||
// still handled safely (collect_ics_files skips a file that doesn't
|
||||
// read as valid UTF-8 rather than erroring), just silently dropped
|
||||
// instead of correctly decoded. Revisit if that's observed in practice.
|
||||
let output = Command::new("readpst")
|
||||
.args(["-S", "-e", "-t", "a", "-o"])
|
||||
let mut cmd = Command::new("readpst");
|
||||
cmd.args(["-S", "-e", "-t", "a", "-o"])
|
||||
.arg(out_dir)
|
||||
.arg(pst_path)
|
||||
.output()
|
||||
.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => CalError::ToolMissing,
|
||||
_ => CalError::Open(e.to_string()),
|
||||
})?;
|
||||
.arg(pst_path);
|
||||
// Bug fix: readpst.exe is a console-subsystem binary, and WhispAssist is
|
||||
// a GUI app with no console of its own тАФ Windows was popping a brand
|
||||
// new console window for it on every import. CREATE_NO_WINDOW spawns it
|
||||
// fully headless instead; readpst's own stdout/stderr are still
|
||||
// captured normally via `.output()` below.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let output = cmd.output().map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => CalError::ToolMissing,
|
||||
_ => CalError::Open(e.to_string()),
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(CalError::Parse(
|
||||
String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
||||
));
|
||||
// readpst writes some errors to stdout rather than stderr; show
|
||||
// whichever stream actually has text, stderr first.
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let message = if stderr.is_empty() {
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
return Err(CalError::Parse(message));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -114,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> {
|
||||
@@ -202,6 +457,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
let mut description = None;
|
||||
let mut starts_at = None;
|
||||
let mut ends_at = None;
|
||||
let mut rrule: Option<String> = None;
|
||||
let mut attendees: Vec<AttendeeInfo> = Vec::new();
|
||||
|
||||
for line in &lines {
|
||||
@@ -217,22 +473,60 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
description = None;
|
||||
starts_at = None;
|
||||
ends_at = None;
|
||||
rrule = None;
|
||||
attendees = Vec::new();
|
||||
}
|
||||
"END" if value.eq_ignore_ascii_case("VEVENT") && in_event => {
|
||||
events.push(ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
source: source.to_string(),
|
||||
subject: summary.take(),
|
||||
organizer: organizer.take(),
|
||||
starts_at,
|
||||
ends_at,
|
||||
description: description.take(),
|
||||
raw_uid: uid.take(),
|
||||
},
|
||||
attendees: std::mem::take(&mut attendees),
|
||||
});
|
||||
let attendees = std::mem::take(&mut attendees);
|
||||
match (rrule.take(), &uid, starts_at) {
|
||||
// A recurring event needs a UID to key its occurrences
|
||||
// for dedup (source, raw_uid) тАФ without one, fall back
|
||||
// to importing just the single stored occurrence below.
|
||||
(Some(rule), Some(base_uid), Some(dtstart)) => {
|
||||
let duration = ends_at.map(|e| e - dtstart).unwrap_or(0);
|
||||
for occ_start in expand_rrule(&rule, dtstart) {
|
||||
events.push(ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
source: source.to_string(),
|
||||
subject: summary.clone(),
|
||||
organizer: organizer.clone(),
|
||||
starts_at: Some(occ_start),
|
||||
ends_at: Some(occ_start + duration),
|
||||
description: description.clone(),
|
||||
raw_uid: Some(format!("{base_uid}@{}", ymd_digits(occ_start))),
|
||||
},
|
||||
attendees: attendees.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 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(),
|
||||
source: source.to_string(),
|
||||
subject: summary.take(),
|
||||
organizer: organizer.take(),
|
||||
starts_at,
|
||||
ends_at,
|
||||
description: description.take(),
|
||||
raw_uid,
|
||||
},
|
||||
attendees,
|
||||
});
|
||||
}
|
||||
}
|
||||
in_event = false;
|
||||
}
|
||||
"UID" if in_event => uid = Some(value.to_string()),
|
||||
@@ -240,6 +534,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
"DESCRIPTION" if in_event => description = Some(unescape_text(value)),
|
||||
"DTSTART" if in_event => starts_at = parse_ics_datetime(value),
|
||||
"DTEND" if in_event => ends_at = parse_ics_datetime(value),
|
||||
"RRULE" if in_event => rrule = Some(value.to_string()),
|
||||
"ORGANIZER" if in_event => {
|
||||
let (name, email) = cal_address(params, value);
|
||||
organizer = name.or(email);
|
||||
@@ -261,6 +556,291 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
events
|
||||
}
|
||||
|
||||
// ---- RRULE (RFC 5545 recurrence) expansion тАФ pure, no I/O ----
|
||||
|
||||
// ponytail: caps for RRULEs with no COUNT/UNTIL (only YEARLY holidays do this
|
||||
// in practice) and a hard ceiling regardless тАФ extend both if a real series
|
||||
// needs more instances than this.
|
||||
const RECURRENCE_HORIZON_YEARS: i64 = 10;
|
||||
const RECURRENCE_MAX_OCCURRENCES: usize = 500;
|
||||
|
||||
enum Freq {
|
||||
Daily,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Yearly,
|
||||
}
|
||||
|
||||
struct Rrule {
|
||||
freq: Freq,
|
||||
interval: i64,
|
||||
count: Option<usize>,
|
||||
until: Option<i64>,
|
||||
byday: Vec<u32>, // weekday indices, 0=SU..6=SA
|
||||
bymonthday: Vec<u32>, // 1..31
|
||||
bymonth: Vec<u32>, // 1..12
|
||||
}
|
||||
|
||||
fn weekday_code_to_index(code: &str) -> Option<u32> {
|
||||
// Strips a leading ordinal like "2MO" ("2nd Monday") тАФ not seen in this
|
||||
// codebase's real-world data (only plain weekday codes are), but the
|
||||
// weekday is all this parser uses either way.
|
||||
let letters: String = code.chars().filter(|c| c.is_ascii_alphabetic()).collect();
|
||||
match letters.to_ascii_uppercase().as_str() {
|
||||
"SU" => Some(0),
|
||||
"MO" => Some(1),
|
||||
"TU" => Some(2),
|
||||
"WE" => Some(3),
|
||||
"TH" => Some(4),
|
||||
"FR" => Some(5),
|
||||
"SA" => Some(6),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `FREQ=WEEKLY;COUNT=26;BYDAY=MO`-style RRULE values. Supports
|
||||
/// DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL/COUNT/UNTIL/BYDAY/BYMONTHDAY/
|
||||
/// BYMONTH тАФ every combination confirmed present in a real 7.2GB mailbox
|
||||
/// (ADR-0008). No BYSETPOS, no per-occurrence exceptions (RECURRENCE-ID).
|
||||
fn parse_rrule(rule: &str) -> Option<Rrule> {
|
||||
let mut freq = None;
|
||||
let mut interval = 1i64;
|
||||
let mut count = None;
|
||||
let mut until = None;
|
||||
let mut byday = Vec::new();
|
||||
let mut bymonthday = Vec::new();
|
||||
let mut bymonth = Vec::new();
|
||||
let mut current_list_key = String::new();
|
||||
for part in rule.split(';') {
|
||||
// readpst joins a multi-value BYDAY with `;` instead of RFC 5545's
|
||||
// `,` (e.g. `BYDAY=MO;TU;WE;TH;FR`), so a continuation token has no
|
||||
// `=` at all тАФ attribute it to whichever list key came before it.
|
||||
let (key, v) = match part.split_once('=') {
|
||||
Some((k, v)) => {
|
||||
current_list_key = k.to_ascii_uppercase();
|
||||
(current_list_key.as_str(), v)
|
||||
}
|
||||
None => (current_list_key.as_str(), part),
|
||||
};
|
||||
match key {
|
||||
"FREQ" => {
|
||||
freq = match v.to_ascii_uppercase().as_str() {
|
||||
"DAILY" => Some(Freq::Daily),
|
||||
"WEEKLY" => Some(Freq::Weekly),
|
||||
"MONTHLY" => Some(Freq::Monthly),
|
||||
"YEARLY" => Some(Freq::Yearly),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
"INTERVAL" => interval = v.parse().unwrap_or(1).max(1),
|
||||
"COUNT" => count = v.parse().ok(),
|
||||
"UNTIL" => until = parse_ics_datetime(v),
|
||||
"BYDAY" => byday.extend(v.split(',').filter_map(weekday_code_to_index)),
|
||||
"BYMONTHDAY" => bymonthday.extend(v.split(',').filter_map(|s| s.parse::<u32>().ok())),
|
||||
"BYMONTH" => bymonth.extend(v.split(',').filter_map(|s| s.parse::<u32>().ok())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(Rrule {
|
||||
freq: freq?,
|
||||
interval,
|
||||
count,
|
||||
until,
|
||||
byday,
|
||||
bymonthday,
|
||||
bymonth,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves a local wall-clock datetime to a UTC unix timestamp, applying
|
||||
/// whatever DST rule the OS has for that specific calendar date тАФ this is
|
||||
/// what keeps a recurring meeting at the same local time across a DST
|
||||
/// transition instead of drifting by an hour. A skipped (spring-forward gap)
|
||||
/// or ambiguous (fall-back overlap) local time resolves to the OS's earliest
|
||||
/// matching instant rather than failing outright.
|
||||
fn local_to_utc_secs(dt: chrono::NaiveDateTime) -> i64 {
|
||||
match Local.from_local_datetime(&dt) {
|
||||
chrono::LocalResult::Single(ldt) | chrono::LocalResult::Ambiguous(ldt, _) => {
|
||||
ldt.with_timezone(&Utc).timestamp()
|
||||
}
|
||||
chrono::LocalResult::None => dt.and_utc().timestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) => format!("{:04}{:02}{:02}", dt.year(), dt.month(), dt.day()),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Expands an RRULE into occurrence start timestamps (unix seconds). Each
|
||||
/// occurrence keeps `dtstart`'s *local* wall-clock time-of-day (assuming the
|
||||
/// meeting's timezone matches this machine's тАФ reasonable for a single-user
|
||||
/// tool reading its own Outlook data), re-resolving the UTC offset per
|
||||
/// occurrence so a series spanning a DST transition doesn't drift by an hour.
|
||||
fn expand_rrule(rule: &str, dtstart: i64) -> Vec<i64> {
|
||||
let Some(r) = parse_rrule(rule) else {
|
||||
return vec![dtstart];
|
||||
};
|
||||
let Some(dtstart_utc) = Utc.timestamp_opt(dtstart, 0).single() else {
|
||||
return vec![dtstart];
|
||||
};
|
||||
let local_start = dtstart_utc.with_timezone(&Local).naive_local();
|
||||
let (hour, min, sec) = (
|
||||
local_start.hour(),
|
||||
local_start.minute(),
|
||||
local_start.second(),
|
||||
);
|
||||
let start_date = local_start.date();
|
||||
|
||||
let indefinite = r.count.is_none() && r.until.is_none();
|
||||
let effective_until = if indefinite {
|
||||
dtstart + RECURRENCE_HORIZON_YEARS * 365 * 86_400
|
||||
} else {
|
||||
r.until.unwrap_or(i64::MAX)
|
||||
};
|
||||
let count_cap = r
|
||||
.count
|
||||
.unwrap_or(RECURRENCE_MAX_OCCURRENCES)
|
||||
.min(RECURRENCE_MAX_OCCURRENCES);
|
||||
|
||||
let at = |date: NaiveDate| date.and_hms_opt(hour, min, sec).map(local_to_utc_secs);
|
||||
|
||||
let mut occurrences = Vec::new();
|
||||
match r.freq {
|
||||
// Outlook emits "every weekday" as either FREQ, always with BYDAY тАФ
|
||||
// both iterate calendar weeks and keep the requested weekdays.
|
||||
Freq::Weekly | Freq::Daily if !r.byday.is_empty() => {
|
||||
let step_weeks = if matches!(r.freq, Freq::Weekly) {
|
||||
r.interval
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let mut week_start =
|
||||
start_date - Duration::days(start_date.weekday().num_days_from_sunday() as i64);
|
||||
'weeks: loop {
|
||||
for &wd in &r.byday {
|
||||
let date = week_start + Duration::days(wd as i64);
|
||||
if date < start_date {
|
||||
continue;
|
||||
}
|
||||
let Some(ts) = at(date) else { continue };
|
||||
if ts > effective_until || occurrences.len() >= count_cap {
|
||||
break 'weeks;
|
||||
}
|
||||
occurrences.push(ts);
|
||||
}
|
||||
week_start += Duration::weeks(step_weeks);
|
||||
}
|
||||
}
|
||||
Freq::Daily => {
|
||||
let mut date = start_date;
|
||||
while let Some(ts) = at(date) {
|
||||
if ts > effective_until || occurrences.len() >= count_cap {
|
||||
break;
|
||||
}
|
||||
occurrences.push(ts);
|
||||
date += Duration::days(r.interval);
|
||||
}
|
||||
}
|
||||
Freq::Weekly => {
|
||||
let mut date = start_date;
|
||||
while let Some(ts) = at(date) {
|
||||
if ts > effective_until || occurrences.len() >= count_cap {
|
||||
break;
|
||||
}
|
||||
occurrences.push(ts);
|
||||
date += Duration::weeks(r.interval);
|
||||
}
|
||||
}
|
||||
Freq::Monthly => {
|
||||
let day_of_month = r.bymonthday.first().copied().unwrap_or(start_date.day());
|
||||
let mut idx: i64 = 0;
|
||||
loop {
|
||||
let total = start_date.month0() as i64 + idx * r.interval;
|
||||
let year = start_date.year() + total.div_euclid(12) as i32;
|
||||
let month = (total.rem_euclid(12) + 1) as u32;
|
||||
if let Some(date) = NaiveDate::from_ymd_opt(year, month, day_of_month) {
|
||||
if date >= start_date {
|
||||
if let Some(ts) = at(date) {
|
||||
if ts > effective_until || occurrences.len() >= count_cap {
|
||||
break;
|
||||
}
|
||||
occurrences.push(ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
idx += 1;
|
||||
if idx as usize > RECURRENCE_MAX_OCCURRENCES * 2 {
|
||||
break; // safety valve against a pathological rule
|
||||
}
|
||||
}
|
||||
}
|
||||
Freq::Yearly => {
|
||||
let months = if r.bymonth.is_empty() {
|
||||
vec![start_date.month()]
|
||||
} else {
|
||||
r.bymonth.clone()
|
||||
};
|
||||
let day_of_month = r.bymonthday.first().copied().unwrap_or(start_date.day());
|
||||
let mut year = start_date.year();
|
||||
loop {
|
||||
for &month in &months {
|
||||
if let Some(date) = NaiveDate::from_ymd_opt(year, month, day_of_month) {
|
||||
if date >= start_date {
|
||||
if let Some(ts) = at(date) {
|
||||
if ts <= effective_until && occurrences.len() < count_cap {
|
||||
occurrences.push(ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
year += r.interval as i32;
|
||||
if occurrences.len() >= count_cap
|
||||
|| year > start_date.year() + (RECURRENCE_HORIZON_YEARS * 2) as i32
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if occurrences.is_empty() {
|
||||
vec![dtstart]
|
||||
} else {
|
||||
occurrences
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an iCalendar DATE-TIME (`20260701T090000Z` / `20260701T090000`) or
|
||||
/// DATE (`20260701`) value to a unix epoch. Both `Z`-suffixed and floating
|
||||
/// (no `Z`, no `TZID`) values are treated as UTC тАФ full IANA timezone
|
||||
@@ -304,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);
|
||||
@@ -399,7 +1043,207 @@ 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(_))));
|
||||
}
|
||||
|
||||
// These assert on *local* wall-clock time rather than raw UTC offsets тАФ
|
||||
// that's the entire point of the DST fix (a fixed UTC time-of-day is
|
||||
// exactly the bug: a recurring meeting drifts an hour across a DST
|
||||
// transition). Local-time assertions depend on this machine's configured
|
||||
// timezone, same as the production code they're testing.
|
||||
fn local_hms(ts: i64) -> (u32, u32, u32) {
|
||||
let dt = Utc.timestamp_opt(ts, 0).unwrap().with_timezone(&Local);
|
||||
(dt.hour(), dt.minute(), dt.second())
|
||||
}
|
||||
fn local_date(ts: i64) -> NaiveDate {
|
||||
Utc.timestamp_opt(ts, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&Local)
|
||||
.date_naive()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_rrule_weekly_single_byday_matches_the_real_1on1_pattern() {
|
||||
// The exact rule readpst produced for a real "Weekly 1:1" on Mondays.
|
||||
let dtstart = ymd_hms_to_unix(2026, 1, 12, 16, 30, 0); // a Monday
|
||||
let occurrences = expand_rrule("FREQ=WEEKLY;COUNT=26;BYDAY=MO", dtstart);
|
||||
assert_eq!(occurrences.len(), 26);
|
||||
assert_eq!(occurrences[0], dtstart);
|
||||
let expected_hms = local_hms(dtstart);
|
||||
for pair in occurrences.windows(2) {
|
||||
assert_eq!((local_date(pair[1]) - local_date(pair[0])).num_days(), 7);
|
||||
}
|
||||
for occ in &occurrences {
|
||||
assert_eq!(
|
||||
local_hms(*occ),
|
||||
expected_hms,
|
||||
"local wall-clock time must not drift across a DST transition"
|
||||
);
|
||||
assert_eq!(local_date(*occ).weekday(), chrono::Weekday::Mon);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_rrule_weekly_multi_byday_covers_every_weekday_in_order() {
|
||||
let dtstart = ymd_hms_to_unix(2026, 1, 12, 9, 0, 0); // Monday
|
||||
let occurrences = expand_rrule("FREQ=WEEKLY;COUNT=10;BYDAY=MO;TU;WE;TH;FR", dtstart);
|
||||
assert_eq!(occurrences.len(), 10);
|
||||
// Mon..Fri week 1, then Mon..Fri week 2 тАФ a flat +1 day step except
|
||||
// the weekend gap between index 4 (Fri) and 5 (next Mon).
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
(local_date(occurrences[i + 1]) - local_date(occurrences[i])).num_days(),
|
||||
1
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
(local_date(occurrences[5]) - local_date(occurrences[4])).num_days(),
|
||||
3
|
||||
);
|
||||
let expected_hms = local_hms(dtstart);
|
||||
for occ in &occurrences {
|
||||
assert_eq!(local_hms(*occ), expected_hms);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_rrule_monthly_bymonthday_steps_calendar_months() {
|
||||
let dtstart = ymd_hms_to_unix(2026, 1, 1, 9, 0, 0);
|
||||
let occurrences = expand_rrule("FREQ=MONTHLY;COUNT=7;BYMONTHDAY=1", dtstart);
|
||||
assert_eq!(occurrences.len(), 7);
|
||||
let last = local_date(occurrences[6]);
|
||||
assert_eq!((last.year(), last.month(), last.day()), (2026, 7, 1));
|
||||
let expected_hms = local_hms(dtstart);
|
||||
for occ in &occurrences {
|
||||
assert_eq!(local_hms(*occ), expected_hms);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_rrule_yearly_with_no_count_or_until_is_capped_by_the_horizon() {
|
||||
let dtstart = ymd_hms_to_unix(2020, 11, 11, 17, 0, 0); // afternoon UTC, safe midnight margin
|
||||
let occurrences = expand_rrule("FREQ=YEARLY;BYMONTHDAY=11;BYMONTH=11", dtstart);
|
||||
assert!(
|
||||
!occurrences.is_empty() && occurrences.len() <= RECURRENCE_HORIZON_YEARS as usize + 2
|
||||
);
|
||||
for occ in &occurrences {
|
||||
let d = local_date(*occ);
|
||||
assert_eq!((d.month(), d.day()), (11, 11));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vevents_expands_a_recurring_event_into_distinct_occurrences() {
|
||||
let ics = "BEGIN:VEVENT\r\n\
|
||||
UID:series-1\r\n\
|
||||
SUMMARY:Weekly 1:1\r\n\
|
||||
DTSTART:20260112T163000Z\r\n\
|
||||
DTEND:20260112T170000Z\r\n\
|
||||
RRULE:FREQ=WEEKLY;COUNT=3;BYDAY=MO\r\n\
|
||||
END:VEVENT\r\n";
|
||||
let events = parse_vevents(ics, "pst");
|
||||
assert_eq!(events.len(), 3);
|
||||
let raw_uids: Vec<_> = events.iter().map(|e| e.event.raw_uid.clone()).collect();
|
||||
assert_eq!(
|
||||
raw_uids.len(),
|
||||
raw_uids
|
||||
.iter()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len()
|
||||
);
|
||||
for e in &events {
|
||||
assert_eq!(e.event.subject.as_deref(), Some("Weekly 1:1"));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+3196
-159
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());
|
||||
}
|
||||
}
|
||||
+285
-6
@@ -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;
|
||||
|
||||
@@ -44,6 +48,10 @@ pub struct AppState {
|
||||
pub struct RecordingSession {
|
||||
pub meeting_id: models::MeetingId,
|
||||
pub capture: audio::CaptureHandle,
|
||||
/// The user's microphone capture (FR-CAP-7), mixed into the transcript
|
||||
/// stream. `None` when the mic is disabled in Settings or failed to open тАФ
|
||||
/// the meeting proceeds on loopback alone either way.
|
||||
pub mic_capture: Option<audio::CaptureHandle>,
|
||||
/// Audio retention for this meeting (ADR-0009); toggle-able mid-meeting.
|
||||
pub retention: bool,
|
||||
pub wav_path: PathBuf,
|
||||
@@ -58,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).
|
||||
@@ -66,36 +81,186 @@ 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| {
|
||||
commands::serve_recording(&request)
|
||||
})
|
||||
.manage(AppState {
|
||||
store,
|
||||
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.
|
||||
@@ -123,6 +288,10 @@ pub fn run() {
|
||||
// Startup recovery + retention + reminder-reconcile pass (FR-REL-1,
|
||||
// FR-STORE-2, FR-CAL-5). Spawned so it never blocks the window from
|
||||
// showing (NFR-PERF-4); nothing here repeats on a timer (NFR-RES-1).
|
||||
// Sweep away any plaintext playback temp files left by the previous
|
||||
// file-based player (T8.8) тАФ playback is now in-memory only.
|
||||
commands::cleanup_playback_temp();
|
||||
|
||||
let store = store_for_setup;
|
||||
let startup_app = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -161,28 +330,72 @@ pub fn run() {
|
||||
if commands::load_settings().sync_enabled {
|
||||
commands::pump_sync(&startup_app, store.as_ref()).await;
|
||||
}
|
||||
|
||||
// Re-import the last .pst path if the user opted into auto-sync
|
||||
// (T6.2). One-shot on startup, same as the sync-job resume above
|
||||
// тАФ no idle timer (NFR-RES-1). Re-import is dedup'd by
|
||||
// (source, raw_uid), so this just catches up on new/changed events.
|
||||
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,
|
||||
pst_settings.pst_import_range_days,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("startup PST auto-sync failed: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
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,
|
||||
commands::cancel_recording,
|
||||
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,
|
||||
commands::hardware_status,
|
||||
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,
|
||||
@@ -193,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,
|
||||
@@ -200,12 +414,19 @@ pub fn run() {
|
||||
commands::set_llm_provider,
|
||||
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,
|
||||
commands::update_sync_target,
|
||||
@@ -234,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);
|
||||
}
|
||||
}
|
||||
|
||||
+907
-19
File diff suppressed because it is too large
Load Diff
@@ -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,15 +274,115 @@ 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.
|
||||
pub retention_max_age_days: Option<u32>,
|
||||
pub retention_max_size_gb: Option<u32>,
|
||||
// Calendar / .pst (T6.2) тАФ remembered so the user doesn't re-browse every
|
||||
// launch. `pst_auto_sync` re-imports this path once at startup if set.
|
||||
#[serde(default)]
|
||||
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)]
|
||||
pub audio_output_device: Option<String>,
|
||||
// Microphone capture (FR-CAP-7): mix the user's own voice into the live
|
||||
// transcript. Local-only, no egress; default ON. Turn off to transcribe just
|
||||
// the system/loopback audio, as WA did before.
|
||||
#[serde(default = "default_true")]
|
||||
pub microphone_enabled: bool,
|
||||
// Microphone device override тАФ `Device::get_id()` string; None = system
|
||||
// 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
|
||||
/// older `settings.json` (so upgrading users get the microphone, FR-CAP-7).
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// ---- Sync (ADR-0010) ----
|
||||
|
||||
+220
-44
@@ -3,7 +3,7 @@
|
||||
//! Renders speaker-tagged Markdown from transcript + speaker names (+ optional
|
||||
//! summary). Names are resolved here from the mapping; segments keep internal IDs.
|
||||
|
||||
use crate::models::{SpeakerInfo, TranscriptSegment};
|
||||
use crate::models::{ManualNotes, SegmentNote, SpeakerInfo, TranscriptSegment};
|
||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -101,6 +101,156 @@ pub trait NotesRenderer: Send + Sync {
|
||||
|
||||
pub struct MarkdownNotes;
|
||||
|
||||
impl MarkdownNotes {
|
||||
/// Bug fix / redesign: `notes.md` used to be generated *only* from the
|
||||
/// transcript (`to_markdown`), then blindly overwritten on every
|
||||
/// finalize/speaker-rename, discarding anything the user typed. `merge`
|
||||
/// is what `stop_recording` now calls instead тАФ it folds in whatever was
|
||||
/// captured live in `manual` (freeform notes typed during the meeting,
|
||||
/// plus any per-moment annotations) alongside the transcript, so the
|
||||
/// generated document isn't transcript-only and isn't a single
|
||||
/// same-speaker-collapsed blob (`## Notes` / `## Transcript` sections,
|
||||
/// with each annotation placed right after the paragraph it points at).
|
||||
pub fn merge(
|
||||
&self,
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
manual: &ManualNotes,
|
||||
summary_md: Option<&str>,
|
||||
template: Option<&NoteTemplate>,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
push_prelude(&mut out, summary_md, template);
|
||||
|
||||
let freeform = manual.freeform_md.trim();
|
||||
if !freeform.is_empty() {
|
||||
out.push_str("## Notes\n\n");
|
||||
out.push_str(freeform);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
let transcript = transcript_with_notes(segments, speakers, &manual.segment_notes);
|
||||
if !transcript.is_empty() {
|
||||
out.push_str("## Transcript\n\n");
|
||||
out.push_str(&transcript);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
out.trim_end().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Template section scaffold + summary block shared by `to_markdown` and `merge`.
|
||||
fn push_prelude(out: &mut String, summary_md: Option<&str>, template: Option<&NoteTemplate>) {
|
||||
if let Some(template) = template {
|
||||
for section in &template.sections {
|
||||
out.push_str(&format!("## {section}\n\n"));
|
||||
}
|
||||
}
|
||||
if let Some(summary) = summary_md {
|
||||
let summary = summary.trim();
|
||||
if !summary.is_empty() {
|
||||
out.push_str(summary);
|
||||
out.push_str("\n\n---\n\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups consecutive same-speaker segments into `(start_ms, end_ms,
|
||||
/// "**Name:** text")` paragraphs тАФ the ms range is each paragraph's span in
|
||||
/// the original recording, used by `transcript_with_notes` to place a
|
||||
/// per-moment note right after the paragraph it was anchored to.
|
||||
fn dialogue_paragraphs(
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
) -> Vec<(u64, u64, String)> {
|
||||
let name_for = |label: &str| -> String {
|
||||
speakers
|
||||
.iter()
|
||||
.find(|s| s.label == label)
|
||||
.and_then(|s| s.display_name.clone())
|
||||
.unwrap_or_else(|| label.to_string())
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut current_speaker: Option<&str> = None;
|
||||
let mut buffer = String::new();
|
||||
let mut range: (u64, u64) = (0, 0);
|
||||
for seg in segments {
|
||||
let text = seg.text.trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if current_speaker != Some(seg.speaker.as_str()) {
|
||||
if let Some(speaker) = current_speaker {
|
||||
out.push((
|
||||
range.0,
|
||||
range.1,
|
||||
format!("**{}:** {}", name_for(speaker), buffer.trim()),
|
||||
));
|
||||
}
|
||||
current_speaker = Some(seg.speaker.as_str());
|
||||
buffer.clear();
|
||||
range = (seg.start_ms, seg.end_ms);
|
||||
}
|
||||
if !buffer.is_empty() {
|
||||
buffer.push(' ');
|
||||
}
|
||||
buffer.push_str(text);
|
||||
range.1 = seg.end_ms;
|
||||
}
|
||||
if let Some(speaker) = current_speaker {
|
||||
out.push((
|
||||
range.0,
|
||||
range.1,
|
||||
format!("**{}:** {}", name_for(speaker), buffer.trim()),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The dialogue paragraphs, each followed by a `> ЁЯУЭ` blockquote for any
|
||||
/// `segment_notes` whose `anchor_ms` falls inside that paragraph's span.
|
||||
/// A note whose anchor doesn't land inside any paragraph (its segment fell
|
||||
/// in a gap, or vanished in a later re-transcription) still isn't dropped тАФ
|
||||
/// it surfaces under "Other notes" at the end instead of silently
|
||||
/// disappearing.
|
||||
fn transcript_with_notes(
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
segment_notes: &[SegmentNote],
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
let mut matched = vec![false; segment_notes.len()];
|
||||
for (start, end, paragraph) in dialogue_paragraphs(segments, speakers) {
|
||||
out.push_str(¶graph);
|
||||
out.push_str("\n\n");
|
||||
for (i, note) in segment_notes.iter().enumerate() {
|
||||
let text = note.text.trim();
|
||||
if text.is_empty() || note.anchor_ms < start || note.anchor_ms > end {
|
||||
continue;
|
||||
}
|
||||
out.push_str(&format!("> ЁЯУЭ {text}\n\n"));
|
||||
matched[i] = true;
|
||||
}
|
||||
}
|
||||
|
||||
let orphans: Vec<&str> = segment_notes
|
||||
.iter()
|
||||
.zip(matched.iter())
|
||||
.filter(|(n, was_matched)| !**was_matched && !n.text.trim().is_empty())
|
||||
.map(|(n, _)| n.text.trim())
|
||||
.collect();
|
||||
if !orphans.is_empty() {
|
||||
out.push_str("### Other notes\n\n");
|
||||
for text in orphans {
|
||||
out.push_str(&format!("> ЁЯУЭ {text}\n\n"));
|
||||
}
|
||||
}
|
||||
|
||||
out.trim_end().to_string()
|
||||
}
|
||||
|
||||
impl NotesRenderer for MarkdownNotes {
|
||||
fn to_markdown(
|
||||
&self,
|
||||
@@ -110,50 +260,11 @@ impl NotesRenderer for MarkdownNotes {
|
||||
template: Option<&NoteTemplate>,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(template) = template {
|
||||
for section in &template.sections {
|
||||
out.push_str(&format!("## {section}\n\n"));
|
||||
}
|
||||
}
|
||||
if let Some(summary) = summary_md {
|
||||
let summary = summary.trim();
|
||||
if !summary.is_empty() {
|
||||
out.push_str(summary);
|
||||
out.push_str("\n\n---\n\n");
|
||||
}
|
||||
}
|
||||
push_prelude(&mut out, summary_md, template);
|
||||
|
||||
let name_for = |label: &str| -> String {
|
||||
speakers
|
||||
.iter()
|
||||
.find(|s| s.label == label)
|
||||
.and_then(|s| s.display_name.clone())
|
||||
.unwrap_or_else(|| label.to_string())
|
||||
};
|
||||
|
||||
// Group consecutive segments from the same speaker into one paragraph
|
||||
// (matters once Phase 4 diarization produces more than one speaker).
|
||||
let mut current_speaker: Option<&str> = None;
|
||||
let mut buffer = String::new();
|
||||
for seg in segments {
|
||||
let text = seg.text.trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if current_speaker != Some(seg.speaker.as_str()) {
|
||||
if let Some(speaker) = current_speaker {
|
||||
out.push_str(&format!("**{}:** {}\n\n", name_for(speaker), buffer.trim()));
|
||||
}
|
||||
current_speaker = Some(seg.speaker.as_str());
|
||||
buffer.clear();
|
||||
}
|
||||
if !buffer.is_empty() {
|
||||
buffer.push(' ');
|
||||
}
|
||||
buffer.push_str(text);
|
||||
}
|
||||
if let Some(speaker) = current_speaker {
|
||||
out.push_str(&format!("**{}:** {}\n\n", name_for(speaker), buffer.trim()));
|
||||
for (_, _, paragraph) in dialogue_paragraphs(segments, speakers) {
|
||||
out.push_str(¶graph);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
out.trim_end().to_string()
|
||||
@@ -350,6 +461,71 @@ mod tests {
|
||||
assert_eq!(md, "## Discussion\n\n## Action Items\n\n**S1:** Hi");
|
||||
}
|
||||
|
||||
fn note(anchor_ms: u64, text: &str) -> SegmentNote {
|
||||
SegmentNote {
|
||||
anchor_ms,
|
||||
text: text.to_string(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_with_no_manual_notes_matches_to_markdown() {
|
||||
let segments = vec![seg(0, "S1", "Hello"), seg(1, "S2", "Hi there.")];
|
||||
let manual = ManualNotes::default();
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Transcript\n\n**S1:** Hello\n\n**S2:** Hi there."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_adds_a_notes_section_for_freeform_text() {
|
||||
let segments = vec![seg(0, "S1", "Hello")];
|
||||
let manual = ManualNotes {
|
||||
freeform_md: "Remember to follow up.".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Notes\n\nRemember to follow up.\n\n## Transcript\n\n**S1:** Hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_places_a_segment_note_right_after_its_paragraph() {
|
||||
// seg 0 spans 0-900ms ("S1"); anchor 500 falls inside it.
|
||||
let segments = vec![seg(0, "S1", "Hello"), seg(1, "S2", "Hi there.")];
|
||||
let manual = ManualNotes {
|
||||
segment_notes: vec![note(500, "circle back on this")],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Transcript\n\n**S1:** Hello\n\n> \u{1f4dd} circle back on this\n\n**S2:** Hi there."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_surfaces_an_unmatched_anchor_under_other_notes() {
|
||||
let segments = vec![seg(0, "S1", "Hello")]; // spans 0-900ms
|
||||
let manual = ManualNotes {
|
||||
segment_notes: vec![note(50_000, "way outside any paragraph")],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Transcript\n\n**S1:** Hello\n\n### Other notes\n\n> \u{1f4dd} way outside any paragraph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_omits_empty_sections_for_an_empty_meeting() {
|
||||
let manual = ManualNotes::default();
|
||||
assert_eq!(MarkdownNotes.merge(&[], &[], &manual, None, None), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_to_blocks_parses_heading_bold_prefix_and_task_list() {
|
||||
let md = "## Summary\n\n**Alice:** Hello world.\n\n- [ ] Follow up\n- [x] Done thing";
|
||||
|
||||
@@ -29,6 +29,13 @@ pub fn meeting_dir(id: &MeetingId) -> PathBuf {
|
||||
meetings_dir().join(id)
|
||||
}
|
||||
|
||||
/// Raw user-authored notes (freeform + per-moment annotations) accumulated
|
||||
/// live during a recording тАФ see `models::ManualNotes`. Distinct from the
|
||||
/// derived `notes.md` the same directory holds after finalize.
|
||||
pub fn manual_notes_file(id: &MeetingId) -> PathBuf {
|
||||
meeting_dir(id).join("manual_notes.json")
|
||||
}
|
||||
|
||||
pub fn models_dir() -> PathBuf {
|
||||
wa_root().join("models")
|
||||
}
|
||||
|
||||
+952
-59
File diff suppressed because it is too large
Load Diff
+2077
-34
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.
|
||||
/// whisper.cpp isn't a true streaming recognizer (it re-decodes from scratch),
|
||||
/// so this trades CPU тАФ the growing window is re-decoded every step тАФ for a
|
||||
/// natural-looking transcript. `audio_ctx_for_window` keeps each decode's cost
|
||||
/// proportional to the window length rather than a full 30s encode, and
|
||||
/// committing on pauses keeps the window short for conversational speech.
|
||||
pub struct Streamer {
|
||||
step_len: usize,
|
||||
max_len: usize,
|
||||
min_commit_len: usize,
|
||||
window: Vec<f32>,
|
||||
committed_offset_ms: u64,
|
||||
since_last_decode: usize,
|
||||
next_id: u64,
|
||||
last_text: String,
|
||||
}
|
||||
|
||||
impl Streamer {
|
||||
pub fn new(tuning: StreamTuning) -> Self {
|
||||
let per_ms = STREAM_SAMPLE_RATE / 1000;
|
||||
Self {
|
||||
step_len: tuning.step_ms as usize * per_ms,
|
||||
max_len: tuning.max_window_ms as usize * per_ms,
|
||||
min_commit_len: tuning.min_commit_ms as usize * per_ms,
|
||||
window: Vec::new(),
|
||||
committed_offset_ms: 0,
|
||||
since_last_decode: 0,
|
||||
next_id: 0,
|
||||
last_text: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append newly captured audio; decode + emit once a step's worth has
|
||||
/// accumulated. `decode(samples, offset_ms)` returns the transcript of the
|
||||
/// window so far (empty for silence).
|
||||
pub fn feed<D, F>(&mut self, chunk: &[f32], decode: &D, on_segment: &mut F)
|
||||
where
|
||||
D: Fn(&[f32], u64) -> String,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
self.window.extend_from_slice(chunk);
|
||||
self.since_last_decode += chunk.len();
|
||||
if self.since_last_decode >= self.step_len {
|
||||
self.since_last_decode = 0;
|
||||
self.tick(false, decode, on_segment);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit whatever's in flight тАФ called once when capture stops so the last
|
||||
/// in-progress line is finalized rather than left interim.
|
||||
pub fn flush<D, F>(&mut self, decode: &D, on_segment: &mut F)
|
||||
where
|
||||
D: Fn(&[f32], u64) -> String,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
self.tick(true, decode, on_segment);
|
||||
}
|
||||
|
||||
fn tick<D, F>(&mut self, force: bool, decode: &D, on_segment: &mut F)
|
||||
where
|
||||
D: Fn(&[f32], u64) -> String,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
if self.window.is_empty() {
|
||||
return;
|
||||
}
|
||||
let window_ms = (self.window.len() as u64 * 1000) / STREAM_SAMPLE_RATE as u64;
|
||||
let text = decode(&self.window, self.committed_offset_ms);
|
||||
let over_max = self.window.len() >= self.max_len;
|
||||
|
||||
// Nothing recognized yet (leading/standalone silence): don't show an
|
||||
// empty line, but still drop the buffer once it's grown too big so we
|
||||
// aren't re-decoding a long silence every step.
|
||||
if text.is_empty() {
|
||||
if over_max || force {
|
||||
self.reset(window_ms, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let stable = text == self.last_text;
|
||||
let commit = force || over_max || (stable && self.window.len() >= self.min_commit_len);
|
||||
on_segment(TranscriptSegment {
|
||||
id: self.next_id,
|
||||
start_ms: self.committed_offset_ms,
|
||||
end_ms: self.committed_offset_ms + window_ms,
|
||||
// Provisional speaker; the post-stop diarization pass reassigns.
|
||||
speaker: "S1".to_string(),
|
||||
text: text.clone(),
|
||||
confidence: None,
|
||||
interim: !commit,
|
||||
});
|
||||
if commit {
|
||||
self.reset(window_ms, true);
|
||||
} else {
|
||||
self.last_text = text;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a fresh window/line after a commit (`new_line`) or after dropping
|
||||
/// leading silence (`!new_line`, which reuses the id since nothing was
|
||||
/// emitted for it).
|
||||
fn reset(&mut self, window_ms: u64, new_line: bool) {
|
||||
self.committed_offset_ms += window_ms;
|
||||
self.window.clear();
|
||||
self.last_text.clear();
|
||||
self.since_last_decode = 0;
|
||||
if new_line {
|
||||
self.next_id += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live streaming worker (FR-TRX-2): drives a `Streamer` from the `audio`
|
||||
/// service's 16kHz-mono frames, decoding each growing window with `transcriber`
|
||||
/// and forwarding interim-then-final segments to `on_segment` (a Tauri emit).
|
||||
///
|
||||
/// `transcribe_stream` is synchronous for whisper.cpp тАФ by the time it returns,
|
||||
/// every segment it produced is already in the per-call channel тАФ so this
|
||||
/// drains it inline and joins the text into the window's transcript for the
|
||||
/// `Streamer` to diff/commit.
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
pub fn run_streaming_worker<T, F>(transcriber: &T, frame_rx: Receiver<Vec<f32>>, mut on_segment: F)
|
||||
where
|
||||
pub fn run_streaming_worker<T, F>(
|
||||
transcriber: &T,
|
||||
frame_rx: Receiver<Vec<f32>>,
|
||||
tuning: StreamTuning,
|
||||
mut on_segment: F,
|
||||
) where
|
||||
// `?Sized` so the dispatcher can hand us a `&dyn Transcriber` (whisper.cpp
|
||||
// or the NPU engine, chosen at runtime) rather than a concrete type.
|
||||
T: Transcriber + ?Sized,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
const SAMPLE_RATE: usize = 16_000;
|
||||
const WINDOW_SECS: f32 = 4.0;
|
||||
let window_len = (WINDOW_SECS * SAMPLE_RATE as f32) as usize;
|
||||
|
||||
let mut buf: Vec<f32> = Vec::new();
|
||||
let mut offset_ms: u64 = 0;
|
||||
|
||||
let run_window = |transcriber: &T, samples: Vec<f32>, offset_ms: u64, on_segment: &mut F| {
|
||||
let decode = |samples: &[f32], offset_ms: u64| -> String {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let window = AudioWindow { samples, offset_ms };
|
||||
let window = AudioWindow {
|
||||
samples: samples.to_vec(),
|
||||
offset_ms,
|
||||
};
|
||||
if let Err(e) = transcriber.transcribe_stream(window, tx) {
|
||||
tracing::warn!("transcription window failed: {e}");
|
||||
return String::new();
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
while let Ok(segment) = rx.try_recv() {
|
||||
on_segment(segment);
|
||||
let t = segment.text.trim();
|
||||
if !t.is_empty() {
|
||||
parts.push(t.to_string());
|
||||
}
|
||||
}
|
||||
parts.join(" ")
|
||||
};
|
||||
|
||||
let mut streamer = Streamer::new(tuning);
|
||||
while let Ok(chunk) = frame_rx.recv() {
|
||||
buf.extend_from_slice(&chunk);
|
||||
while buf.len() >= window_len {
|
||||
let samples: Vec<f32> = buf.drain(..window_len).collect();
|
||||
run_window(transcriber, samples, offset_ms, &mut on_segment);
|
||||
offset_ms += (window_len as u64 * 1000) / SAMPLE_RATE as u64;
|
||||
}
|
||||
}
|
||||
// Final partial window on stop, if there's enough audio to be worth a pass.
|
||||
if buf.len() > SAMPLE_RATE / 2 {
|
||||
run_window(transcriber, buf, offset_ms, &mut on_segment);
|
||||
streamer.feed(&chunk, &decode, &mut on_segment);
|
||||
}
|
||||
streamer.flush(&decode, &mut on_segment);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -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.
|
||||
// Greedy KV-cache decode. Step 1 runs the merged graph's no-cache
|
||||
// branch over the full prompt and yields every present KV; later steps
|
||||
// feed a single token plus the cache, refreshing only the decoder
|
||||
// self-attention slots тАФ the cache branch passes encoder
|
||||
// cross-attention KVs through untouched, so the step-1 tensors stay
|
||||
// authoritative (same contract as Optimum's own runner).
|
||||
// ponytail: KVs round-trip host memory each step; wire ort IoBinding
|
||||
// to pin them on-device if GPU-decoder profiling shows the copies matter.
|
||||
let mut kv_cache: Vec<Tensor<f32>> = self
|
||||
.kv_slots
|
||||
.iter()
|
||||
.map(|slot| {
|
||||
let len = slot.dummy_dims.iter().product::<usize>();
|
||||
Tensor::from_array((slot.dummy_dims.clone(), vec![0.0f32; len]))
|
||||
})
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
|
||||
let mut tokens = self.initial_tokens.clone();
|
||||
let mut cached = false; // flips true once step 1 has filled kv_cache
|
||||
for _ in 0..MAX_NEW_TOKENS {
|
||||
let ids: Vec<i64> = tokens.clone();
|
||||
let ids: Vec<i64> = if cached {
|
||||
vec![*tokens.last().unwrap_or(&self.eot)]
|
||||
} else {
|
||||
tokens.clone()
|
||||
};
|
||||
let ids_tensor = Tensor::from_array(([1usize, ids.len()], ids))
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
let branch = Tensor::from_array(([1usize], vec![cached]))
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
|
||||
let next = {
|
||||
// Presents are extracted (copied) inside the outputs' borrow scope,
|
||||
// then written back into kv_cache after it ends.
|
||||
let (next, fresh_kvs) = {
|
||||
let mut dec = self
|
||||
.decoder
|
||||
.lock()
|
||||
.map_err(|_| TrxError::Inference("decoder mutex poisoned".into()))?;
|
||||
let mut inputs: Vec<(Cow<'_, str>, SessionInputValue<'_>)> = vec![
|
||||
(Cow::from(self.dec_ids.as_str()), ids_tensor.view().into()),
|
||||
(Cow::from(self.dec_hidden.as_str()), hidden.view().into()),
|
||||
(Cow::from(self.dec_use_cache.as_str()), branch.view().into()),
|
||||
];
|
||||
for (slot, kv) in self.kv_slots.iter().zip(kv_cache.iter()) {
|
||||
inputs.push((Cow::from(slot.past.as_str()), kv.view().into()));
|
||||
}
|
||||
let outputs = dec
|
||||
.run(ort::inputs![
|
||||
self.dec_ids.as_str() => ids_tensor.view(),
|
||||
self.dec_hidden.as_str() => hidden.view(),
|
||||
])
|
||||
.run(inputs)
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
let (shape, data) = outputs[self.dec_logits.as_str()]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
self.argmax_last(shape, data)
|
||||
let next = self.argmax_last(shape, data);
|
||||
|
||||
let mut fresh: Vec<(usize, Vec<usize>, Vec<f32>)> = Vec::new();
|
||||
for (i, slot) in self.kv_slots.iter().enumerate() {
|
||||
if slot.self_attention || !cached {
|
||||
let (s, d) = outputs[slot.present.as_str()]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
fresh.push((i, s.iter().map(|d| *d as usize).collect(), d.to_vec()));
|
||||
}
|
||||
}
|
||||
(next, fresh)
|
||||
};
|
||||
for (i, dims, data) in fresh_kvs {
|
||||
kv_cache[i] = Tensor::from_array((dims, data))
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
}
|
||||
cached = true;
|
||||
|
||||
if next == self.eot {
|
||||
break;
|
||||
@@ -180,6 +267,11 @@ impl OnnxTranscriber {
|
||||
best
|
||||
}
|
||||
|
||||
/// Which EP the decoder session landed on тАФ "OpenVINO/GPU" or "CPU".
|
||||
pub fn decoder_ep(&self) -> &'static str {
|
||||
self.decoder_ep
|
||||
}
|
||||
|
||||
fn segment(&self, text: String, start_ms: u64, end_ms: u64) -> Option<TranscriptSegment> {
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
@@ -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()
|
||||
.map_err(|e| TrxError::Load(e.to_string()))?
|
||||
.commit_from_file(&decoder_path)
|
||||
.map_err(|e| TrxError::Load(e.to_string()))?;
|
||||
// Decoder session. The KV-cache loop is dynamic-shaped and
|
||||
// autoregressive тАФ a poor NPU fit тАФ but it needn't burn CPU either: on
|
||||
// the NPU path, try the same OpenVINO runtime's GPU plugin (Intel
|
||||
// iGPU) first and fall back to the default CPU EP if no GPU is usable.
|
||||
// `WA_ONNX_DECODER_DEVICE=cpu|gpu` overrides (debug escape hatch, like
|
||||
// WA_NPU_RUNTIME_URL). DirectML backends keep the CPU decoder тАФ the
|
||||
// DML EP handles per-step-growing KV shapes poorly.
|
||||
let want_gpu = match std::env::var("WA_ONNX_DECODER_DEVICE").as_deref() {
|
||||
Ok("cpu") => false,
|
||||
Ok("gpu") => true,
|
||||
_ => matches!(backend, BackendId::Npu),
|
||||
};
|
||||
let mut decoder_ep = "CPU";
|
||||
let mut decoder = None;
|
||||
if want_gpu {
|
||||
let gpu = Session::builder()
|
||||
.and_then(|b| {
|
||||
b.with_execution_providers([OpenVINOExecutionProvider::default()
|
||||
.with_device_type("GPU")
|
||||
.build()
|
||||
.error_on_failure()])
|
||||
})
|
||||
.and_then(|b| b.commit_from_file(&decoder_path));
|
||||
match gpu {
|
||||
Ok(s) => {
|
||||
decoder_ep = "OpenVINO/GPU";
|
||||
decoder = Some(s);
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
"OpenVINO GPU EP unavailable for the decoder ({e}); using the CPU EP"
|
||||
),
|
||||
}
|
||||
}
|
||||
let decoder = match decoder {
|
||||
Some(s) => s,
|
||||
None => Session::builder()
|
||||
.map_err(|e| TrxError::Load(e.to_string()))?
|
||||
.commit_from_file(&decoder_path)
|
||||
.map_err(|e| TrxError::Load(e.to_string()))?,
|
||||
};
|
||||
|
||||
let enc_input = Self::find_input(&encoder, "input_features")?;
|
||||
let enc_output = Self::find_output(&encoder, "last_hidden_state")
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,12 @@ pub fn seal(plaintext: &[u8]) -> Result<Vec<u8>, VaultError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `data` begins with the vault's sealed-file magic (i.e. it's
|
||||
/// ciphertext at rest, not a plaintext/pre-vault file).
|
||||
pub fn is_sealed(data: &[u8]) -> bool {
|
||||
data.len() >= MAGIC.len() && &data[..MAGIC.len()] == MAGIC
|
||||
}
|
||||
|
||||
/// Inverse of `seal`. Plaintext (no magic) passes through unchanged; sealed data
|
||||
/// requires the vault to be unlocked.
|
||||
pub fn open(data: &[u8]) -> Result<Vec<u8>, VaultError> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "WhispAssist",
|
||||
"version": "0.1.6",
|
||||
"version": "0.7.5",
|
||||
"identifier": "bet.dou.whispassist",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -21,19 +21,19 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; img-src 'self' data:; style-src 'self' 'unsafe-inline'"
|
||||
},
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/tray.png",
|
||||
"tooltip": "WhispAssist"
|
||||
"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'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["msi", "nsis"],
|
||||
"icon": ["icons/icon.ico"],
|
||||
"resources": ["wa-defaults.ini"],
|
||||
"windows": {
|
||||
"webviewInstallMode": { "type": "downloadBootstrapper" }
|
||||
"webviewInstallMode": { "type": "downloadBootstrapper" },
|
||||
"nsis": {
|
||||
"installMode": "both"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "gen/schemas/desktop-schema.json",
|
||||
"bundle": {
|
||||
"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
|
||||
+338
-56
@@ -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, 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)");
|
||||
@@ -99,6 +164,12 @@
|
||||
else recording.stop();
|
||||
}
|
||||
|
||||
async function cancelRecording() {
|
||||
if (!confirm(t("app.discard_confirm"))) return;
|
||||
await recording.cancel();
|
||||
meetings.deselect();
|
||||
}
|
||||
|
||||
// Global shortcuts (T7.4, FR-UX-3): record start/stop, view toggles.
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
@@ -120,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;
|
||||
}
|
||||
});
|
||||
@@ -140,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,56 +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={t("app.cancel_title")}>
|
||||
<Trash2 size={12} aria-hidden="true" />
|
||||
{t("app.cancel")}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="spacer"></div>
|
||||
{#if recording.state === "idle"}
|
||||
<button
|
||||
class="import-btn"
|
||||
onclick={() => (showImport = true)}
|
||||
title={t("app.add_meeting_title")}
|
||||
>
|
||||
<FilePlus size={13} aria-hidden="true" />
|
||||
{t("app.add_meeting")}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="rec">
|
||||
<span class="rec-dot" aria-hidden="true"></span>
|
||||
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}
|
||||
<span class="sr-only">{recording.micMuted ? t("app.unmute") : t("app.mute")}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<label class="retention" title="Save audio as .wav for this meeting">
|
||||
{#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}
|
||||
@@ -224,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)}
|
||||
>
|
||||
@@ -238,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
|
||||
>
|
||||
@@ -250,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>
|
||||
|
||||
@@ -416,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;
|
||||
}
|
||||
@@ -428,7 +630,8 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.record-btn,
|
||||
.stop-btn {
|
||||
.stop-btn,
|
||||
.import-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
@@ -443,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;
|
||||
@@ -461,6 +673,25 @@
|
||||
.stop-btn:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
.cancel-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 150ms ease-out,
|
||||
border-color 150ms ease-out;
|
||||
}
|
||||
.cancel-btn:hover {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.rec {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -517,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;
|
||||
@@ -559,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;
|
||||
|
||||
+202
-13
@@ -48,8 +48,29 @@ export interface HardwareStatus {
|
||||
directml?: { applicable: boolean; runtimeReady: boolean; modelInstalled: boolean };
|
||||
}
|
||||
|
||||
// One enumerated audio device тАФ a render (playback) device for the loopback
|
||||
// picker (FR-CAP-1) or a capture (microphone) device for the mic picker
|
||||
// (FR-CAP-7). `id` is the persisted `Device::get_id()`; `name` is display-only.
|
||||
export interface AudioDeviceInfo {
|
||||
id: string;
|
||||
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[];
|
||||
@@ -61,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;
|
||||
@@ -144,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;
|
||||
@@ -151,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;
|
||||
@@ -188,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).
|
||||
@@ -276,13 +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).
|
||||
@@ -312,39 +397,93 @@ 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 }),
|
||||
// Abandon an accidental recording: stop + delete files + drop the DB row.
|
||||
cancelRecording: (meetingId: MeetingId) => invoke<void>("cancel_recording", { meetingId }),
|
||||
// Absolute path to a playable audio.wav (decrypted if sealed), for convertFileSrc.
|
||||
recordingPlaybackPath: (meetingId: MeetingId) =>
|
||||
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"),
|
||||
listAudioDevices: () => invoke<AudioDeviceInfo[]>("list_audio_devices"),
|
||||
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) =>
|
||||
@@ -364,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: (
|
||||
@@ -381,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).
|
||||
@@ -394,14 +545,25 @@ export const api = {
|
||||
invoke<void>("generate_summary", { meetingId, templateId }),
|
||||
confirmActionItems: (meetingId: MeetingId, items: ActionItem[]) =>
|
||||
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) =>
|
||||
invoke<CalendarEventDetail>("get_calendar_event", { eventId }),
|
||||
attachMeetingToEvent: (meetingId: MeetingId, eventId: string) =>
|
||||
invoke<void>("attach_meeting_to_event", { meetingId, eventId }),
|
||||
renameMeeting: (meetingId: MeetingId, title: string) =>
|
||||
invoke<void>("rename_meeting", { meetingId, title }),
|
||||
renameSpeaker: (meetingId: MeetingId, label: string, name: string) =>
|
||||
invoke<void>("rename_speaker", { meetingId, label, name }),
|
||||
mapSpeakerToParticipant: (meetingId: MeetingId, label: string, participantId: string) =>
|
||||
@@ -432,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) =>
|
||||
@@ -459,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) ----
|
||||
@@ -468,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> =>
|
||||
@@ -489,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";
|
||||
@@ -515,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>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
// GitHub-topic-style pill (T8.3, FR-SEARCH-2). Clicking the label filters
|
||||
// the meeting list to this tag; the optional 'x' removes it from whatever
|
||||
// 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;
|
||||
removable?: boolean;
|
||||
onRemove?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
let { tag, removable = false, onRemove, onClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="chip" class:clickable={!!onClick}>
|
||||
<button
|
||||
type="button"
|
||||
class="label"
|
||||
onclick={onClick}
|
||||
disabled={!onClick}
|
||||
title={onClick ? t("tagchip.filter", { tag }) : undefined}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
{#if removable}
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
onclick={onRemove}
|
||||
aria-label={t("tagchip.remove", { tag })}
|
||||
>
|
||||
<X size={10} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.label {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0.15rem 0.65rem;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: default;
|
||||
}
|
||||
.label:disabled {
|
||||
opacity: 1; /* a non-clickable chip should still read as fully legible */
|
||||
}
|
||||
.chip.clickable .label {
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip.clickable .label:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.label:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.remove {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
margin: 0 0.3rem 0 -0.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease-out,
|
||||
opacity 150ms ease-out;
|
||||
}
|
||||
.remove:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.remove:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -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 рдмрдВрдж рд╣реЛрдиреЗ рдХреЗ рдмрдЬрд╛рдп рдмреИрдХрдЧреНрд░рд╛рдЙрдВрдб рдореЗрдВ рдЪрд▓рддрд╛ рд░рд╣рддрд╛ рд╣реИред рдЯреНрд░реЗ рдЖрдЗрдХрди рд╕реЗ рджреЛрдмрд╛рд░рд╛ рдЦреЛрд▓реЗрдВ; рдкреВрд░реА рддрд░рд╣ рдмрдВрдж рдХрд░рдиреЗ рдХреЗ рд▓рд┐рдП рдЯреНрд░реЗ рдХрд╛ 'рдмрд╛рд╣рд░ рдирд┐рдХрд▓реЗрдВ' рдЙрдкрдпреЛрдЧ рдХрд░реЗрдВред рдбрд┐рдлрд╝реЙрд▓реНрдЯ рд░реВрдк рд╕реЗ рдЪрд╛рд▓реВред"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user