571 Commits
Author SHA1 Message Date
iamdoubz ad3e839b6c Merge pull request 'Feat model quant' (#27) from feat_model_quant into main
Reviewed-on: #27
2026-07-17 08:00:52 -05:00
iamdoubz f9b2695dfc chore(release): bump tauri.conf.json to 0.7.5 2026-07-17 07:59:08 -05:00
iamdoubz a63a7d22e3 chore(release): bump whispassist Cargo.lock entry to 0.7.5 2026-07-17 07:59:06 -05:00
iamdoubz 1d6c2cc7f8 chore(release): bump Cargo.toml to 0.7.5 2026-07-17 07:59:05 -05:00
iamdoubz 64b1e50d17 chore(release): bump package.json to 0.7.5 2026-07-17 07:59:04 -05:00
iamdoubz afb1ffaa3e feat(i18n): ur strings for unencrypted padlock and encrypt action 2026-07-16 23:07:14 -05:00
iamdoubz 8f56509c16 feat(i18n): ar strings for unencrypted padlock and encrypt action 2026-07-16 23:07:13 -05:00
iamdoubz 618e8e1f40 feat(i18n): si strings for unencrypted padlock and encrypt action 2026-07-16 23:07:12 -05:00
iamdoubz a1f066d361 feat(i18n): ta strings for unencrypted padlock and encrypt action 2026-07-16 23:07:10 -05:00
iamdoubz c3f874418b feat(i18n): bn strings for unencrypted padlock and encrypt action 2026-07-16 23:07:09 -05:00
iamdoubz f49288ddf1 feat(i18n): hi strings for unencrypted padlock and encrypt action 2026-07-16 23:07:08 -05:00
iamdoubz 78a5c09ea4 feat(i18n): ko strings for unencrypted padlock and encrypt action 2026-07-16 23:07:07 -05:00
iamdoubz 1ff31c188f feat(i18n): zh-CN strings for unencrypted padlock and encrypt action 2026-07-16 23:07:05 -05:00
iamdoubz fb380fb11d feat(i18n): fi strings for unencrypted padlock and encrypt action 2026-07-16 23:07:04 -05:00
iamdoubz b67e1e3748 feat(i18n): nb strings for unencrypted padlock and encrypt action 2026-07-16 23:07:03 -05:00
iamdoubz 6f31bc3907 feat(i18n): sv strings for unencrypted padlock and encrypt action 2026-07-16 23:07:02 -05:00
iamdoubz 2205367925 feat(i18n): ru strings for unencrypted padlock and encrypt action 2026-07-16 23:07:00 -05:00
iamdoubz 5507ad48db feat(i18n): pt-BR strings for unencrypted padlock and encrypt action 2026-07-16 23:06:59 -05:00
iamdoubz 2419059eea feat(i18n): pt-PT strings for unencrypted padlock and encrypt action 2026-07-16 23:06:58 -05:00
iamdoubz 010649b1d1 feat(i18n): fr-CA strings for unencrypted padlock and encrypt action 2026-07-16 23:06:57 -05:00
iamdoubz 058082824d feat(i18n): fr-FR strings for unencrypted padlock and encrypt action 2026-07-16 23:06:55 -05:00
iamdoubz 7ba27c079f feat(i18n): es-MX strings for unencrypted padlock and encrypt action 2026-07-16 23:06:54 -05:00
iamdoubz c1146bb76f feat(i18n): es-ES strings for unencrypted padlock and encrypt action 2026-07-16 23:06:53 -05:00
iamdoubz f5f25e9f86 feat(i18n): de strings for unencrypted padlock and encrypt action 2026-07-16 23:06:52 -05:00
iamdoubz c546b567e0 feat(i18n): en strings for unencrypted padlock and encrypt action 2026-07-16 23:06:50 -05:00
iamdoubz ce14a7bd06 feat(vault): refresh meeting padlocks after vault enable/unlock/lock 2026-07-16 23:06:49 -05:00
iamdoubz fa15a105fd feat(vault): open-padlock marker + Encrypt action per meeting (FR-SEC-3) 2026-07-16 23:06:48 -05:00
iamdoubz e104dfd5fe feat(vault): meetings store tracks unencrypted ids, vault unlock, encrypt action 2026-07-16 23:06:47 -05:00
iamdoubz 2b2c500432 feat(vault): api bindings for unencrypted-meeting list and encrypt_meeting 2026-07-16 23:06:45 -05:00
iamdoubz 691456552d chore(lint): run i18n parity check first so prettier drift can't mask it 2026-07-16 23:06:44 -05:00
iamdoubz 785b4dce50 feat(vault): register unencrypted-list and encrypt-meeting commands 2026-07-16 23:01:26 -05:00
iamdoubz 39f71ab266 feat(vault): list_unencrypted_meetings + encrypt_meeting seal-in-place commands (FR-SEC-3) 2026-07-16 23:01:25 -05:00
iamdoubz 06bf067fdb chore(lint): run i18n parity check in npm run lint 2026-07-16 22:58:52 -05:00
iamdoubz f3197ffc53 chore(i18n): key-parity + placeholder check script, catches untranslated keys 2026-07-16 22:58:50 -05:00
iamdoubz ddc3dd4262 fix(i18n): ur backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:46 -05:00
iamdoubz d4f0a93420 fix(i18n): ar backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:45 -05:00
iamdoubz 8ff00da0b4 fix(i18n): si backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:44 -05:00
iamdoubz 7fd52c9a62 fix(i18n): ta backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:43 -05:00
iamdoubz 91354cb45d fix(i18n): bn backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:42 -05:00
iamdoubz f39814cd95 fix(i18n): hi backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:41 -05:00
iamdoubz e9e6677756 fix(i18n): ko backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:40 -05:00
iamdoubz 167a53e897 fix(i18n): zh-CN backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:39 -05:00
iamdoubz 0598f4d8e2 fix(i18n): fi backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:38 -05:00
iamdoubz a2daf3aa02 fix(i18n): nb backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:37 -05:00
iamdoubz b41a8f666f fix(i18n): sv backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:35 -05:00
iamdoubz 31bdf0e39c fix(i18n): ru backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:34 -05:00
iamdoubz 5731e2df68 fix(i18n): pt-BR backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:33 -05:00
iamdoubz 04244196bb fix(i18n): pt-PT backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:32 -05:00
iamdoubz 28b9816162 fix(i18n): fr-CA backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:31 -05:00
iamdoubz a7d38b39dc fix(i18n): fr-FR backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:30 -05:00
iamdoubz 804b0a8598 fix(i18n): es-MX backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:29 -05:00
iamdoubz 804e3b6fa6 fix(i18n): es-ES backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:28 -05:00
iamdoubz 1525645ace fix(i18n): de backfill 53 untranslated keys, drop dead import.requires_1-3 2026-07-16 21:24:27 -05:00
iamdoubz d49f375a13 feat(i18n): ur strings for model quantization picker 2026-07-16 21:10:14 -05:00
iamdoubz 0f8b5500c2 feat(i18n): ar strings for model quantization picker 2026-07-16 21:10:13 -05:00
iamdoubz 9c349bb84d feat(i18n): si strings for model quantization picker 2026-07-16 21:10:12 -05:00
iamdoubz 5d2aa9840a feat(i18n): ta strings for model quantization picker 2026-07-16 21:10:11 -05:00
iamdoubz 204a507de7 feat(i18n): bn strings for model quantization picker 2026-07-16 21:10:10 -05:00
iamdoubz 4e167082ac feat(i18n): hi strings for model quantization picker 2026-07-16 21:10:08 -05:00
iamdoubz ee5c0ea7bc feat(i18n): ko strings for model quantization picker 2026-07-16 21:10:07 -05:00
iamdoubz 9311909a58 feat(i18n): zh-CN strings for model quantization picker 2026-07-16 21:10:06 -05:00
iamdoubz ec42d89f66 feat(i18n): fi strings for model quantization picker 2026-07-16 21:10:05 -05:00
iamdoubz c00da29f06 feat(i18n): nb strings for model quantization picker 2026-07-16 21:10:03 -05:00
iamdoubz 66b35c3a6c feat(i18n): sv strings for model quantization picker 2026-07-16 21:10:02 -05:00
iamdoubz 1a27fa0a1a feat(i18n): ru strings for model quantization picker 2026-07-16 21:10:01 -05:00
iamdoubz 8998863bb0 feat(i18n): pt-BR strings for model quantization picker 2026-07-16 21:09:59 -05:00
iamdoubz 94443dcd35 feat(i18n): pt-PT strings for model quantization picker 2026-07-16 21:09:58 -05:00
iamdoubz d3f4aeef14 feat(i18n): fr-CA strings for model quantization picker 2026-07-16 21:09:57 -05:00
iamdoubz 4bbaed7b0c feat(i18n): fr-FR strings for model quantization picker 2026-07-16 21:09:56 -05:00
iamdoubz b55bd5175f feat(i18n): es-MX strings for model quantization picker 2026-07-16 21:09:55 -05:00
iamdoubz d1b951e9ea feat(i18n): es-ES strings for model quantization picker 2026-07-16 21:09:54 -05:00
iamdoubz e2ec141d75 feat(i18n): de strings for model quantization picker 2026-07-16 21:09:53 -05:00
iamdoubz 90c12cb070 feat(i18n): en strings for model quantization picker 2026-07-16 21:09:51 -05:00
iamdoubz 88adce152b feat(ui): group whisper models per family with quantization dropdown (FR-MODEL-1) 2026-07-16 21:09:50 -05:00
iamdoubz 9e3f6327b7 feat(models): add Q8_0 variants + large-v3-turbo q5_0/q8_0 to catalog (FR-MODEL-1) 2026-07-16 21:06:35 -05:00
iamdoubz 8cdd0f61df Merge pull request 'Parakeet v Whisper h2h' (#26) from test_parakeet into main
Reviewed-on: #26
2026-07-16 10:23:17 -05:00
iamdoubz 2d1c9fa478 Bench test npu 2026-07-16 10:10:30 -05:00
iamdoubz a71299803f docs(release): v0.7.4 release notes with SHA-256 checksums 2026-07-16 10:03:23 -05:00
iamdoubz fcf72da9db chore: bump version 0.7.4 2026-07-16 09:56:22 -05:00
iamdoubz 616d2f4f97 chore(bench): rustfmt asr_bench + allow large_enum_variant (one Engine per process) 2026-07-16 08:47:11 -05:00
iamdoubz be49c53940 docs(bench): W1 anomaly resolved тАФ LP-E-core spin-barrier root cause, fixed numbers, criteria updated 2026-07-16 08:44:17 -05:00
iamdoubz 6cd736bec1 test(bench): W1 re-run after thread-cap fix тАФ RTF 0.218, 0.827 cpu-sec/audio-sec, WER unchanged 2026-07-16 08:43:26 -05:00
iamdoubz 0d2eee7416 fix(transcription): cap whisper n_threads at 4 тАФ hybrid-CPU spin-barrier pathology
available_parallelism() put ggml workers on Meteor Lake LP E-cores; every
per-node spin barrier then waits on the slowest worker while the rest burn
CPU: RTF ~22 at 14 threads vs 0.28 at 4 threads (~80x), same build. This
was the real W1 CPU-pathology root cause; /Od-vs-/O2 made no difference.
4 matches whisper.cpp upstream default and is the CPU-frugal choice
(NFR-RES-1). WA_WHISPER_THREADS env overrides for support/bench.
2026-07-16 08:39:45 -05:00
iamdoubz c7899e72a2 fix(build): use Ninja generator so cmake-rs stops clobbering MSVC /O2
The cmake crate strips -O*/O* from CFLAGS and, with no explicit generator
on MSVC, overwrites CMAKE_C_FLAGS_RELEASE тАФ so the previous CFLAGS=/O2
attempt could never work (verified: /DNDEBUG survived, /O2 stripped).
With CMAKE_GENERATOR=Ninja the override branch is skipped and CMake's
default Release flags (/O2 /Ob2 /DNDEBUG) reach ggml.
2026-07-16 08:20:07 -05:00
iamdoubz 4e6c5a357e docs(bench): qualitative real-recording pass тАФ P1 cleanest + best punctuation, W4 worst 2026-07-16 08:18:00 -05:00
iamdoubz 181af150e5 test(bench): qualitative meeting-audio pass тАФ W2/W4/P1 on bank_deposits.wav
62 s real recording (48 kHz stereo, downmixed/resampled by the production
reader). Reference .txt is a placeholder; WER column is meaningless here,
only the hypothesis text counts.
2026-07-16 08:16:53 -05:00
iamdoubz d89b99db6c fix(build): restore /O2 /DNDEBUG for native deps clobbered by cmake-rs on MSVC
cmake-rs hardcodes opt_level(0) and replaces CMake's Release C flags, so
ggml/whisper.cpp compiled at /Od тАФ root cause of the W1 CPU-pathology
(RTF ~20) in docs/benchmarks/2026-07-parakeet-vs-whisper.md. cc-rs appends
env CFLAGS/CXXFLAGS last, restoring optimization for all native deps.
2026-07-16 08:14:08 -05:00
iamdoubz 32e0d3c8d9 test(bench): raw benchmark CSVs (30-utterance LibriSpeech matrix + W1 non-vulkan rerun) 2026-07-16 00:42:26 -05:00
iamdoubz 21691d3756 docs(bench): parakeet-vs-whisper results тАФ pre-registered criteria evaluated, per-rung recommendation 2026-07-16 00:42:25 -05:00
iamdoubz ca0563039d test(bench): p2-parakeet-dml contender (Parakeet on the iGPU via DirectML EP) 2026-07-15 23:14:47 -05:00
iamdoubz a682ffccdd test(bench): bench-directml feature for DirectML sherpa binaries (P2, never default) 2026-07-15 23:14:42 -05:00
iamdoubz 68632db6ef fix(bench): named Set-Content params тАФ positional+NoNewline silently wrote nothing on PS 5.1 2026-07-15 21:45:46 -05:00
iamdoubz 297c412488 test(bench): sequential matrix runner (6 corpus + 3 live runs) 2026-07-15 21:42:28 -05:00
iamdoubz ad4495f220 test(bench): asr_bench harness тАФ W1-W4 + Parakeet contenders, WER/RTF/CPU-seconds, live-window mode 2026-07-15 21:40:44 -05:00
iamdoubz 4dddcdfe1a test(bench): typeperf CPU/GPU/NPU counter capture script 2026-07-15 21:35:43 -05:00
iamdoubz 965f1adc86 test(bench): parakeet-tdt-0.6b-v2 int8 staging script 2026-07-15 21:35:42 -05:00
iamdoubz 96072a2f75 test(bench): corpus staging script + gitignore for bench-corpus (parakeet bake-off) 2026-07-15 21:35:41 -05:00
iamdoubz 8c8e476cb7 Ignore memory file 2026-07-15 21:16:46 -05:00
iamdoubz 22810df4c1 Delete MEMORY.md 2026-07-15 21:15:06 -05:00
iamdoubz 87fb665e9e Merge pull request 'Feature chore bug 010' (#25) from feature_chore_bug_010 into main
Reviewed-on: #25
2026-07-15 20:42:24 -05:00
iamdoubz 470fe6a7e7 docs(readme): refresh to v0.7.3 тАФ NPU+iGPU split, live 'You' diarization, mic mute, tray, background import, AI-enhance, libpst button; drop stale 'signed installer' claim 2026-07-15 20:39:29 -05:00
iamdoubz 403d652c6d style: cargo fmt sweep (reflow-only; verified rustfmt(HEAD) == worktree per file) 2026-07-15 20:36:48 -05:00
iamdoubz e8a6ddca71 docs(release): v0.7.3 release notes with SHA-256 checksums 2026-07-15 20:35:12 -05:00
iamdoubz c9643cd79b feat(calendar): libpst (readpst) download button in Settings, matching the Import dialog's tool buttons (FR-CAL-1, ADR-0008) 2026-07-15 20:28:19 -05:00
iamdoubz 29bb636cb5 feat(i18n): Settings calendar 'requires readpst' string 2026-07-15 20:28:18 -05:00
iamdoubz 89bd7c3b7e chore: bump version 0.7.3 2026-07-15 20:14:30 -05:00
iamdoubz 4a3f8de679 docs(adr): 0004 update тАФ NPU path is OpenVINO; decoder split to iGPU with KV cache 2026-07-15 19:48:16 -05:00
iamdoubz 7f7de48ecc feat(npu): engine log names the decoder EP alongside the encoder EP 2026-07-15 19:48:15 -05:00
iamdoubz 402ffe4586 feat(npu): KV-cache greedy decode + decoder on the OpenVINO GPU EP (T3.4)
Merged-decoder (use_cache_branch/past_key_values) loop discovered from the
graph at load; step 1 runs the no-cache branch over the prompt, later steps
feed one token + cached KVs (decoder slots refreshed, encoder cross-attn KVs
kept from step 1). Decoder session lands on the Intel iGPU via the OpenVINO
GPU plugin (same staged runtime) with CPU-EP fallback and a
WA_ONNX_DECODER_DEVICE=cpu|gpu override; DirectML backends keep the CPU
decoder. Legacy decoder_model.onnx is removed once the merged graph loads.

Measured (Core Ultra 5 135U, base.en, 19s TTS clip, identical transcripts):
decoder on OpenVINO/GPU infer=1288ms vs CPU-EP infer=6098ms
2026-07-15 19:48:14 -05:00
iamdoubz 177f1f7d90 feat(npu): catalog the merged Whisper decoder (decoder_model_merged.onnx) for KV-cache decode (T3.4) 2026-07-15 19:35:16 -05:00
iamdoubz 21f3d76ed0 Merge pull request 'Feature chore bug 009' (#24) from feature_chore_bug_009 into main
Reviewed-on: #24
2026-07-15 18:13:11 -05:00
iamdoubz 19bd5815c0 chore: bump version 0.7.2 2026-07-15 18:09:07 -05:00
iamdoubz a552ddb1fa fix(ui): action items as two-row cards so the action text is always readable in the narrow pane
Text row (confirm + full-width text + delete) over a wrapping meta row
(owner, due date, reminder). Also void the seekNonce dependency read to
satisfy no-unused-expressions.
2026-07-15 17:41:38 -05:00
iamdoubz de25ac70e7 fix(notes): adopt server-side notes.md changes (speaker rename, reprocess) into the open editor buffer
Also untangles the a11y svelte-ignore comment from its prose so eslint's
no-unused-svelte-ignore stops parsing the explanation as ignore codes.
2026-07-15 17:41:37 -05:00
iamdoubz 28d9b2988a fix(spk): live split attribution + honest model reporting + notes rename rewrite (FR-SPK, T3.4)
- live diarization tick on split recordings now uses attribute_split (mic
  channel = 'You' from the first pass) instead of whole-mix clustering +
  voiceprint matching, which never reliably surfaced 'You' before stop
- attribute_split assigns via diarization::assign_split (channel-total
  overlap) so the user's speech can't be swallowed by a longer far-side span
- stop_recording overlays live user renames over the automatic name map
- rename_speaker/map_speaker_to_participant rewrite notes.md's baked-in
  **Name:** dialogue tags so the Notes pane follows the Speakers pane
- model_used records the ONNX model when the NPU/DirectML engine is routed
  (was reporting the unused ggml model id) + info log naming the encoder EP
2026-07-15 17:41:28 -05:00
iamdoubz f24adc3a59 fix(diarization): channel-total split attribution so mic speech can't be swallowed by a far span (FR-SPK) 2026-07-15 17:33:40 -05:00
iamdoubz 94425d456c docs(release): v0.7.1 release notes with SHA-256 checksums 2026-07-15 14:44:01 -05:00
iamdoubz 49cdbd6a54 chore: bump version 0.7.1 2026-07-15 14:38:31 -05:00
iamdoubz b4d72622da docs(data): close_to_tray setting in settings.json schema 2026-07-15 14:02:22 -05:00
iamdoubz c1ccdce497 docs(api): toggle_microphone_mute command + recording://mic event 2026-07-15 14:02:21 -05:00
iamdoubz a319dd9e97 feat(i18n): mute + close-to-tray strings 2026-07-15 14:01:43 -05:00
iamdoubz 6a01afbba6 feat(settings): default close_to_tray in settings store 2026-07-15 14:01:42 -05:00
iamdoubz 8c6a0ea25c feat(settings): close-to-tray toggle in Settings 2026-07-15 14:01:41 -05:00
iamdoubz 4a000f07a2 feat(app): M-key + mic mute button in recording controls 2026-07-15 14:01:40 -05:00
iamdoubz 0e2a3bb110 feat(recording): micMuted state + toggleMute synced to recording://mic 2026-07-15 13:59:04 -05:00
iamdoubz 95b3d34713 feat(api): toggleMicrophoneMute + recording://mic event + close_to_tray setting 2026-07-15 13:58:18 -05:00
iamdoubz 588354f488 fix(tray): remove duplicate trayIcon from config тАФ one tray icon only 2026-07-15 13:57:38 -05:00
iamdoubz ee08f2bc88 feat(app): single tray with Show/Quit menu, close-to-tray, register mute cmd 2026-07-15 13:57:37 -05:00
iamdoubz a71c17965f feat(recording): toggle_microphone_mute command + recording://mic event 2026-07-15 13:57:36 -05:00
iamdoubz 3c73ec1cd4 feat(settings): close_to_tray setting (default on) 2026-07-15 13:57:35 -05:00
iamdoubz 8f805b8ef5 feat(audio): live mic mute тАФ zero mic samples via CaptureHandle.set_muted (FR-CAP-7) 2026-07-15 13:57:33 -05:00
iamdoubz f83234bde1 docs(api): document import_media (model, background) + import://progress event 2026-07-15 13:46:50 -05:00
iamdoubz d4b1578478 feat(i18n): transcript.transcribed_with label 2026-07-15 13:46:01 -05:00
iamdoubz 185e4ec803 feat(import): show import tracker + 'transcribed with' model in meeting view 2026-07-15 13:46:00 -05:00
iamdoubz 30f4a4d3ec feat(import): init imports store for live progress tracking 2026-07-15 13:45:59 -05:00
iamdoubz 86c3587d19 feat(i18n): import model/tracker/phase strings; background import copy 2026-07-15 13:44:00 -05:00
iamdoubz d736b78ecb feat(import): model selector + ffmpeg/yt-dlp download links in add-meeting dialog 2026-07-15 13:43:26 -05:00
iamdoubz 98f6fcdc16 feat(import): ImportTracker Domino's-style 4-step progress component 2026-07-15 13:42:19 -05:00
iamdoubz 683838bef1 feat(import): imports store tracking per-meeting import phase progress 2026-07-15 13:41:08 -05:00
iamdoubz c63cabaf88 feat(import): api model param + import://progress event and types 2026-07-15 13:39:55 -05:00
iamdoubz fe355f034d feat(import): run import in background with per-phase progress + model override 2026-07-15 13:39:13 -05:00
iamdoubz e0e9377273 feat(storage): set_meeting_status for background import status transitions 2026-07-15 13:35:06 -05:00
iamdoubz a95eafe952 docs(api): enhance_notes, monitor_audio_level, stress_test_hardware + events 2026-07-15 09:50:17 -05:00
iamdoubz 5b9e0ed1f7 feat(app): register enhance_notes, monitor_audio_level, stress_test_hardware 2026-07-15 09:50:16 -05:00
iamdoubz 6c343c4204 feat(hardware): enhance_notes, monitor_audio_level, stress_test_hardware commands 2026-07-15 09:50:15 -05:00
iamdoubz 7d16c864e8 feat(ui): i18n for notes editor, vault, and hardware test/stress 2026-07-15 09:50:13 -05:00
iamdoubz e461d4eb22 feat(ui): enhanceNotes/monitorAudioLevel/stressTestHardware bindings + events + types 2026-07-15 09:50:12 -05:00
iamdoubz b14728e1c8 feat(ui): vault lock card, audio device test meter, quick hardware stress test 2026-07-15 09:50:00 -05:00
iamdoubz b7e6f54d91 feat(ui): header reshuffle тАФ actions far-left, template beside them, drop wordmark 2026-07-15 09:49:58 -05:00
iamdoubz cf919f1499 fix(notes): refresh notes pane after re-transcribe; add AI-enhance + slash/toolbar
Re-sync the editor buffer after reprocess (was stale until restart). Adds a
Granola-style AI-enhance button (grounded in transcript, one-step undo), H3/
numbered/quote/divider toolbar buttons, and slash commands.
2026-07-15 09:49:57 -05:00
iamdoubz c616e0bfec Merge pull request 'Chores customize install' (#23) from chores_customize_install into main
Reviewed-on: #23
2026-07-14 21:40:02 -05:00
iamdoubz ecbe3e464c docs(release): v0.7.0 release notes with SHA-256 checksums 2026-07-14 20:01:04 -05:00
iamdoubz 7f6c0fac81 chore: bump version 0.7.0 2026-07-14 19:53:03 -05:00
iamdoubz 6fa9916f7e docs(readme): opt-in startup, install scope, enterprise deployment pointer 2026-07-14 16:27:02 -05:00
iamdoubz d78a426a7e docs(api): set_auto_start command 2026-07-14 16:27:01 -05:00
iamdoubz 1b36df020a docs(data-model): auto_start setting + wa-defaults.ini first-run seeding 2026-07-14 16:27:00 -05:00
iamdoubz 4af008da0f docs(deploy): enterprise deployment guide (wa-defaults.ini, install flags) 2026-07-14 16:26:59 -05:00
iamdoubz 223dc5f34b docs(adr): ADR-0012 launch-at-login & enterprise deployment defaults 2026-07-14 16:26:58 -05:00
iamdoubz 27773a03e5 feat(ui): i18n for launch-at-login and hardware refresh 2026-07-14 16:24:28 -05:00
iamdoubz 203e7b1bf6 feat(ui): launch-at-login toggle + refresh-hardware button 2026-07-14 16:24:27 -05:00
iamdoubz a6e1728346 feat(ui): setAutoStart store action + auto_start default 2026-07-14 16:24:26 -05:00
iamdoubz 26808970df feat(ui): setAutoStart binding + auto_start in AppSettings 2026-07-14 16:24:25 -05:00
iamdoubz 4a63b38ee0 chore(bundle): include wa-defaults.ini in the Vulkan release resources 2026-07-14 16:21:43 -05:00
iamdoubz 5d6563231c chore(bundle): bundle wa-defaults.ini + NSIS installMode both (per-user/all-users) 2026-07-14 16:21:42 -05:00
iamdoubz 2edd0382d3 feat(deploy): ship commented wa-defaults.ini template (no-op until edited) 2026-07-14 16:21:41 -05:00
iamdoubz 1530bca976 feat(app): register autostart plugin, first-run deploy seed, auto_start reconcile 2026-07-14 16:21:35 -05:00
iamdoubz 55c5143de0 feat(deploy): first-run wa-defaults.ini settings seeding
Reads an admin-supplied .ini once before settings.json exists; coerces
values onto typed Settings via serde; drops secret-looking keys.
2026-07-14 16:21:34 -05:00
iamdoubz 47071b5861 feat(app): set_auto_start command + default_settings/save_settings visibility 2026-07-14 16:21:33 -05:00
iamdoubz ded5e00750 feat(app): add auto_start setting (opt-in, default off, NFR-RES-4) 2026-07-14 16:21:26 -05:00
iamdoubz 2fa3ea6824 feat(app): add tauri-plugin-autostart dependency
Pulls in the official launch-at-login plugin (per-user HKCU\Run, no admin).
Regenerated ACL/schema manifests reflect the new plugin.
2026-07-14 16:21:25 -05:00
iamdoubz 14300fccee Remove temp changelog files 2026-07-14 15:54:41 -05:00
iamdoubz 2c8958da01 Bump version 0.6.0 2026-07-14 15:48:50 -05:00
iamdoubz f2c945838c docs(plan): mark dual-channel capture implemented (FR-SPK) 2026-07-14 12:22:59 -05:00
iamdoubz 0e0f3a09f1 docs(architecture): per-stream diarization via split channels in stop data flow (FR-SPK) 2026-07-14 12:22:57 -05:00
iamdoubz 3d183fdd7c docs(data-model): audio.wav split layout + audio_layout column; drop mic_activity.json (FR-SPK) 2026-07-14 12:22:56 -05:00
iamdoubz 32bb4057ec docs(adr-0005): Phase 3.5 dual-channel capture supersedes the masked-mono timeline (FR-SPK) 2026-07-14 12:22:55 -05:00
iamdoubz 90a9cbd476 feat(recording): fold split stereo to mono on playback, dual-mono on export (FR-SPK)
fold_wav folds a WAV's channels to their average across N output channels.
serve_recording plays it as mono so a split (mic-left/loopback-right) recording
plays both sources in both ears; bundle export decrypts + folds to dual-mono so
the shared file is a normal stereo file (manifest records it as summed). Test
covers mono + dual-mono.
2026-07-14 12:21:12 -05:00
iamdoubz 5bd76b80e8 feat(diarization): channel-based split attribution; retire masked-mono path (FR-SPK)
stop + reprocess now, for a split recording, diarize the right (loopback)
channel into Speaker N and take You from left-channel (mic) VAD тАФ read back from
the file so both agree. Fixes reprocess collapsing to one speaker. Summed
recordings keep the whole-signal + voiceprint fallback. Removes MicTimelineFile,
phase3_attribute, mask_ranges, mic_activity.json (retirement note retained).
2026-07-14 12:16:03 -05:00
iamdoubz a1d71d1c73 refactor(recording): drop RecordingSession.mic_activity (retired) (FR-SPK) 2026-07-14 12:16:02 -05:00
iamdoubz e287fa5ebe feat(audio): read_wav_channel_16k + vad_spans; retire MicActivity (FR-SPK)
Add a channel-selective 16k reader (share the decode with read_wav_mono_16k via
read_wav_16k) and vad_spans (100ms energy-windowed You detection). Remove the
MicActivity live timeline + its capture wiring тАФ the split layout makes the
mic/far-side separation intrinsic to the file, so You is recomputed from the
left channel instead.
2026-07-14 12:16:01 -05:00
iamdoubz 534f5ca350 feat(commands): mark recordings split/summed at stop from the session layout (FR-SPK) 2026-07-14 12:04:46 -05:00
iamdoubz 1e99c4c747 feat(recording): RecordingSession.audio_layout (split when mic on) (FR-SPK) 2026-07-14 12:04:45 -05:00
iamdoubz 5e5f61f3b7 feat(audio): write_wav_split тАФ stereo mic-left/loopback-right capture (FR-SPK)
New split writer + a `split` flag through the capture path: when the mic is on,
audio.wav is recorded 2ch with the mic on the left channel and the loopback
(downmixed) on the right, keeping the two sources separate in the file. Same
byte size as today's summed stereo. Transcription still downmixes L+R (= the
summed signal). Unit test covers L=mic / R=loopback.
2026-07-14 12:04:44 -05:00
iamdoubz dec9251e7b feat(commands): set audio_layout at each finalize/export site (summed for now) (FR-SPK) 2026-07-14 11:58:25 -05:00
iamdoubz d4e2ab494c feat(models): MeetingBundle.audio_layout (serde default) for portable layout (FR-SPK) 2026-07-14 11:58:23 -05:00
iamdoubz 845903a772 feat(storage): thread audio_layout through FinalizeMeeting + Meeting + get_meeting (FR-SPK) 2026-07-14 11:58:22 -05:00
iamdoubz 998de62df2 feat(storage): migration 0009 тАФ meetings.audio_layout column (FR-SPK/FR-CAP) 2026-07-14 11:58:21 -05:00
iamdoubz e449c194ef docs(plan): draft dual-channel (mic-L/loopback-R) capture plan + ADR-0005 amendment for review (FR-SPK) 2026-07-14 11:28:16 -05:00
iamdoubz 6cea767dec fix(audio): drain mic device-paced during silence to stop playback skips/ticks (FR-CAP-7)
The capture-silence fix metered the mic pull by wall-clock, but the mic fills
the bridge at its own device clock. Over a long silent stretch they drifted, the
bridge backed up to its 0.5s cap and dropped oldest samples -> the recording
skipped/'fast-forwarded' late in the gap. And a 10ms idle threshold punched
silence into the loopback on brief inter-packet stalls -> audible ticks. Now:
drain the whole bridge (device-paced, no overflow) and only synthesize after a
sustained 150ms gap.
2026-07-14 11:10:07 -05:00
iamdoubz 60ae97c771 docs(plan): mark Phase 3 slices aтАУd landed (FR-SPK) 2026-07-14 00:53:07 -05:00
iamdoubz d8587b8e93 docs(architecture): note per-stream diarization in the stop data flow (FR-SPK) 2026-07-14 00:53:06 -05:00
iamdoubz 695a12dc2d docs(data-model): add mic_activity.json to the meeting dir list (FR-SPK) 2026-07-14 00:53:05 -05:00
iamdoubz 6385e38b33 docs(adr-0005): amend with Phase 3 per-stream 'You' attribution (FR-SPK) 2026-07-14 00:53:04 -05:00
iamdoubz 0b5a85f461 feat(diarization): persist mic_activity.json; reprocess uses the Phase 3 timeline (FR-SPK)
Factor the Phase 3 attribution (you_spans -> mask -> diarize far side -> merge +
name) into a shared phase3_attribute() used by both stop and reprocess. stop now
writes mic_activity.json (the 'You' spans) next to a retained audio.wav
(plaintext timing metadata, local-only, lives/dies with audio.wav); reprocess
prefers that timeline over the voiceprint fallback so re-transcription keeps
correct per-stream 'You'/'Speaker' attribution.
2026-07-14 00:51:41 -05:00
iamdoubz fd9311c482 feat(diarization): Phase 3 stop attribution тАФ mic timeline is 'You', diarize far side masked (FR-SPK)
stop_recording now, when the mic is on, takes 'You' spans straight from the
MicActivity timeline and diarizes the recording with those ranges zeroed, so
sherpa only ever clusters the far side into Speaker N. Merges the span lists and
names them via the existing voiceprint map (You + Speaker 2..). Falls back to
the whole-signal pass + voiceprint match when the mic is off. Adds mask_ranges
helper + tests.
2026-07-14 00:04:58 -05:00
iamdoubz 39294adcbc feat(audio): populate MicActivity timeline from the loopback writer (FR-SPK) 2026-07-14 00:04:57 -05:00
iamdoubz fee18ebc59 refactor(diarization): expose first_appearance_order + build_name_map to crate (FR-SPK) 2026-07-14 00:04:56 -05:00
iamdoubz 51f5ff085c feat(diarization): Diarizer::diarize_samples + short-input guard for masked far-side pass (FR-SPK) 2026-07-14 00:04:55 -05:00
iamdoubz 6d4204fb22 feat(recording): RecordingSession.mic_activity for Phase 3 attribution (FR-SPK) 2026-07-14 00:04:54 -05:00
iamdoubz 185489fa18 feat(audio): MicActivity timeline for Phase 3 per-stream 'You' attribution (FR-SPK)
Records, per 100ms of audio.wav frame time, when the mic (the user) was
speech-level тАФ captured in the loopback writer where mic and loopback exist
separately in the recording's own timebase. you_spans() collapses active
windows into merged 'You' ranges. The far side will be diarized with these
ranges masked out, so clustering never sees the mic. Struct + span logic land
first (tested); capture wiring + stop attribution follow.
2026-07-13 21:15:49 -05:00
iamdoubz 41c2c79797 docs(plan): finalize Phase 3 design тАФ mic=You, diarize loopback-masked (FR-SPK) 2026-07-13 21:15:48 -05:00
iamdoubz 6dd6c3c69f fix(audio): capture mic during system-audio silence (FR-CAP-7)
WASAPI loopback delivers no packets when the render endpoint is idle, so the
recording only advanced while system audio played. The bridged mic (the user
talking over silence) was lost to MicBridge overflow, and audio.wav lost those
stretches entirely тАФ corrupting diarization and dropping the user's solo
remarks in real meetings. When loopback is idle, synthesize silence for the
elapsed gap and fold the mic in, keeping audio.wav continuous. Loopback +
mic-enabled only.
2026-07-13 20:58:23 -05:00
iamdoubz 9468781218 fix(audio): gate voiceprint capture on mic energy so it's real speech (FR-SPK)
VoiceSample kept the first N samples unconditionally, so a user silent for the
first 30s got a voiceprint of room noise/a cough. That noise embedding
spuriously matched a cluster and mislabeled every speaker 'You', even during
silence. Skip sub-threshold (VOICE_ENERGY_FLOOR) chunks so the window tracks
actual speech; until ~1s of real voice accumulates, the match returns empty
(no premature 'You').
2026-07-13 20:35:25 -05:00
iamdoubz b39352dbff fix(diarization): clear stale speaker rows on reprocess so Participants pane matches (FR-SPK)
Reprocess re-diarizes and upserts the new labels but never deleted the old
ones, so get_meeting (Participants pane) kept returning the discarded run's
labels (e.g. 83) while the transcript showed the new 5. Clear the meeting's
speaker rows before finalize re-inserts the fresh set.
2026-07-13 20:09:03 -05:00
iamdoubz 0d263637df feat(storage): Store::clear_speakers to drop a meeting's speaker rows (FR-SPK) 2026-07-13 20:09:01 -05:00
iamdoubz bb6e222101 docs(plan): Phase 3 per-stream attribution design sketch + ADR-0005 review (FR-SPK) 2026-07-13 19:32:15 -05:00
iamdoubz 85ed3b5587 docs(api-contracts): note transcript://segment re-emit + live diarization://updated (FR-SPK) 2026-07-13 19:25:31 -05:00
iamdoubz 68d692fbe8 fix(transcript): resolve live speaker names incl 'You' in the recording view (FR-SPK) 2026-07-13 19:25:30 -05:00
iamdoubz fa664e5147 feat(recording): live speakers state from diarization://updated (FR-SPK) 2026-07-13 19:25:29 -05:00
iamdoubz b5c99621b5 feat(api): onDiarizationUpdated event listener for live speaker updates (FR-SPK) 2026-07-13 19:25:28 -05:00
iamdoubz db52b9e812 feat(diarization): live tick re-emits relabeled segments + live 'You' (FR-SPK)
Phase 2 backend. The 15s provisional pass now (1) runs the mic voiceprint
match every tick and merges 'You'/'Speaker N' into the session name map without
overwriting user-set names, and (2) re-emits every committed segment whose
speaker changed via transcript://segment (replace-by-id) so the live transcript
reflects the refined labels instead of a frozen 'S1'.
2026-07-13 19:25:27 -05:00
iamdoubz 319e7a31cc docs(data-model): add voiceprint.wav to the meeting dir file list (FR-SPK) 2026-07-13 19:19:06 -05:00
iamdoubz 25011d0f27 fix(diarization): reprocess re-diarizes + persist mic voiceprint.wav (FR-SPK)
Phase 1. stop_recording writes voiceprint.wav next to a retained audio.wav
(ADR-0009 gated, sealed at rest with the vault); reprocess_transcript now
re-diarizes the fresh transcript and rebuilds the speaker name map from the
voiceprint instead of reusing the stale original-run names, fixing the
collapse to a single 'You' speaker on re-transcription.
2026-07-13 19:19:06 -05:00
iamdoubz 7733e86cfd feat(audio): write_wav_mono_16k helper for persisting the mic voiceprint (FR-SPK) 2026-07-13 19:19:05 -05:00
iamdoubz a481f5dcbf fix(diarization): tame cluster explosion with threshold 0.7 + 700ms min span (FR-SPK)
Phase 0 of the speaker-accuracy plan. sherpa fast-clustering threshold 0.5
over summed mic+loopback mono exploded a 2-speaker call into 83 clusters;
raise to 0.7 (larger threshold -> fewer clusters) and drop sub-700ms spans
whose embeddings are unstable and only churn labels in assign_by_overlap.
2026-07-13 19:12:04 -05:00
iamdoubz bd6519b65e docs(plan): phased fix plan for diarization speaker accuracy (FR-SPK) 2026-07-13 18:40:44 -05:00
iamdoubz 4f3af98bfa Add Clippy marketing image 2026-07-13 16:15:18 -05:00
iamdoubz 77f9a8b518 docs(readme): bump status to v0.5.2 and add UI-localization comparison row 2026-07-13 16:07:12 -05:00
iamdoubz 2a7e6229f4 docs(readme): list the 20 available interface languages and RTL support 2026-07-13 16:05:12 -05:00
iamdoubz 64e7baad66 docs(release): v0.5.2 release notes with SHA-256 checksums 2026-07-13 16:00:00 -05:00
iamdoubz 804d451bfa chore(release): bump version to 0.5.2 2026-07-13 15:59:58 -05:00
iamdoubz a29e60b47d fix(storage): show native error dialog on startup DB failure instead of panic
A migration-checksum mismatch (an edited-after-shipping migration) made
SqliteStore::connect() return Err, which .expect() turned into a panic=abort
fast-fail: the window never opened and the OS reported only a 0xc0000409 crash.
Replace the expect with a blocking MessageBoxW that names the failure and points
at wa.db, then exit(1) cleanly.
2026-07-13 15:52:55 -05:00
iamdoubz 969ed6022e build(deps): enable Win32_UI_WindowsAndMessaging for startup error dialog 2026-07-13 15:52:54 -05:00
iamdoubz 226593b49d Merge pull request 'Bump version 0.5.0' (#22) from bump_version_0.5.0 into main
Reviewed-on: #22
2026-07-13 08:46:24 -05:00
iamdoubz dc1d352e09 docs(release): v0.5.1 release notes with SHA-256 checksums 2026-07-12 22:47:48 -05:00
iamdoubz 05e003ab19 chore(release): bump version to 0.5.1 2026-07-12 22:41:45 -05:00
iamdoubz d636eec848 feat(i18n): register 19 locales and flip document dir for RTL (ar, ur) 2026-07-12 20:12:45 -05:00
iamdoubz 1aeaae3550 feat(i18n): add Urdu (ur) translation 2026-07-12 20:12:44 -05:00
iamdoubz 84774c75cb feat(i18n): add Arabic (ar) translation 2026-07-12 20:07:27 -05:00
iamdoubz bca790571c feat(i18n): add Sinhala (si) translation 2026-07-12 20:04:27 -05:00
iamdoubz bfe7005368 feat(i18n): add Tamil (ta) translation 2026-07-12 16:27:57 -05:00
iamdoubz e01d61ae0c feat(i18n): add Bengali (bn) translation 2026-07-12 16:21:00 -05:00
iamdoubz 8947eb2f39 feat(i18n): add Hindi (hi) translation 2026-07-12 16:17:15 -05:00
iamdoubz d2ef12269b feat(i18n): add Korean (ko) translation 2026-07-12 16:13:53 -05:00
iamdoubz bab1eb57b1 feat(i18n): add Mandarin Chinese Simplified (zh-CN) translation 2026-07-12 16:11:17 -05:00
iamdoubz 76bb38cbe6 feat(i18n): add Finnish (fi) translation 2026-07-12 16:09:01 -05:00
iamdoubz 7570d810e4 feat(i18n): add Norwegian Bokmal (nb) translation 2026-07-12 16:05:22 -05:00
iamdoubz a090cc4acd feat(i18n): add Swedish (sv) translation 2026-07-12 16:02:27 -05:00
iamdoubz 7f0a718210 feat(i18n): add Russian (ru) translation 2026-07-12 15:59:42 -05:00
iamdoubz 96e8723cb9 feat(i18n): add Portuguese (Brazil, pt-BR) translation 2026-07-12 15:57:11 -05:00
iamdoubz fce3a6b96c feat(i18n): add Portuguese (Portugal, pt-PT) translation 2026-07-12 15:54:48 -05:00
iamdoubz 0f8288f90f feat(i18n): add French (Canada, fr-CA) translation 2026-07-12 15:52:25 -05:00
iamdoubz 8ffc015e29 feat(i18n): add French (France, fr-FR) translation 2026-07-12 15:49:42 -05:00
iamdoubz b8c58d6ab8 feat(i18n): add Spanish (Mexico, es-MX) translation 2026-07-12 15:47:07 -05:00
iamdoubz 51a03b0ec4 feat(i18n): add Spanish (Spain, es-ES) translation 2026-07-12 15:44:43 -05:00
iamdoubz 96851157dd feat(i18n): add German (de) translation 2026-07-12 15:42:14 -05:00
iamdoubz f05cec879f docs: add Build & release section for the unified installer 2026-07-12 14:32:46 -05:00
iamdoubz bf67be8764 docs(release): v0.5.0 release notes with SHA-256 checksums 2026-07-12 13:14:46 -05:00
iamdoubz c1de97938e chore(release): bump version to 0.5.0 2026-07-12 13:08:56 -05:00
iamdoubz 5f300ee177 Merge pull request 'Feature chore bug 007' (#21) from feature_chore_bug_007 into main
Reviewed-on: #21
2026-07-12 12:59:04 -05:00
iamdoubz 52d3fcbf3b Instruction for how to activate whispassist memory 2026-07-12 12:56:17 -05:00
iamdoubz 2181aa5b0e docs(i18n): mark Batch F done тАФ migration complete 2026-07-12 12:54:10 -05:00
iamdoubz 4e87cab3ad feat(i18n): convert LevelMeter to t() (Batch F) 2026-07-12 12:53:26 -05:00
iamdoubz 84ab88b565 feat(i18n): convert TagChip to t() (Batch F) 2026-07-12 12:53:25 -05:00
iamdoubz ae13944563 feat(i18n): convert ImportMeeting to t() (Batch F) 2026-07-12 12:53:23 -05:00
iamdoubz 8fc5d72576 feat(i18n): convert ThemeToggle to t() (Batch F) 2026-07-12 12:53:22 -05:00
iamdoubz 681933c8cf feat(i18n): convert HostedAiBanner to t() (Batch F) 2026-07-12 12:53:21 -05:00
iamdoubz cfe73e7d80 feat(i18n): convert ConsentNotice to t() (Batch F) 2026-07-12 12:53:20 -05:00
iamdoubz 505387ba49 feat(i18n): add tagchip.remove key (Batch F) 2026-07-12 12:53:19 -05:00
iamdoubz fa8ce62f63 feat(i18n): component keys for Batch F (consent, hosted-ai, theme, import, chip, meter) 2026-07-12 12:51:03 -05:00
iamdoubz 7527da92c5 docs(i18n): mark Batch E (Settings) done, log the batch 2026-07-12 12:41:14 -05:00
iamdoubz ef933c3786 feat(i18n): convert Settings dialog title + header to t() (Batch E) 2026-07-12 12:40:31 -05:00
iamdoubz f6f336d79f feat(i18n): Settings dialog-title key (Batch E) 2026-07-12 12:40:30 -05:00
iamdoubz 1f25833e74 feat(i18n): convert Settings about section to t() (Batch E) 2026-07-12 12:39:33 -05:00
iamdoubz 0d8bcf3b17 feat(i18n): Settings about-section keys (Batch E) 2026-07-12 12:39:32 -05:00
iamdoubz 27a1f98051 feat(i18n): convert Settings privacy section to t() (Batch E) 2026-07-12 12:38:53 -05:00
iamdoubz 0ecd6db12e feat(i18n): Settings privacy-section keys (Batch E) 2026-07-12 12:38:52 -05:00
iamdoubz d2f35c64b1 feat(i18n): convert Settings MCP section to t() (Batch E) 2026-07-12 12:37:22 -05:00
iamdoubz 5967fd6997 feat(i18n): Settings MCP-section keys (Batch E) 2026-07-12 12:37:21 -05:00
iamdoubz a0ad1564a8 feat(i18n): convert Settings AI section to t() (Batch E) 2026-07-12 12:35:42 -05:00
iamdoubz 377a91d1b8 feat(i18n): Settings AI-section keys (Batch E) 2026-07-12 12:35:41 -05:00
iamdoubz 7796c5b62d feat(i18n): convert Settings sync section to t() (Batch E) 2026-07-12 12:32:54 -05:00
iamdoubz 698fb26b0a feat(i18n): Settings sync-section keys + shared Close button (Batch E) 2026-07-12 12:32:53 -05:00
iamdoubz 802e30e9c6 feat(i18n): convert Settings calendar section to t() (Batch E) 2026-07-12 12:30:51 -05:00
iamdoubz f1683af01c feat(i18n): Settings calendar-section keys (Batch E) 2026-07-12 12:30:50 -05:00
iamdoubz 12388b8bd9 feat(i18n): convert Settings storage section to t() (Batch E) 2026-07-12 12:29:25 -05:00
iamdoubz b295e1ee83 feat(i18n): Settings storage-section keys (Batch E) 2026-07-12 12:29:24 -05:00
iamdoubz c98c14bbc7 feat(i18n): convert Settings hardware section to t() (Batch E) 2026-07-12 12:27:48 -05:00
iamdoubz c0b7587298 feat(i18n): Settings hardware-section keys (Batch E) 2026-07-12 12:27:47 -05:00
iamdoubz d5f0d028ac feat(i18n): convert Settings recording section to t() (Batch E) 2026-07-12 12:24:31 -05:00
iamdoubz 2f925de9a5 feat(i18n): Settings recording-section keys (Batch E) 2026-07-12 12:24:29 -05:00
iamdoubz 486767587e docs(i18n): mark Batch C + D done, log the batch 2026-07-12 11:18:19 -05:00
iamdoubz 88969019b3 feat(i18n): convert SummaryPanel chrome to t() (Batch D) 2026-07-12 11:17:45 -05:00
iamdoubz 2b13de4ef2 feat(i18n): add summary panel keys to en.json (Batch D) 2026-07-12 11:14:01 -05:00
iamdoubz a112b81d6e feat(i18n): convert TranscriptNotes chrome to t() (Batch C) 2026-07-12 11:12:01 -05:00
iamdoubz 1da127a61c feat(i18n): add transcript + notes keys to en.json (Batch C) 2026-07-12 11:09:05 -05:00
iamdoubz 57d3aca386 docs(i18n): mark Batch A + B done, log the batch 2026-07-12 11:04:06 -05:00
iamdoubz aac88bfbc5 feat(i18n): convert App shell chrome to t() (Batch A) 2026-07-12 11:03:42 -05:00
iamdoubz e5fdb2baea feat(i18n): convert MeetingsList chrome to t() (Batch B, FR-*) 2026-07-12 11:03:41 -05:00
iamdoubz fc89f76ac8 feat(i18n): add app shell + meetings-list keys to en.json (Batch A/B) 2026-07-12 11:00:18 -05:00
iamdoubz 7b8a651730 docs(i18n): clarify chrome-only scope тАФ never translate user content 2026-07-12 10:57:52 -05:00
iamdoubz e1e2808d9c docs(i18n): migration tracking doc for incremental string conversion 2026-07-12 10:52:18 -05:00
iamdoubz 77234e697d docs(api): document 'obsidian' export format (FR-STORE-4) 2026-07-12 09:38:09 -05:00
iamdoubz f5c521df0d style(settings): prettier formatting for i18n selector 2026-07-12 09:37:45 -05:00
iamdoubz 3f04ec6167 fix(playback): silence a11y warning on transcript scroll-intent handlers 2026-07-12 09:36:14 -05:00
iamdoubz 030b06329a feat(i18n): display-language selector in Settings + t() on nav and transcription-language strings 2026-07-12 09:35:22 -05:00
iamdoubz a7af623753 feat(i18n): reactive t() store with localStorage locale, English fallback 2026-07-12 09:34:14 -05:00
iamdoubz b98c258c13 feat(i18n): English translation baseline (en.json) 2026-07-12 09:33:59 -05:00
iamdoubz 8ca6cf4f89 feat(export): add 'obsidian' to exportMeeting format union (FR-STORE-4) 2026-07-12 09:32:49 -05:00
iamdoubz dbe845e923 feat(export): 'obsidian' format тАФ one vault note (notes+summary+action items+transcript, no audio) (FR-STORE-4) 2026-07-12 09:32:23 -05:00
iamdoubz d5aef2966f feat(playback): click transcript to seek + highlight/scroll active line; Obsidian export button (FR-REC-5, FR-STORE-4) 2026-07-12 09:31:14 -05:00
iamdoubz 2389fb05a5 feat(playback): seek audio on transcript click, push position on timeupdate (FR-REC-5) 2026-07-12 09:28:21 -05:00
iamdoubz 96d02cc220 feat(playback): shared player store to sync transcript<->audio (FR-REC-5) 2026-07-12 09:27:46 -05:00
iamdoubz c3da6cf0f3 style(settings): prettier formatting for export/import copy 2026-07-11 22:48:55 -05:00
iamdoubz 5564c9c9a6 docs(data-model): document portable bundle export/import format 2026-07-11 22:48:03 -05:00
iamdoubz d25c297257 docs(api): document action_items on Meeting, bundle export shape, import_meeting_bundle, auto-resync 2026-07-11 22:48:02 -05:00
iamdoubz 23cfca7ea2 feat(export): Export/Import section in Settings тЖТ Storage (FR-STORE-4) 2026-07-11 22:43:51 -05:00
iamdoubz 6129866ac5 feat(export): importMeetingBundle api binding 2026-07-11 22:42:21 -05:00
iamdoubz 2e57ccfab5 feat(export): register import_meeting_bundle command 2026-07-11 22:42:08 -05:00
iamdoubz 4dff7a58b5 feat(export): portable bundle (meeting.json + summary.json) + import_meeting_bundle (FR-STORE-4) 2026-07-11 22:42:07 -05:00
iamdoubz 25204c0235 feat(export): set_meeting_times store method to preserve dates on import 2026-07-11 22:40:00 -05:00
iamdoubz 2b2e120f12 feat(export): MeetingBundle manifest for portable meeting export/import 2026-07-11 22:39:38 -05:00
iamdoubz 70eb182eb4 feat(sync): auto-resync artifacts on edit (notes/summary/transcript/tags/action items) (FR-SYNC-5) 2026-07-11 22:37:51 -05:00
iamdoubz 836547596b feat(action-items): manual add/delete/edit action items in the summary panel 2026-07-11 22:34:12 -05:00
iamdoubz 58215076cb feat(action-items): add action_items to Meeting type 2026-07-11 22:32:27 -05:00
iamdoubz 862ab86860 feat(action-items): cancel reminders for deleted items on confirm 2026-07-11 22:32:15 -05:00
iamdoubz a0faaa94b4 feat(action-items): table-backed action_items on Meeting; reconcile deletes (FR-LLM-3) 2026-07-11 22:32:14 -05:00
iamdoubz 9c74f84c5b feat(transcript): show per-segment timestamps in live and finalized views 2026-07-11 22:29:43 -05:00
iamdoubz 57cd46be46 Merge pull request 'Feature chore bug 006' (#20) from feature_chore_bug_006 into main
Reviewed-on: #20
2026-07-11 09:26:39 -05:00
iamdoubz 490a960676 Add optional dependencies section 2026-07-11 09:23:36 -05:00
iamdoubz 379bdf532f chore: sync Cargo.lock to version 0.4.0 2026-07-11 09:10:17 -05:00
iamdoubz 95f07e1dba fix(transcription): scope streamer-test closures so out isn't borrow-locked 2026-07-11 09:08:20 -05:00
iamdoubz 931a2b78b4 fix(media): blank doc line before the tool list (clippy doc_lazy_continuation) 2026-07-11 09:08:19 -05:00
iamdoubz 89254bc070 fix(audio): update audio_level test calls for the new mic arg 2026-07-11 09:08:18 -05:00
iamdoubz 22a37c5f4e docs: README for v0.4.0 тАФ new features + Granola/Meetily/WA comparison (Granola MCP) 2026-07-11 08:58:06 -05:00
iamdoubz cecfe20eec chore: bump tauri config version to 0.4.0 2026-07-11 08:58:05 -05:00
iamdoubz 6fbf967fdb chore: bump crate version to 0.4.0 2026-07-11 08:58:04 -05:00
iamdoubz 9f3c1b65d0 chore: bump version to 0.4.0 2026-07-11 08:58:03 -05:00
iamdoubz a4b2b9a292 feat(transcription): drive live worker with StreamTuning; store only committed segments 2026-07-11 08:42:25 -05:00
iamdoubz 12e23143f6 feat(transcription): streaming growing-window + interim + commit-on-pause (FR-TRX-2) 2026-07-11 08:42:24 -05:00
iamdoubz fd556c57a1 feat(import): import_media тАФ file/URL to a transcribed, diarized meeting; auto_record default 2026-07-11 00:38:19 -05:00
iamdoubz 1ea8011dc8 feat: register media module + import_media command 2026-07-11 00:38:18 -05:00
iamdoubz 002f8caa2b feat(media): transcode local files + URLs to 16kHz wav via ffmpeg/yt-dlp 2026-07-11 00:38:16 -05:00
iamdoubz d72509df5f feat: add auto_record_calendar setting (FR-CAL) 2026-07-11 00:38:15 -05:00
iamdoubz 7ef45bb315 feat(ui): Add meeting button + import modal wiring in the header 2026-07-11 00:37:53 -05:00
iamdoubz bd251b991a feat(ui): add-a-meeting modal for importing a file or URL 2026-07-11 00:37:51 -05:00
iamdoubz dfaeb7c873 feat(ui): import_media command in the typed api 2026-07-11 00:37:50 -05:00
iamdoubz 6b164cd02a feat(ui): auto-start-on-calendar-event toggle in Settings > Recording 2026-07-11 00:31:34 -05:00
iamdoubz 21101054a4 feat(ui): arm a one-shot auto-record timer for the next calendar event (FR-CAL) 2026-07-11 00:31:32 -05:00
iamdoubz 16200a130e feat(ui): let recording.start attach a calendar event id 2026-07-11 00:31:31 -05:00
iamdoubz 86f115642c feat(ui): default auto_record_calendar off in the settings store 2026-07-11 00:31:29 -05:00
iamdoubz 1760d4ae24 feat(ui): add auto_record_calendar to the settings contract 2026-07-11 00:31:16 -05:00
iamdoubz 2681a5d8c8 feat(audio): forward mic/system flag on the recording://level event 2026-07-11 00:27:42 -05:00
iamdoubz 233d641041 feat(audio): emit tagged mic + system level updates from capture (FR-CAP-5/7) 2026-07-11 00:27:41 -05:00
iamdoubz 29589663b2 feat(ui): pass mic level + enabled state to the level meter 2026-07-11 00:27:06 -05:00
iamdoubz 28058e205d feat(ui): overlay mic level on the meter in the accent colour (FR-CAP-5/7) 2026-07-11 00:27:05 -05:00
iamdoubz 235129d0d6 feat(ui): track microphone level separately in the recording store 2026-07-11 00:27:03 -05:00
iamdoubz 59c2d70643 feat(ui): tag recording level events with mic vs system source (FR-CAP-7) 2026-07-11 00:27:02 -05:00
iamdoubz e082f99f32 feat(ui): speaker diarization download cards in Settings > Hardware 2026-07-11 00:23:19 -05:00
iamdoubz 5260a4240b feat(ui): load/download/remove diarization models in the settings store 2026-07-11 00:23:18 -05:00
iamdoubz bf644ea0be feat(ui): expose diarization models over the typed api (FR-MODEL-1) 2026-07-11 00:23:17 -05:00
iamdoubz 13fb9984da feat(ui): model download cards + PST import range + calendar cleanup
Settings > Hardware: the Models list is now a bordered card row per
model with active/installed badges and a real progress bar for
downloads (was plain text links + a bare "downloading NN%" string),
using the app's existing lucide icon set.

Settings > Calendar: an Import range picker (30/90/180/365 days/all
time, persisted, applied to both a manual Import and the pst_auto_sync
startup re-import) and a Clean up section (delete all / older-than-N
threshold, confirm() gated, always reports how many events were kept
because they're linked to a meeting) -- fixes the 14,686-row bloat from
importing a mailbox's entire unbounded history.
2026-07-11 00:13:02 -05:00
iamdoubz 99d50583cf feat(ui): live notes pane, click-to-annotate transcript, resizable split
Granola-style redesign: the live-recording view gains a Notes pane
(bound to recording.notesText) alongside the transcript, and each
transcript line is clickable to attach/edit a note for that moment
(recording.segmentNotes, badge on annotated lines).

Also, for both the live and finalized-meeting layouts: the
transcript/notes split is now drag-resizable and each side
independently collapsible (Splitter + layout store), and the
post-finalize Notes pane defaults to Preview (rendered) instead of raw
Editor mode, since the merged document is now properly sectioned rather
than a single-speaker blob.
2026-07-11 00:13:01 -05:00
iamdoubz 0027becd10 style(ui): polish Upload now / Save action items / provider select
Upload now and Save action items now use the existing .primary
solid-accent button style (with icons) instead of unstyled native
buttons; the AI provider dropdown gets real border/background/hover
states matching the app's other selects.
2026-07-11 00:12:45 -05:00
iamdoubz e3c80b565c feat(ui): resizable/hideable meetings-list and summary panes
Left (meetings list) and right (summary) panes gain drag-to-resize via
Splitter and a collapse/expand toggle (thin rail + panel icon), sizes
and collapsed state persisted via the layout store.
2026-07-11 00:12:44 -05:00
iamdoubz 2a637e78ad feat(ui): default pst_import_range_days in the settings fallback 2026-07-11 00:12:29 -05:00
iamdoubz 80ad2a5f76 feat(ui): calendar store passes rangeDays through to importPst 2026-07-11 00:12:28 -05:00
iamdoubz 6622ae81ff feat(ui): recording store gains live notes state
notesText (debounced save, flushed on stop before finalize reads it),
segmentNotes Map keyed by anchor_ms, setNotesText/setSegmentNote.
2026-07-11 00:12:26 -05:00
iamdoubz 90b2d51d08 feat(ui): API bindings for live notes and calendar range/cleanup
updateLiveNotes/setSegmentNote; import_pst's rangeDays param;
cleanupCalendarEvents; pst_import_range_days on AppSettings.
2026-07-11 00:12:15 -05:00
iamdoubz 7081040475 feat(ui): layout store for resizable/hideable panes
Pane widths, collapsed flags, and the transcript/notes split ratio,
persisted to localStorage (pure client-side UI ephemera, not app data --
no Tauri settings round-trip needed).
2026-07-11 00:12:04 -05:00
iamdoubz 7dfcbdf247 feat(ui): draggable Splitter component for resizable panes
WAI-ARIA "window splitter" pattern (role=separator, keyboard-operable) --
reusable divider used by the resizable/hideable pane layout.
2026-07-11 00:12:01 -05:00
iamdoubz 864cd33a5f docs: document live notes commands and calendar range/cleanup commands
update_live_notes/set_segment_note; import_pst's new rangeDays param;
new cleanup_calendar_events command.
2026-07-11 00:11:53 -05:00
iamdoubz 94eafd4fae docs: document manual_notes.json (Granola-style live notes redesign) 2026-07-11 00:11:52 -05:00
iamdoubz 45ddc2bd3e feat(commands): live notes commands + calendar range/cleanup commands
Live notes (Granola-style redesign):
- update_live_notes/set_segment_note: live-session-only, mutate
  RecordingSession.manual_notes and write-through to manual_notes.json.
- stop_recording, reprocess_transcript, and resume_transcription now call
  MarkdownNotes::merge (loading manual_notes.json from disk for the
  latter two, which have no live RecordingSession) instead of the old
  transcript-only to_markdown, so re-transcription/crash-recovery never
  silently drops what the user typed.
- refresh_notes_and_notify no longer re-renders/overwrites notes.md on a
  post-finalize speaker rename/merge -- that used to clobber manual
  edits; it still updates the speakers table and emits the live-refresh
  event.

Calendar range/cleanup (14,686-row bloat fix):
- import_pst_core/import_pst take an optional range_days, converted to a
  from timestamp for PstSource.
- New cleanup_calendar_events command wrapping the new store method.
2026-07-11 00:11:45 -05:00
iamdoubz bb226dcb5e feat(core): wire up live notes and calendar range/cleanup commands
- RecordingSession.manual_notes; register update_live_notes/set_segment_note.
- Register cleanup_calendar_events; pass pst_import_range_days through to
  the pst_auto_sync startup re-import so it applies the same range as the
  last manual import, not the full mailbox history.
2026-07-11 00:11:32 -05:00
iamdoubz 98b183d2b0 feat(storage): cleanup_calendar_events, protects meeting-linked rows
CalendarCleanupResult + Store::cleanup_calendar_events(older_than_unix):
None deletes every unlinked event ("Delete all"), Some(cutoff) only those
starting before it. An event referenced by meetings.calendar_event_id is
always kept regardless of age or the "delete all" choice; participant
links cascade via the existing ON DELETE CASCADE FK.
2026-07-11 00:11:19 -05:00
iamdoubz 8828fca805 fix(calendar): honor from/to range on PST import
PstSource used to silently ignore CalImport.from/to (only Graph honored
a range) -- a long-lived mailbox has no natural upper bound on history,
so importing "everything" meant every recurring series expanded across
its full lifetime (up to 500 occurrences each) plus every one-off entry
the file ever held (e.g. a decade of Outlook's auto-generated yearly
holidays). Filters parsed events by starts_at after readpst parsing,
after collect_events -- doesn't need to touch how readpst is invoked or
how recurrence expansion works.
2026-07-11 00:11:02 -05:00
iamdoubz 9057b25e92 feat(notes): MarkdownNotes::merge folds manual notes into the transcript
Adds the Notes/Transcript-sectioned merge (freeform notes under ## Notes,
transcript dialogue under ## Transcript with per-moment annotations
placed as > note callouts right after the paragraph they anchor to,
unmatched anchors surfaced under "Other notes" rather than dropped) that
stop_recording now calls instead of the old transcript-only to_markdown.
2026-07-11 00:10:47 -05:00
iamdoubz 41b5553836 feat(core): add ManualNotes/SegmentNote types; pst_import_range_days setting
Two features:
- Granola-style live notes redesign: ManualNotes/SegmentNote are the IPC +
  on-disk shape of manual_notes.json (freeform notes typed during
  recording, plus per-moment annotations anchored by timestamp).
- Calendar bloat fix: Settings.pst_import_range_days persists how far back
  a PST import should go (None = full history), applied to both a manual
  import and the pst_auto_sync startup re-import.
2026-07-11 00:10:37 -05:00
iamdoubz 538db4372f feat(core): add manual_notes.json path helper
Granola-style live notes redesign: manual_notes_file(id) mirrors the
existing meeting_dir()-based helpers for the new per-meeting raw
user-notes artifact.
2026-07-11 00:10:24 -05:00
iamdoubz 8906f8c8ed chore(ui): disable the default WebView right-click menu
Back/Forward/Reload/Inspect doesn't belong in a native-feeling desktop
app. Disabled app-wide for now, per explicit instruction -- a
WhispAssist-specific context menu is a deferred follow-up, not decided
against.
2026-07-10 22:08:25 -05:00
iamdoubz 2f62e4b13a fix(calendar): run readpst headless instead of popping a console window
readpst.exe is a console-subsystem binary; WhispAssist has no console of
its own, so Windows created a brand new visible console window for it on
every PST import. CREATE_NO_WINDOW spawns it fully headless -- its
stdout/stderr are still captured normally via .output().
2026-07-10 22:08:19 -05:00
iamdoubz 3214bd2740 fix(calendar): stable content-based raw_uid for UID-less events; UTC dedup keys
parse_vevents used to leave raw_uid: None for any VEVENT with no UID
line, which the storage layer's unique index exempts from dedup -- so
that event duplicated on every re-import forever. Falls back to
content_uid (subject|organizer|starts_at|ends_at) so re-parsing the same
source resolves to the same key. Also fixes ymd_digits, the per-occurrence
dedup-key suffix for expanded recurring events, to format in UTC instead
of the machine's local timezone -- it could otherwise compute a different
date (and therefore a different key) for the same occurrence across a DST
transition or timezone change between imports.
2026-07-10 19:22:18 -05:00
iamdoubz e7b951cb90 fix(storage): collapse existing duplicate calendar_events rows
One-time cleanup for anyone already hit by the reimport-duplication bug:
groups existing rows by (source, raw_uid) -- backfilling a content-based
key first for rows that had none -- keeps the row a meeting is attached
to (or the first survivor otherwise), repoints any meeting pointing at a
row about to be removed, then deletes the rest. The unique index is
dropped and recreated around this since the backfill step can
momentarily produce rows that collide before they're deduped.
2026-07-10 19:22:10 -05:00
iamdoubz a341afc52f fix(commands): reindex search after generate_summary writes summary.json
summary.json is sealed straight to disk, not through a store write, so
the FTS index needs an explicit nudge to pick up newly generated
summaries (FR-SEARCH-1).
2026-07-10 19:22:04 -05:00
iamdoubz 83babc99cd fix(storage): reindex FTS on tags/summary; default a stable calendar raw_uid
Two bugs, one file:

- Search: reindex_fts now also indexes summary_text and tags_text, and
  runs on every set_tags call (it previously only fired from
  finalize_meeting/update_notes, so tag and summary changes never touched
  the index at all). Promoted from a private helper to a Store trait
  method so commands.rs can call it after summary.json is written
  (summary lives only on disk, not through a store write).

- Calendar dedup: import_calendar_events used to insert whatever raw_uid
  the caller passed, including None -- which the (source, raw_uid)
  unique index explicitly exempts from dedup, so any event without a
  captured UID duplicated on every re-import forever (the reported
  1762 -> 11,764 row bug). Defaults to a deterministic content-derived
  key (calendar::content_uid) when raw_uid is missing, and adds a
  regression test that re-importing the same batch repeatedly doesn't
  grow the table.
2026-07-10 19:21:59 -05:00
iamdoubz 4015b5132e feat(storage): add summary/tags columns to the FTS5 search index
meeting_fts only ever indexed title/transcript/notes -- search silently
never covered summary or tags despite FR-SEARCH-1 requiring it (bug
report: "search not working" for those). meeting_fts is a derived index
(never a source of truth), so drop+recreate is safe: backfill_fts
repopulates every meeting from scratch on next startup.
2026-07-10 19:21:48 -05:00
iamdoubz b5f5ef172c feat(commands): auto-label the mic speaker "You" at stop_recording
Wires VoiceSample creation into start_recording (mic-enabled path) and
runs the voiceprint match after the final diarization pass in
stop_recording: on a confident match, persists "You" and "Speaker 2",
"Speaker 3"... via the existing rename_speaker store path, skipping any
label the user already renamed live.
2026-07-10 19:05:28 -05:00
iamdoubz 69eedc9354 feat(core): track the mic voiceprint sample on RecordingSession
mic_voice_sample carries the VoiceSample handle from start_recording
through to stop_recording so the final diarization pass can run the
mic-speaker voiceprint match.
2026-07-10 19:05:23 -05:00
iamdoubz c9b5b91473 chore(diarization): register the new voiceprint module 2026-07-10 19:05:18 -05:00
iamdoubz dca797aaa3 feat(diarization): voiceprint match to identify the mic speaker cluster
Mic and system audio are already summed into one mono stream before
diarization runs, so clustering alone can't tell which cluster is the
user's own voice. match_mic_speaker compares a mic-only sample's speaker
embedding (same sherpa-onnx model diarization already uses) against each
diarized cluster's own audio and returns a label->name map: best match ->
"You", the rest -> "Speaker 2", "Speaker 3", etc. Returns empty (no
guessing) when the mic sample is too short or no cluster clears the
similarity threshold.
2026-07-10 19:05:14 -05:00
iamdoubz b532acbb3c feat(audio): capture a bounded mic-only sample for voiceprint matching
VoiceSample collects the first ~8s of raw 16kHz-mono mic audio during a
recording, then stops accepting once full. Feeds the mic-speaker
voiceprint match in diarization::voiceprint (bug: mic speaker mislabeled
S1/S2 instead of "You").
2026-07-10 19:05:07 -05:00
iamdoubz 469df7524f fix(transcription): drop whisper-hallucinated segments on silent windows 2026-07-10 18:51:08 -05:00
iamdoubz ec864c24f0 fix(transcription): medium quantized model only exists as q5_0 upstream 2026-07-10 18:51:07 -05:00
iamdoubz 9f1fc671da Merge pull request 'Feature chore bug 005' (#19) from feature_chore_bug_005 into main
Reviewed-on: #19
2026-07-07 22:02:41 -05:00
iamdoubz 3372dd7490 Bump new release version 0.3.0 2026-07-07 21:54:21 -05:00
iamdoubz a32acba157 chore: bump version to 0.3.0 2026-07-07 21:33:22 -05:00
iamdoubz d9f4fd0960 chore: bump version to 0.3.0 2026-07-07 21:33:21 -05:00
iamdoubz ff74e91f69 chore: bump version to 0.3.0 2026-07-07 21:33:19 -05:00
iamdoubz e557d7599f chore: bump version to 0.3.0 2026-07-07 21:33:18 -05:00
iamdoubz 678f8bee0f Add memanto memories 2026-07-07 21:29:57 -05:00
iamdoubz 1cf0be1259 test(commands): cover graph_calendar_enabled in the privacy egress allowlist test (M4.4) 2026-07-07 18:00:20 -05:00
iamdoubz b14cba2a49 docs(roadmap): mark M4 items shipped, M4.4 MS Graph calendar source complete 2026-07-07 17:58:34 -05:00
iamdoubz 7df76dff00 docs: MS Graph calendar commands/events + fix stale CalendarSource trait signature (T8.9, M4.4) 2026-07-07 17:58:09 -05:00
iamdoubz a48550da1e style(sync): cargo fmt 2026-07-07 17:57:13 -05:00
iamdoubz ce4c54c93b style(commands): cargo fmt 2026-07-07 17:57:12 -05:00
iamdoubz aa5ae127b3 style(calendar): cargo fmt 2026-07-07 17:57:10 -05:00
iamdoubz 3d90265e8f chore(lib): register MS Graph calendar commands (T8.9, M4.4) 2026-07-07 17:53:31 -05:00
iamdoubz d81b4726f9 feat(commands): MS Graph calendar link/import/disconnect + egress allowlist (T8.9, M4.4, FR-CAL-6) 2026-07-07 17:53:13 -05:00
iamdoubz ef6bb28f3e feat(models): add graph_calendar Settings fields (T8.9, M4.4) 2026-07-07 17:51:48 -05:00
iamdoubz d407be8bb0 feat(calendar): GraphSource for MS Graph calendar import (T8.9, M4.4, FR-CAL-6) 2026-07-07 17:51:40 -05:00
iamdoubz 8a80580851 chore(sync): expose resolve_access_token to crate for calendar reuse (M4.4) 2026-07-07 17:49:32 -05:00
iamdoubz 83cb30ef0e feat(calendar): add graph-calendar OAuth provider (T8.9, M4.4) 2026-07-07 17:49:25 -05:00
iamdoubz 6991c1d1bc feat(sync): Dropbox and Box upload targets (T9.10, M4.3)
DropboxTarget: path-addressed like OneDrive/WebDAV; create_folder_v2 creates
the whole intermediate path in one call; upload-session chunking above
LARGE_FILE_THRESHOLD (start/append_v2/finish).

BoxTarget: Box addresses items by numeric ID, not path, so ensure_dir/exists/
put all walk (and lazily create) the folder chain from root ("0") by listing
each level's children. Always uploads via Box's session API regardless of
file size rather than its simple multipart endpoint, since the session API
takes plain PUT bodies (consistent with every other target here) and needs
no new reqwest feature; Box computes and returns each part's digest, so no
local hashing is needed either.

Both follow the same documented ceiling as OneDriveTarget's put_chunked
(M4.1): the upload-session id lives only for one put() call, not persisted
across a durable-queue retry after a restart.

Tests: in-process std::net mock servers for both providers (same pattern as
the existing MockNextcloud/MockGraph), each exercising a real multi-chunk
upload end-to-end.
2026-07-07 14:46:43 -05:00
iamdoubz 40709b257d fix(transcription): resolve_language normalizes case-insensitive "en" match (T8.7, M4.2)
resolve_language(Some("EN"), false) was passed through unchanged instead of
being normalized to lowercase "en" - the English-only-model guard only fired
when the requested language *differed* from English, not when it matched with
different casing. Also fixes cargo fmt drift in mod.rs/npu.rs left over from
the M4.2 worktree (stopped mid-verification per session instruction before
running fmt).
2026-07-07 13:59:55 -05:00
iamdoubz 4e33e7d84c Merge branch 'm4-2-work' into feature_chore_bug_005 2026-07-07 13:33:14 -05:00
iamdoubz e7cc433aff fix(ui): compute activeModel via $derived instead of invalid {@const} placement (T8.7) 2026-07-07 13:32:26 -05:00
iamdoubz 2cc48c9d4d feat(sync): chunked/resumable upload for large recordings (T9.2, M4.1)
Recordings are now native-quality (~50-100 MB) and put() used to buffer
the whole file into memory with a single PUT/upload call; OneDrive/Graph
also caps a single PUT at 250 MB. This replaces that with disk-streamed,
chunked uploads on both sync backends (FR-SYNC-2/5):

- Both WebDavTarget::put and OneDriveTarget::put now stream the file off
  disk in fixed-size buffers (tokio::fs::File + BufReader) instead of
  tokio::fs::read()'ing it whole тАФ memory use is O(chunk), not O(file
  size), for every upload, large or small.
- Files at or below LARGE_FILE_THRESHOLD (8 MiB) still take a single
  streamed PUT; only large artifacts (in practice, .wav recordings) take
  the chunked path, so small transcript/notes/summary uploads are
  unaffected.
- WebDAV (Nextcloud/ownCloud): implements the chunking-v2 protocol тАФ
  MKCOL an upload collection keyed deterministically off the remote
  path's sha256 (stable across retries), PUT lexically-sortable parts,
  MOVE the virtual `.file` to assemble server-side. A retry PROPFINDs
  the collection first and skips any part already landed, so a
  partially-uploaded large file resumes instead of restarting at byte 0
  тАФ genuine mid-file resume, not just chunk-level retry. Generic WebDAV
  targets (Seafile, Synology, unbranded servers) have no equivalent
  server-side assembly endpoint, so they fall back to one streamed PUT
  above the threshold too тАФ memory-safe, just not chunk-resumable
  (documented ponytail simplification).
- OneDrive/Graph: implements the createUploadSession + Content-Range
  byte-range PUT flow (chunks capped at 10 MiB, a multiple of Graph's
  required 320 KiB granularity). A failed chunk is retried a few times
  within the same put() call. ponytail-documented ceiling: the session
  URL isn't persisted on the sync_jobs row, so a retry from a *later*
  pump cycle (e.g. after an app restart) starts a fresh session and
  re-uploads from byte 0 rather than resuming тАФ true cross-attempt
  resume would need a sync_jobs column, left as a follow-up.
- Progress is reported via the existing ProgressSink after each chunk,
  not just once at the end, so the UI sees steady movement on a large
  upload (FR-SYNC-11).
- OneDriveTarget's Graph URLs are now built from a `graph_base()` helper
  (overridable via WA_GRAPH_BASE_URL, unset in production) so the
  chunked session flow can be exercised against a local mock in tests.

Tests (all against small, in-process, dependency-free mock servers тАФ
std::net only, no new crates, same pattern as oauth::LoopbackRedirect):
- nextcloud_chunked_upload_assembles_and_resumes: multi-chunk upload
  assembles byte-for-byte, then a resumed upload with one chunk
  pre-seeded skips exactly that chunk.
- small_file_skips_chunking_even_on_a_chunking_capable_target: a small
  file never takes the chunked path.
- onedrive_chunked_upload_via_graph_session_mock: multi-chunk Graph
  upload session assembles byte-for-byte with per-chunk progress.
- parse_chunk_sizes_extracts_href_and_length_ignoring_namespace_prefix
  and chunk_name_is_zero_padded_and_lexically_sortable: pure unit tests
  for the PROPFIND-response scraper and chunk-naming scheme.

Skip-if-unchanged (SHA-256, in storage::upsert_sync_job) and the
exponential-backoff retry queue (storage::claim_due_sync_jobs) are
untouched тАФ this only changes how SyncTarget::put moves bytes.

Verified: cargo fmt clean; cargo clippy --features sync and --features
mcp both clean (-D warnings); cargo test --lib (default features:
audio, cpu-transcription, diarization, pst, sync, npu) 165 passed, 0
failed, 7 ignored (unrelated hardware-gated tests); cargo test --features
sync --lib: 18 passed, 0 failed, 1 ignored (webdav_round_trip, opt-in
live-server test per existing convention).
2026-07-07 13:32:09 -05:00
iamdoubz be025bbd6c chore(sync): enable tokio io-util/net for disk-streamed chunked upload (M4.1)
Needed for AsyncReadExt/AsyncSeekExt/BufReader used to stream .wav
recordings off disk in fixed-size chunks instead of buffering the
whole file in memory (T9.2 refinement, FR-SYNC-5).
2026-07-07 13:31:49 -05:00
iamdoubz 7ebd34ae08 fix(test): add missing language field to NewMeeting in smoke test (T8.7) 2026-07-07 13:14:38 -05:00
iamdoubz 353d582e96 feat(ui): show meeting language badge + reprocess language picker (T8.7, M4.2) 2026-07-07 13:12:51 -05:00
iamdoubz 7bff39271c feat(ui): thread optional language override through meetings.reprocess (T8.7, M4.2) 2026-07-07 13:12:46 -05:00
iamdoubz e6e995efe0 feat(ui): Transcription Language picker in Settings > Hardware (T8.7, M4.2) 2026-07-07 13:11:36 -05:00
iamdoubz 2cdae9b20d docs(api): document whisper language selection contract surface (T8.7, M4.2) 2026-07-07 13:10:06 -05:00
iamdoubz dc969df792 feat(ui): pass configured whisper_language when starting a recording (T8.7, M4.2) 2026-07-07 13:09:21 -05:00
iamdoubz 489271964f feat(ui): load language catalog + setWhisperLanguage in settings store (T8.7, M4.2) 2026-07-07 13:08:59 -05:00
iamdoubz 4c00496c09 feat(ui): typed API surface for whisper language selection (T8.7, M4.2) 2026-07-07 13:08:13 -05:00
iamdoubz 7490ff5db4 feat(transcription): wire language selection through recording/reprocess/recovery (T8.7, M4.2) 2026-07-07 13:06:00 -05:00
iamdoubz 34fff3f7de feat(core): register list_whisper_languages Tauri command (T8.7, M4.2) 2026-07-07 13:05:55 -05:00
iamdoubz 7675e0467e fix(diarization): set multilingual:false for the shared ModelInfo shape (T8.7) 2026-07-07 13:05:36 -05:00
iamdoubz 75ca3df0a0 feat(core): track resolved per-meeting language on RecordingSession (T8.7, M4.2) 2026-07-07 13:02:24 -05:00
iamdoubz 66d9eff2f9 feat(storage): persist requested language at meeting creation (T8.7, M4.2) 2026-07-07 13:02:14 -05:00
iamdoubz 1fb1da0946 feat(transcription): thread language param through OnnxTranscriber (ignored on NPU/DirectML, T8.7) 2026-07-07 13:01:17 -05:00
iamdoubz 496a8aa29b feat(transcription): wire whisper language param through Transcriber trait (T8.7, M4.2) 2026-07-07 13:00:52 -05:00
iamdoubz 7f88101a79 feat(transcription): whisper language catalog for Settings dropdown (T8.7, M4.2) 2026-07-07 13:00:48 -05:00
iamdoubz 37a6dd172d feat(transcription): multilingual model catalog entries (T8.7, M4.2) 2026-07-07 12:58:52 -05:00
iamdoubz 6bc0949e7e feat(transcription): add multilingual/language fields to ModelInfo+Settings (T8.7, M4.2) 2026-07-07 12:58:12 -05:00
iamdoubz e966181ab8 Merge branch 'worktree-agent-af5a53986b9441608' into feature_chore_bug_005
# Conflicts:
#	src-tauri/src/llm/mod.rs
#	src/lib/views/Settings.svelte
#	src/lib/views/SummaryPanel.svelte
2026-07-07 08:15:04 -05:00
iamdoubz 56e739c6fe docs(data-model): document hosted_ai_acknowledged in settings.json (M3.3)
Records the new Settings field alongside the rest of the llm block;
notes "openai" is documented but not yet wired (M3 scope is Anthropic).
2026-07-07 08:00:23 -05:00
iamdoubz a68407f059 style(ui): prettier reflow in HostedAiBanner copy 2026-07-07 08:00:08 -05:00
iamdoubz 67fffb0203 style(ui): prettier reflow in Settings.svelte AI banner copy 2026-07-07 08:00:06 -05:00
iamdoubz a211f88ad4 feat(ui): active-provider indicator + per-use quick switch in SummaryPanel (T10.3/M3.3)
Adds a provider row above Summary (dropdown mirroring Settings' AI
options + a local/hosted badge sourced from llm_status) so it's always
visible which provider a summary/tag generation will actually use.
Generate/Regenerate and the quick switch itself route hosted selections
(Anthropic, or "custom" once its endpoint resolves off-network) through
the same HostedAiBanner one-time acknowledgment gate as Settings тАФ the
generate-click gate is the one that actually matters, since selecting a
provider alone sends nothing; it's kept even though the quick-switch
already pre-empts Anthropic specifically.

Also drops a stale svelte-ignore comment (pre-existing, unrelated to
this change тАФ eslint-plugin-svelte no longer flags that element) that
was failing `eslint .` for this file.
2026-07-07 07:59:57 -05:00
iamdoubz b986c570f7 fix(llm): reset stale hosted endpoint when quick-switching away from Anthropic
apply_llm_provider_args now resets llm_endpoint to Ollama's local
default when switching to a non-Anthropic provider without an explicit
endpoint and the stored endpoint is still Anthropic's fixed hosted
URL. Prevents a future per-use provider switch (SummaryPanel) that
only sends `provider` from silently leaving llm_endpoint pointed at a
third party for a provider that has no business talking to it.
2026-07-07 07:56:09 -05:00
iamdoubz 0a597f86f0 feat(ui): Anthropic provider + hosted-AI banner in Settings AI section (T10.1/T10.3/M3.3)
Adds "Anthropic (Claude)" to the provider select, a write-only masked
API key field (show/hide toggle) shown in place of the endpoint field
for that provider (Anthropic's endpoint is fixed server-side), and a
persistent "leaves this device" note.

Save is gated behind HostedAiBanner the first time the user selects a
hosted provider (Anthropic, or "custom" once its endpoint resolves
off-network) and hasn't acknowledged hosted_ai_acknowledged yet;
accepting persists the ack and completes the save in one step.
2026-07-07 07:54:21 -05:00
iamdoubz 754f0f0b6a feat(ui): HostedAiBanner one-time hosted-AI notice component (T10.3/M3.3)
New component mirroring ConsentNotice.svelte's card/overlay pattern:
shown before first use of a hosted (non-local) provider, explains that
the transcript leaves the device to a third party, and offers
accept/cancel. Uses the existing design tokens (--accent, --warning,
--bg-elevated, --radius-lg/sm, --shadow-lg) so it matches the
recording-consent notice visually. Not yet wired into any view тАФ next
commits add it to Settings and SummaryPanel.
2026-07-07 07:52:25 -05:00
iamdoubz 3cdb0abb2d feat(llm): wire apiKey through setLlmProvider, add acknowledgeHostedAi (T10.2/T10.3)
settings.svelte.ts's setLlmProvider now accepts an optional apiKey
(forwarded straight to the set_llm_provider command, which is the only
place it gets stored тАФ the OS credential store) and DEFAULT_SETTINGS
gains hosted_ai_acknowledged. acknowledgeHostedAi() persists the
one-time hosted-AI banner acknowledgment the same way
acknowledgeConsent() does for recording consent.
2026-07-07 07:52:18 -05:00
iamdoubz 11b175dafa feat(llm): add hosted_ai_acknowledged to AppSettings (T10.3/M3.3)
Frontend counterpart of the new Settings field: the one-time "data
leaves your device" acknowledgment for hosted (non-local) AI providers.
2026-07-07 07:52:11 -05:00
iamdoubz d73af49742 feat(llm): set_llm_provider stores Anthropic key in credential store, extends egress allowlist (T10.2/M3.2)
set_llm_provider now accepts provider "anthropic": the apiKey argument
is written straight to crate::llm::credentials (OS credential store)
and never assigned into Settings, so it structurally cannot reach
settings.json/wa.db (FR-SEC-1). apply_llm_provider_args pulls the
settings-mutation logic into a small pure function specifically so
that guarantee is unit-testable without touching a real keyring.

llm_provider_from_settings gets an "anthropic" arm; api.anthropic.com
joins the settings-derived egress allowlist through the exact same
generic is_local()-based path privacy_self_check_json already uses for
every other provider тАФ no anthropic-specific allowlist code, and the
host only appears once the provider is actually selected (never
unconditionally).

Also fixes pre-existing cargo-fmt drift on one unrelated line this
file's formatter pass touched (CONTENT_RANGE header call).
2026-07-07 07:50:15 -05:00
iamdoubz e7925fc3ad feat(llm): add hosted_ai_acknowledged settings field (T10.3/M3.3)
One-time "data leaves your device" acknowledgment flag for hosted
(non-local) AI providers, persisted like consent_acknowledged so the
banner doesn't nag on every use. serde(default) so it's false for any
settings.json written before this field existed.
2026-07-07 07:47:57 -05:00
iamdoubz c3e8bae27f feat(llm): AnthropicProvider real implementation (T10.1/M3.1)
Implements AnthropicProvider::status/summarize/suggest_tags against the
real Anthropic Messages API (POST /v1/messages, x-api-key + anthropic-
version headers, SSE streaming for summarize, non-streaming for the
short tag reply), replacing the stub that returned "isn't built yet".

- Adds llm::credentials (mirrors sync::credentials) so hosted API keys
  live in the OS credential store, never settings/DB/logs (ADR-0011).
- Fixes OpenAiCompatProvider::api_key() to actually read credential_ref
  from the store instead of the hardcoded None left by the T10a.2 stub.
- Splits build_messages/build_tag_messages into system/user halves
  (build_system_and_user/tag_system_and_user) since Anthropic's system
  prompt is a top-level field, not a messages[0] entry like OpenAI's shape.
- is_local() is unconditionally false for AnthropicProvider (no
  unauthenticated mode, unlike OpenAiCompatProvider).
- Adds a loopback raw-socket mock HTTP server (mirrors sync::oauth's
  LoopbackRedirect pattern тАФ no HTTP-mock crate in the dependency tree)
  and tests proving request/response shape for both AnthropicProvider
  and OpenAiCompatProvider against mocked endpoints.
2026-07-07 07:47:03 -05:00
iamdoubz 2582d8947f Merge branch 'worktree-agent-af937df502b61b006' into feature_chore_bug_005
# Conflicts:
#	src-tauri/src/storage/mod.rs
2026-07-07 01:15:08 -05:00
iamdoubz ff3ced6874 revert(mcp): remove the unverified e2e MCP-client test (T10.4)
Written to satisfy "an in-process MCP client can call the tools", but
its dependency (reqwest 0.13 via rmcp's client-http feature) pulled in
a very heavy native build (aws-lc-rs) that hadn't finished verifying
when told to stop building. Removing rather than leaving an unverified
test + dependency in the tree. The acceptance criterion it targeted is
still exercised at the unit level: http_transport::tests (loopback
bind/refusal) and mcp::scope::tests (tool-handler scope logic) both
pass under --features mcp; the full wire-protocol round trip is a
follow-up (see final report).
2026-07-07 00:56:22 -05:00
iamdoubz 57e757f01f revert(mcp): bind_loopback/serve back to pub(crate) (T10.4)
No longer need to be pub now that the e2e test that required it is
reverted -- restores the tighter, originally-intended encapsulation.
2026-07-07 00:56:20 -05:00
iamdoubz f76cd46660 revert(mcp): drop the reqwest013 test-only dependency (T10.4)
Per instruction to stop iterating on builds: reqwest 0.13's client-http
feature (needed only for an in-process MCP-client e2e test) was pulling
a very slow/heavy native build (aws-lc-rs, even with the TLS backend
stripped down) and the last attempt was still in progress -- unverified.
Reverting Cargo.toml to the last state that was actually confirmed
green (cargo fmt/clippy -D warnings/test, both default and --features
mcp, all passing) rather than ship an unverified dependency change.
The HTTP transport itself (bind_loopback/token gate/serve loop) keeps
its own unit tests, which did pass under --features mcp.
2026-07-07 00:56:17 -05:00
iamdoubz 10edf053a9 fix(mcp): drop the TLS backend from the test-only reqwest013 dep (T10.4)
The e2e test only ever talks plain http://127.0.0.1 (never https://), so
a TLS backend is unnecessary weight -- reqwest 0.13's `rustls` feature
pulls in aws-lc-rs, a large C codebase that was taking a very long time
to compile (and once contributed to a pagefile-exhaustion build failure
alongside concurrent cargo invocations). Building reqwest013 with
default-features=false and no TLS feature is enough for the plain-HTTP
client the test needs.
2026-07-07 00:54:44 -05:00
iamdoubz 23388f9305 fix(mcp): reqwest 0.13's TLS feature is named rustls, not rustls-tls (T10.4) 2026-07-07 00:50:57 -05:00
iamdoubz 42463d41f6 fix(mcp): use reqwest013::Client for the rmcp client transport in the e2e test (T10.4) 2026-07-07 00:50:18 -05:00
iamdoubz f5651c1133 fix(mcp): pin a second reqwest (0.13) for rmcp's client-http impl (T10.4)
rmcp's transport-streamable-http-client-reqwest is written against
reqwest 0.13.2+, a semver-incompatible major vs. the reqwest 0.12 the
rest of WA (llm/sync) depends on -- Cargo can't unify those, so
`reqwest::Client` from our own dependency isn't the same type rmcp's
`impl StreamableHttpClient for reqwest::Client` is written for. Adds
`reqwest013` (a Cargo package-rename of reqwest 0.13) purely so
tests/mcp_server_test.rs can construct the exact client type rmcp
expects, without touching reqwest 0.12 anywhere else in the crate.
2026-07-07 00:50:16 -05:00
iamdoubz 0839d9c0b8 test(mcp): end-to-end client over real HTTP loopback (T10.4-10.9)
A real rmcp client speaks Streamable HTTP to the real RmcpServer stack
(bind_loopback + serve + WaMcpHandler) over an OS-assigned loopback
port, using an in-memory SqliteStore -- not just isolated unit pieces.
Asserts: an unauthenticated POST is rejected (401) before it ever
reaches the MCP service; an authenticated client's list_tools/call_tool
round-trip is schema-valid; and the call appends an mcp_access_log row
even though default scope (`none`) denies it data (FR-MCP-5 logs the
ask, not just the answer). mint_and_store/delete touch the real OS
credential store, same pattern as sync::tests::webdav_round_trip.
2026-07-07 00:48:09 -05:00
iamdoubz 71824d44cc fix(mcp): make bind_loopback/serve/HttpServerHandle pub for the e2e test (T10.4)
Widens visibility from pub(crate) to pub so tests/mcp_server_test.rs
(an external integration-test crate, per CLAUDE.md's "cross-service
tests in /tests") can drive the real HTTP transport end-to-end. This
does not weaken the loopback guarantee: bind_loopback still refuses
non-loopback regardless of caller -- only its visibility changed.
2026-07-07 00:47:55 -05:00
iamdoubz e424ecd8d3 style(mcp): prettier 2026-07-07 00:42:01 -05:00
iamdoubz 1ade08a60e feat(mcp): MCP server settings section + privacy panel integration (T10.4/10.5, M2.5/M2.6)
New "MCP server" tab: persistent disclosure banner (agents may forward
served data to their own provider; WA itself adds no egress), enable
toggle, transport/port/scope controls, a reveal-once auth-token callout
(aria-live, never re-shown), and a live access-log list backed by the
settings store's mcp:// access subscription (FR-MCP-5).

Privacy tab gains a compact MCP status row + an explicit "adds nothing
to the egress list" confirmation line (FR-MCP-7, FR-SEC-2) next to the
existing sync/LLM egress rows.

Built with ui-ux-pro-max guidance (disclosure banner wording, reveal-
once secret pattern, aria-live for the token, badge semantics) mapped
onto this file's existing minimal/utilitarian style (banner/badge/row/
confirm classes already used by the Sync and Privacy sections) rather
than introducing a new visual language.
2026-07-07 00:36:54 -05:00
iamdoubz 02fb7de6a8 feat(briefs): Feature briefs UI in SummaryPanel (T10.6, M1.5) 2026-07-07 00:36:20 -05:00
iamdoubz 70b4952366 feat(mcp): settings store gains MCP state/actions (T10.4/10.5)
loadMcpStatus/loadMcpAccessLog/setMcpEnabled/setMcpScope mirror the
existing sync-target patterns; a live "mcp://access" subscription tails
the FR-MCP-5 audit log into the store while the panel is open, on top
of the on-demand loadMcpAccessLog() refresh. mcpLastToken holds the
freshly-minted token from the most recent enable, for the one-time
reveal UI (next commit).
2026-07-07 00:33:16 -05:00
iamdoubz ae0ef563e9 feat(mcp): frontend AppSettings/McpStatus types for the new fields (T10.4)
Adds mcp_transport/mcp_port/mcp_expose/mcp_expose_recordings to
AppSettings and a typed McpStatus for mcp_status()'s response; types
onMcpAccess's payload correctly (the live event is camelCase per
docs/04-api-contracts.md, unlike the snake_case McpAccessEntry rows
mcpAccessLog() returns).
2026-07-07 00:32:22 -05:00
iamdoubz ec8b12f636 fix(mcp): collapse nested if-let per clippy (T10.4) 2026-07-07 00:31:32 -05:00
iamdoubz cd6997265f build(mcp): Cargo.lock for the new mcp-feature dependencies (T10.4)
Regenerated by `cargo check --features mcp` after promoting hyper/
hyper-util/http/http-body-util/bytes/tower-service/tokio-util to direct
deps and widening rmcp's feature set (see the earlier Cargo.toml
commits).
2026-07-07 00:30:07 -05:00
iamdoubz 63bfc293cb feat(briefs): create/list/get_feature_brief + set_brief_exposed command bodies (T10.6, M1.4/M1.6) 2026-07-07 00:30:04 -05:00
iamdoubz 4fc3df504a refactor(briefs): bundle distill() transcript args to satisfy clippy (M1.3) 2026-07-07 00:30:03 -05:00
iamdoubz 55b520d373 style(storage): rustfmt fixup for list_feature_briefs (M1.1) 2026-07-07 00:30:01 -05:00
iamdoubz bd97032550 style: rustfmt (incl. one pre-existing long line in serve_recording) 2026-07-07 00:30:00 -05:00
iamdoubz 59742e4fdf style(mcp): rustfmt Store trait additions 2026-07-07 00:29:58 -05:00
iamdoubz 8caeb818a7 style(mcp): rustfmt run_mcp_stdio 2026-07-07 00:29:56 -05:00
iamdoubz 71c4c6ecf0 style(mcp): rustfmt 2026-07-07 00:29:54 -05:00
iamdoubz 386be75658 style(mcp): rustfmt 2026-07-07 00:29:52 -05:00
iamdoubz 36a28dc096 style(mcp): rustfmt 2026-07-07 00:29:50 -05:00
iamdoubz 07072551c0 style(mcp): rustfmt 2026-07-07 00:29:49 -05:00
iamdoubz e0112ec8c4 style(mcp): rustfmt 2026-07-07 00:29:48 -05:00
iamdoubz 45e34284e9 fix(mcp): use HttpServerHandle.local_addr instead of a second lookup (T10.4)
Fixes a dead_code warning (clippy -D warnings would fail on it) --
server.rs was calling listener.local_addr() itself before handing the
listener to http_transport::serve(), leaving the handle's own
local_addr field unread.
2026-07-07 00:27:56 -05:00
iamdoubz 0ecc72f18f feat(mcp): wire mcp_status/set_mcp_enabled/set_mcp_scope/mcp_access_log (T10.4)
Replaces the four not_implemented() stubs. mcp_status/set_mcp_enabled
reach the process-wide RmcpServer singleton (behind `#[cfg(feature =
"mcp")]`, with a not_implemented fallback for builds without it);
set_mcp_scope/mcp_access_log are plain settings/Store I/O and work in
every build regardless of the `mcp` cargo feature. The token is only
ever returned once, right when set_mcp_enabled mints it -- it is never
re-readable afterwards, same as any other freshly-issued secret.
2026-07-07 00:26:11 -05:00
iamdoubz f99845ef1b feat(mcp): run_mcp_stdio() serves one session over process stdio (T10.4, FR-MCP-6)
Builds its own tokio runtime (there is no Tauri app in this mode) and
tracing goes to stderr, not stdout -- stdout is the MCP JSON-RPC channel.
Connects to the same wa.db as the GUI instance via SqliteStore::connect,
builds a WaMcpHandler with no AppHandle (None), and runs it to
completion via mcp::stdio_transport::serve_once. A build without the
`mcp` feature prints an error and exits(1) instead of silently opening
the GUI.
2026-07-07 00:24:47 -05:00
iamdoubz f85b78c9eb fix(mcp): add tokio-util for CancellationToken (T10.4)
http_transport.rs needs tokio_util::sync::CancellationToken directly;
rmcp pulls tokio-util transitively but that doesn't make it `use`-able
from our own crate without a direct Cargo.toml entry.
2026-07-07 00:24:32 -05:00
iamdoubz 949bade137 feat(mcp): --mcp-stdio launches the headless stdio adapter (T10.4, FR-MCP-6)
Checked before whispassist_lib::run() builds the Tauri app -- the flag
routes straight to run_mcp_stdio() (next commit) and returns instead of
opening a window.
2026-07-07 00:23:47 -05:00
iamdoubz eb2aa2a0d6 fix(mcp): pass the GUI's AppHandle as Some(..) to the handler (T10.4)
Follows the previous commit's WaMcpHandler::new signature change.
2026-07-07 00:23:27 -05:00
iamdoubz 49cc1e6cd3 fix(mcp): WaMcpHandler's AppHandle is optional (T10.4)
The --mcp-stdio child process (main.rs, next commits) has no running
Tauri app/window to emit "mcp://access" events to -- only the GUI
instance's HTTP transport does. The mcp_access_log DB row is still
written unconditionally either way (FR-MCP-5); only the live event is
skipped when there's no AppHandle.
2026-07-07 00:23:20 -05:00
iamdoubz bd9b5ba3cc feat(mcp): RmcpServer wires token+transport+handler together (T10.4)
start() mints/stores a fresh token, then either binds the loopback HTTP
listener (bind_loopback + http_transport::serve) or, for stdio, just
records "enabled" and hands back the whishassist.exe --mcp-stdio command
line the agent's client config should spawn -- there is nothing to run
in-process for stdio (see mcp::stdio_transport). stop() tears down the
HTTP listener if any and deletes the stored token. instance() is a
process-wide singleton (OnceLock) so separate Tauri command invocations
(set_mcp_enabled/mcp_status/set_mcp_scope) share the same running state.
2026-07-07 00:22:35 -05:00
iamdoubz 58d565907b feat(mcp): McpToolDescriptor is Copy (T10.4)
Needed so RmcpServer::tools() can hand back TOOL_DESCRIPTORS without a
manual field-by-field clone (next commit).
2026-07-07 00:21:58 -05:00
iamdoubz 5f690f4b74 feat(mcp): stdio transport adapter (T10.4, FR-MCP-6)
serve_once() runs one MCP session over the current process's stdin/
stdout to completion -- the entry point main.rs's --mcp-stdio flag calls
into (next commit). No bearer-token check here: spawning this process
at all requires the same local-user privilege as any other command, so
process-spawn capability is the trust boundary for stdio, not a header.
2026-07-07 00:21:16 -05:00
iamdoubz 851e89720d feat(mcp): auth token in the OS credential store, not settings/DB (T10.4, FR-MCP-1)
Mirrors sync::credentials: mint_and_store() generates a fresh 32-byte
random token per set_mcp_enabled call and writes it via `keyring`
(Windows Credential Manager); the token is never persisted to
settings.json/wa.db/logs. verify() is a constant-time compare so token
checking doesn't leak timing information about a partial match
(NFR-SEC-5).
2026-07-07 00:20:39 -05:00
iamdoubz 9eed88e5bd feat(mcp): loopback bind + bearer-token gate for Streamable HTTP (T10.4, FR-MCP-1/6)
bind_loopback() refuses to bind anything that doesn't resolve to a
loopback address -- unit tested directly (non-loopback IP, unparseable
host, and a real 127.0.0.1:0 bind). serve() runs a hyper HTTP/1 accept
loop in front of rmcp's StreamableHttpService (a bare tower_service, not
an axum app); every request needs `Authorization: Bearer <token>`
(constant-time compared via mcp::token::verify) before it ever reaches
the MCP service -- an unauthenticated request never reaches rmcp at all.
2026-07-07 00:20:14 -05:00
iamdoubz c48c67760d build(mcp): pull in the HTTP/stdio transport features (T10.4)
rmcp gains transport-streamable-http-server + transport-io (the actual
serving code -- the base "server" feature only got tool routing) plus
client + transport-streamable-http-client-reqwest, used solely by this
crate's own in-process MCP-client tests (WA never opens an outbound MCP
connection at runtime, so this is not new egress, FR-MCP-7). hyper/
hyper-util/http-body-util/http/bytes/tower-service are the low-level glue
to run HTTP/1 over a loopback TcpListener in front of rmcp's
StreamableHttpService (a bare tower_service::Service, not an axum app).
All are already in Cargo.lock transitively via reqwest/tauri -- no new
crates, just promoted to direct deps under the existing `mcp` feature.
2026-07-07 00:19:15 -05:00
iamdoubz 14868d4e5c feat(mcp): ServerHandler implementing the four MCP tools (T10.5)
WaMcpHandler wires list_recent_meetings/get_transcript/get_action_items/
get_feature_brief to Store, re-checking ExposeScope + the recordings
gate independently in every handler (FR-MCP-3) and logging every read
via record_mcp_access + an "mcp://access" event (FR-MCP-5) before scope
is even evaluated, so the audit trail is "what was asked for", not just
"what was returned". get_feature_brief calls through the existing
commands::get_feature_brief stub per the M1/M2 integration contract --
it always errors right now since M1's brief storage isn't implemented
in this worktree yet; the `selected`-scope exposed-flag check is left
as a marked TODO for when M1 lands.
2026-07-07 00:17:56 -05:00
iamdoubz fc93f8e14f feat(mcp): scope-control logic, unit tested (T10.7, FR-MCP-3)
Pure functions (no DB, no cargo feature gate) so scope control is
testable without a running server: meetings_visible/brief_visible/
recording_gate_ok/meeting_allowed. Documents the conservative choice for
`selected` scope on meetings/transcript/action-items -- there's no
per-meeting selection flag in the schema yet (only feature_briefs.exposed
does), so `selected` behaves like `none` there until a real "pick which
meetings" mechanism exists, rather than silently behaving like `all`.
2026-07-07 00:14:50 -05:00
iamdoubz c73245f293 feat(mcp): trait/config plumbing for the real server (T10.4)
ExposeScope/McpTransport gain as_str/parse (privacy-safe fallback: an
unparsed scope becomes None, not All/Selected) so commands.rs can read
them out of Settings' string fields. Declares the mcp submodules
(scope always compiled; handler/http_transport/server/stdio_transport/
token behind the `mcp` cargo feature) and removes the todo!() RmcpServer
stub -- its real implementation moves to mcp::server in the next commits.
2026-07-07 00:14:32 -05:00
iamdoubz e0668e48df feat(mcp): Store methods for action items + access log (T10.4/10.5)
Adds list_action_items (backs the get_action_items MCP tool -- distinct
from list_pending_reminders, which is reminder-scoped across all
meetings) and record_mcp_access/list_mcp_access_log (FR-MCP-5 audit
trail) to the Store trait + SqliteStore, reusing the mcp_access_log
table from migrations/0003_ai_mcp.sql.
2026-07-07 00:13:11 -05:00
iamdoubz 999084ab3f feat(mcp): default_settings() covers the new MCP fields (T10.4)
Keeps default_settings() exhaustive now that Settings grew mcp_* fields.
2026-07-07 00:10:37 -05:00
iamdoubz c88c04233c feat(mcp): add MCP settings fields to Settings (T10.4)
Adds mcp_enabled/mcp_transport/mcp_port/mcp_expose/mcp_expose_recordings
to Settings, all defaulting to off/none so an upgrading settings.json
gets a fully-local default (FR-MCP-1). The auth token itself never lives
here -- OS credential store only (see mcp::token, next commit).
2026-07-07 00:10:17 -05:00
iamdoubz 7e1b7882eb chore(briefs): register briefs module (T10.6, M1.3) 2026-07-07 00:04:41 -05:00
iamdoubz 18f46ea6c3 feat(briefs): FeatureBriefBuilder distiller + golden-transcript tests (T10.6, M1.3/M1.6) 2026-07-07 00:04:39 -05:00
iamdoubz a7fda95843 feat(llm): non-streaming complete() primitive for Ollama/OpenAI-compat (T10.6, M1.2) 2026-07-07 00:03:22 -05:00
iamdoubz d14a6766e5 feat(briefs): storage layer for feature_briefs (T10.6, M1.1) 2026-07-07 00:03:21 -05:00
iamdoubz 3038b9d05d Merge pull request 'Update doc roadmap' (#18) from feature_chore_bug_004 into main
Reviewed-on: #18
2026-07-06 23:30:34 -05:00
iamdoubz b40a7e2fbc docs(test): expand M1 golden-transcript brief tests (mock provider, grounding invariant) 2026-07-06 23:27:04 -05:00
iamdoubz 106ffed786 docs(roadmap): implementation-ready M1 (feature briefs) checklist with signatures 2026-07-06 23:27:03 -05:00
iamdoubz 23db4b6dac docs(data-model): align briefs/<id>.json schema (provenance + grounding + file-vs-IPC) 2026-07-06 23:27:02 -05:00
iamdoubz 4cf5eb89b4 docs(roadmap): post-v0.2.0 execution plan (Phase 10 + reliability milestones) 2026-07-06 23:19:51 -05:00
iamdoubz 246fb60731 Merge pull request 'Feature chore bug 003' (#17) from feature_chore_bug_003 into main
Reviewed-on: #17
2026-07-06 20:06:53 -05:00
iamdoubz 233c6fa8cd chore(release): bump version to 0.2.0 + refresh README 2026-07-06 19:14:51 -05:00
iamdoubz 2eff32662b docs: document the single universal (Vulkan) installer build 2026-07-06 19:06:09 -05:00
iamdoubz f0480244bf chore: gitignore the staged vulkan-1.dll loader 2026-07-06 19:06:08 -05:00
iamdoubz 2a0379ce43 build: bundle vulkan-1.dll so the single Vulkan installer runs on non-GPU machines 2026-07-06 19:06:07 -05:00
iamdoubz 91677476cc build: stage redistributable vulkan-1.dll for the merged installer 2026-07-06 19:06:06 -05:00
iamdoubz 76a4f8ce08 build: lockfile after dropping protocol-asset 2026-07-06 18:40:18 -05:00
iamdoubz 2603e12b36 feat(ui): load recording from waaudio://; remove download affordance 2026-07-06 18:40:16 -05:00
iamdoubz 90ba14cc2d build: drop tauri protocol-asset feature (no longer used) 2026-07-06 18:40:15 -05:00
iamdoubz 7106dc1584 feat(recording): CSP media-src for waaudio://; drop asset protocol 2026-07-06 18:40:14 -05:00
iamdoubz 84b9087de7 feat(recording): register waaudio:// protocol + sweep plaintext temp files 2026-07-06 18:40:13 -05:00
iamdoubz 1634820523 feat(recording): stream + decrypt recordings in memory via waaudio:// (FR-REC-5) 2026-07-06 18:40:12 -05:00
iamdoubz 57127cfa07 docs: FR-CAP-7 now covers mic in the saved recording 2026-07-06 18:25:58 -05:00
iamdoubz 49893d5874 feat(audio): wire MicBridge so recordings include the user's voice 2026-07-06 18:25:57 -05:00
iamdoubz a14ca871e1 fix(audio): mix mic into the recording at native rate via MicBridge (FR-CAP-7) 2026-07-06 18:25:56 -05:00
iamdoubz 2fda4041ff docs: FR-CAP-8/9, FR-REC-5, FR-SYNC-11 + cancelled recording state 2026-07-06 17:56:19 -05:00
iamdoubz 0ecba7f055 docs(ui): correct playback requirement id to FR-REC-5 2026-07-06 17:56:18 -05:00
iamdoubz ea79fb656d docs(commands): correct playback requirement id to FR-REC-5 2026-07-06 17:56:16 -05:00
iamdoubz a7eba7fd82 build: lockfile for tauri protocol-asset (http-range) 2026-07-06 17:55:13 -05:00
iamdoubz d08d03077c feat(ui): recording player + live per-item sync progress bars 2026-07-06 17:55:05 -05:00
iamdoubz 3b0f27f5d9 feat(ui): Cancel button to discard an accidental recording 2026-07-06 17:55:04 -05:00
iamdoubz f2db826e8c feat(recording): cancel() + handle cancelled state 2026-07-06 17:55:02 -05:00
iamdoubz a5d81bd9e4 feat(api): cancelRecording + recordingPlaybackPath client bindings 2026-07-06 17:55:01 -05:00
iamdoubz 010941e620 feat: enable asset protocol + media-src CSP for recording playback 2026-07-06 17:52:00 -05:00
iamdoubz 90580da0af build: enable tauri protocol-asset feature for recording playback 2026-07-06 17:51:59 -05:00
iamdoubz e715c09ce0 feat: register cancel_recording + recording_playback_path commands 2026-07-06 17:51:58 -05:00
iamdoubz da25ccaec6 feat: cancel_recording, recording_playback_path, live sync progress 2026-07-06 17:51:57 -05:00
iamdoubz e7f628ad7a feat(sync): stream upload bodies with live byte progress (FR-SYNC-11) 2026-07-06 17:51:55 -05:00
iamdoubz 25d0232256 feat(vault): add is_sealed() helper 2026-07-06 17:51:54 -05:00
iamdoubz 34b4f4ae09 feat(audio): write recordings as 16-bit PCM to halve .wav size (FR-CAP-8) 2026-07-06 17:51:53 -05:00
iamdoubz 96b30ed99a feat(notes): single-pane Editor/Preview toggle 2026-07-06 17:44:05 -05:00
iamdoubz a37f766817 Merge pull request 'Feature chore bug 002' (#16) from feature_chore_bug_002 into main
Reviewed-on: #16
2026-07-06 17:05:58 -05:00
iamdoubz 4cdeb8f475 test(audio): real-hardware microphone open/stop smoke test (FR-CAP-7) 2026-07-06 16:29:56 -05:00
iamdoubz 5828023651 docs: FR-CAP-7 microphone capture + mixer contract 2026-07-06 16:26:27 -05:00
iamdoubz 3202f884af feat(settings): Microphone device picker under Audio Devices (FR-CAP-7) 2026-07-06 16:26:25 -05:00
iamdoubz 996c7edc99 feat(audio): load input devices + setMicrophone in settings store (FR-CAP-7) 2026-07-06 16:26:24 -05:00
iamdoubz bb688418bf feat(audio): add listInputDevices + mic settings to API client (FR-CAP-7) 2026-07-06 16:26:23 -05:00
iamdoubz 97b5f67049 feat(audio): wire mic capture + mixer into recording lifecycle (FR-CAP-7) 2026-07-06 16:26:17 -05:00
iamdoubz 55ddaad544 feat(audio): track mic capture handle; register list_input_devices (FR-CAP-7) 2026-07-06 16:26:16 -05:00
iamdoubz 75f7d19ade feat(audio): add microphone_enabled + audio_input_device settings (FR-CAP-7) 2026-07-06 16:26:14 -05:00
iamdoubz 2fb5fd6f30 feat(audio): microphone capture + stream mixer (FR-CAP-7) 2026-07-06 16:26:13 -05:00
iamdoubz b8dc5fc37f feat(settings): Audio Devices section under Hardware
A <select> defaulting to "Default system audio", populated from the
enumerated render devices тАФ picking one overrides which device
WhispAssist loopback-captures from instead of always following
Windows' system default.
2026-07-06 16:01:00 -05:00
iamdoubz 38b9cb2910 feat(audio): add audioDevices state + loadAudioDevices/setAudioOutputDevice 2026-07-06 16:00:59 -05:00
iamdoubz 137605934c feat(audio): add AudioDeviceInfo type + listAudioDevices to the API client 2026-07-06 16:00:57 -05:00
iamdoubz 5b281531fb feat(audio): register list_audio_devices with the Tauri invoke handler 2026-07-06 16:00:50 -05:00
iamdoubz 2759a0e49a feat(audio): add list_audio_devices command; wire device into start_recording
list_audio_devices runs the (blocking, COM-based) enumeration off the
async runtime via spawn_blocking, same pattern as import_pst_core.
start_recording now passes settings.audio_output_device through to
WasapiCapture::start instead of always capturing the default device.
2026-07-06 16:00:49 -05:00
iamdoubz 8e3e22c0e5 feat(audio): add audio_output_device to Settings
Device::get_id() string; None = system default (unchanged behavior).
#[serde(default)] so an existing settings.json without this key still
deserializes.
2026-07-06 16:00:47 -05:00
iamdoubz bb1d1730f8 feat(audio): device-selectable WASAPI loopback capture
AudioCapture::start now takes an optional device id (Device::get_id())
instead of always resolving the system default render device.
open_capture_session/find_render_device resolve the configured device,
falling back to the system default if it's no longer present (same
degrade-gracefully spirit as the existing mid-recording reconnect,
which now retries the *same* selection rather than switching to
whatever's currently default).

Adds list_render_devices() (new AudioDeviceInfo) for the Settings
picker тАФ enumeration was already fully supported by the wasapi crate,
just unused until now.
2026-07-06 16:00:46 -05:00
iamdoubz 16fcb154f3 feat(tags): Generate tags button + chip-based tag editor
Generate tags mirrors Generate summary: reads the transcript (the same
build_prompt assembly, non-streamed) and suggests 1-8 tags, merged
into the working tag list rather than replacing it outright so a
manually-added tag never silently disappears.

Replaces the old comma-separated text input with GitHub-topic-style
chips: typing a comma commits everything before it as its own chip
immediately, each chip is removable via its 'x', and clicking a chip's
label filters the meeting list to that tag (meetings.filterByTag).
2026-07-06 15:38:18 -05:00
iamdoubz cf4232cbae feat(tags): sync the sidebar tag dropdown with the shared filter state
Writable $derived instead of local $state, so clicking a tag chip
elsewhere (the Tags panel) is reflected here too, not just changes
made directly in this dropdown.
2026-07-06 15:38:17 -05:00
iamdoubz 4a30674008 feat(tags): add filterByTag to the meetings store
Same mechanism as the sidebar's tag dropdown (load with a tag filter,
drop search mode), just callable from anywhere a tag chip is clicked тАФ
not only the dropdown itself.
2026-07-06 15:38:15 -05:00
iamdoubz e16d9e5ade feat(tags): add a GitHub-topics-style TagChip component
Rounded pill using the existing --accent-soft/--accent tokens.
Clicking the label filters the meeting list to this tag; an optional
'x' removes it from whatever list rendered it (the component doesn't
know or care what "remove" means to its caller).
2026-07-06 15:38:14 -05:00
iamdoubz 1a31cac65b feat(tags): add generateTags to the typed API client 2026-07-06 15:38:03 -05:00
iamdoubz ba1b577734 feat(llm): register generate_tags with the Tauri invoke handler 2026-07-06 15:38:01 -05:00
iamdoubz fb406d62da feat(llm): add generate_tags command
Reuses build_prompt's transcript+metadata assembly (same as
generate_summary) and the same recording-in-progress guard.
2026-07-06 15:38:00 -05:00
iamdoubz 68c32ffa06 feat(llm): add suggest_tags to LlmProvider for AI-generated tags
Non-streaming тАФ a 1-8 tag reply is short enough that a second
token-stream event isn't worth wiring up. Implemented for Ollama and
the OpenAI-compatible path (reusing summarize()'s SSE plumbing, just
accumulating instead of forwarding to a TokenSink); the Anthropic stub
mirrors its existing "not built yet" summarize() error.

parse_tags/sanitize_tag/strip_ordinal_prefix handle a model that
ignores "comma-separated, nothing else" (numbered lists, newlines,
quotes) and normalize everything to lowercase, hyphenated, chip-safe
tags, capped at 8 and deduped.
2026-07-06 15:37:59 -05:00
iamdoubz 2efe29558a feat(meetings): editable title header above the transcript/notes pane
Nothing in the UI displayed or let you change a meeting's title
outside the sidebar list тАФ recordings default to "Untitled meeting"
with no way to fix that. Adds an inline-editable header; blur/change
commits the rename.
2026-07-06 15:10:59 -05:00
iamdoubz 3cd267f737 feat(meetings): add renameMeeting; refresh the list after attach/rename
attachEvent and renameMeeting can both change a meeting's title now, so
both refresh the list (not just the selected-detail view) to keep the
sidebar in sync.
2026-07-06 15:10:58 -05:00
iamdoubz f9e75b7d17 feat(meetings): add renameMeeting to the typed API client 2026-07-06 15:10:56 -05:00
iamdoubz ae9e8398c5 feat(meetings): register rename_meeting with the Tauri invoke handler 2026-07-06 15:10:55 -05:00
iamdoubz 4f80c14f72 feat(meetings): add rename_meeting command
Validates a non-empty trimmed title before delegating to the store.
2026-07-06 15:10:54 -05:00
iamdoubz e68e888c07 feat(meetings): rename_meeting + mirror event subject on attach
rename_meeting backs a user-editable title (T2.2) тАФ recordings had no
way to change their default "Untitled meeting" name from the UI at all.

attach_meeting_to_event now also copies the linked event's subject onto
the meeting's title when it has one: linking is meant to say "this
recording is that meeting," so the title should follow.
2026-07-06 15:10:53 -05:00
iamdoubz 7e646b3e0f feat(calendar): search/date filter on the meeting-linking event picker
A real mailbox import is thousands of events тАФ searching/filtering
belongs where the user is actually tying a meeting to one, not just
the general browse list in Settings. Defaults the date filter to the
selected meeting's own recording date, since that's almost always the
event being linked.
2026-07-06 14:55:50 -05:00
iamdoubz 00de9d3f68 feat(calendar): remember .pst path, add auto-sync-on-launch toggle
Prefills the file path from settings once loaded (without clobbering
an in-progress browse/edit) and persists it after a successful import,
so the user doesn't have to re-browse to the same file every launch.
The new checkbox just flips pst_auto_sync, which lib.rs's one-shot
startup pass reads.
2026-07-06 14:55:49 -05:00
iamdoubz e6a2109c9c feat(settings): default pst_last_path/pst_auto_sync in the fallback settings object 2026-07-06 14:55:47 -05:00
iamdoubz 67ac4c3813 feat(settings): add pst_last_path/pst_auto_sync to AppSettings type 2026-07-06 14:55:46 -05:00
iamdoubz 9d757be415 feat(calendar): one-shot PST auto-sync on launch
Mirrors the existing sync-job-resume pattern right above it: runs once
at startup if pst_auto_sync is on and a path is remembered, no idle
timer (NFR-RES-1). Re-import is dedup'd by (source, raw_uid), so this
just catches up on new/changed events since last launch.
2026-07-06 14:55:36 -05:00
iamdoubz 871b11d75e refactor(calendar): split import_pst into a reusable core fn
import_pst_core takes &AppHandle/&dyn Store directly instead of the
State<AppState> extractor, so the startup auto-sync pass (lib.rs) can
run the same import logic without going through a Tauri command.
2026-07-06 14:55:34 -05:00
iamdoubz 75ec24ddf6 feat(calendar): add pst_last_path/pst_auto_sync to Settings
Backing fields for remembering the imported .pst path across sessions
and an opt-in startup auto-resync (T6.2). #[serde(default)] so an
existing settings.json without these keys still deserializes.
2026-07-06 14:55:33 -05:00
iamdoubz 4637ce8aac chore(deps): promote chrono to a direct dependency
Needed for DST-aware recurrence expansion (calendar/mod.rs); already
resolved transitively via sqlx, so this adds no new compiled crate.
2026-07-06 14:55:31 -05:00
iamdoubz 6f07f28cef fix(calendar): preserve local wall-clock time across DST in recurrence
expand_rrule reused dtstart's raw UTC time-of-day for every occurrence,
so a weekly meeting spanning a DST transition (e.g. created in winter
CST, recurring into summer CDT) drifted an hour once displayed in the
machine's local timezone.

Rewrites occurrence generation on chrono's Local/NaiveDate (already a
transitive dependency via sqlx, now promoted to direct) instead of
hand-rolled epoch-day arithmetic: each occurrence keeps dtstart's local
wall-clock hour/minute, re-resolving the UTC offset per occurrence date
so DST is applied correctly for that specific day. civil_from_days/
weekday_from_epoch_day/is_leap_year/days_in_month are no longer needed
(chrono's NaiveDate replaces them) and are removed.
2026-07-06 14:55:30 -05:00
iamdoubz fa8a7f2552 feat(calendar): expand recurring PST events into per-occurrence rows
RRULE was previously ignored entirely, so a recurring meeting only ever
stored its first occurrence тАФ invisible to any later date filter/lookup.
Adds RRULE parsing + occurrence expansion (DAILY/WEEKLY/MONTHLY/YEARLY,
INTERVAL/COUNT/UNTIL/BYDAY/BYMONTHDAY/BYMONTH) covering every pattern
found in a real 7.2GB mailbox (ADR-0008), each occurrence stored as its
own row keyed by "{uid}@{ymd}" so re-import dedup still applies.

Also fixes a real parser bug found via that mailbox: this libpst build
joins multi-value BYDAY with `;` instead of RFC 5545's `,`
(BYDAY=MO;TU;WE;TH;FR), which previously made RRULE parsing bail out
silently for any event with more than one weekday.

Indefinite rules (no COUNT/UNTIL тАФ only YEARLY holidays in practice) are
capped at 10 years/500 occurrences; ponytail-flagged as the ceiling to
raise if a real series needs more.
2026-07-06 14:34:21 -05:00
iamdoubz 8f98332cdd feat(calendar): filter imported events by title and date
Client-side filter over the already-loaded events list (T6.3, FR-CAL-2)
тАФ 1700+ events from a real mailbox is unwieldy to browse unfiltered.
2026-07-06 14:19:14 -05:00
iamdoubz 2ca4151444 fix(calendar): fall back to stdout when readpst's stderr is empty
readpst writes some failure messages to stdout rather than stderr, so a
failing run with empty stderr surfaced as a blank "parse failed: " error
with no diagnostic content.
2026-07-06 13:56:44 -05:00
iamdoubz 66aae232c5 fix(calendar): use shared errorMessage() for PST import errors
Tauri commands reject with a {kind, message} object, not a native Error,
so `e instanceof Error ? e.message : String(e)` always fell through to
String(e) -> "[object Object]", hiding the actual readpst failure reason.
2026-07-06 13:43:23 -05:00
123 changed files with 34035 additions and 1292 deletions
+14
View File
@@ -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"
+9
View File
@@ -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/
+57
View File
@@ -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
+144 -53
View File
@@ -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.
+78
View File
@@ -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.
+94
View File
@@ -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.
+94
View File
@@ -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.
+78
View File
@@ -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.
+4
View File
@@ -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."
1 contender file audio_secs load_ms wall_ms cpu_ms wer_errors ref_words hypothesis
2 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.
3 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.
4 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.
+31
View File
@@ -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."
1 contender file audio_secs load_ms wall_ms cpu_ms wer_errors ref_words hypothesis
2 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.
3 w1-cpu-base 1089-134686-0001 3.27 163 2111 7469 1 8 Stuff it into you, his belly counseled him.
4 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.
5 w1-cpu-base 1089-134686-0003 2.68 163 1844 6999 0 7 Hello Bertie, any good in your mind?
6 w1-cpu-base 1089-134686-0004 5.22 163 1941 7250 1 11 Number 10. Fresh Nelly is waiting on you. Good night husband.
7 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.
8 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.
9 w1-cpu-base 1089-134686-0007 4.28 163 1659 6500 0 8 A cold, lucid indifference reigned in his soul.
10 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.
11 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.
12 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."
13 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.
14 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.
15 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.
16 w1-cpu-base 1089-134686-0014 2.23 163 1573 6155 0 8 He tried to think how it could be.
17 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.
18 w1-cpu-base 1089-134686-0016 3.54 163 1568 6125 1 10 Then you can ask him questions on the Catechism Daedalus.
19 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,
20 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.
21 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.
22 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.
23 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.
24 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?
25 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?
26 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?
27 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.
28 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.
29 w1-cpu-base 1089-134686-0027 2.71 163 1549 5937 0 9 He clasped his hands on the desk and said,
30 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.
31 w1-cpu-base 1089-134686-0029 4.67 163 1601 6280 0 11 On Friday, confession will be heard all the afternoon after beads.
+31
View File
@@ -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."
1 contender file audio_secs load_ms wall_ms cpu_ms wer_errors ref_words hypothesis
2 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.
3 w1-cpu-base 1089-134686-0001 3.27 112 82717 961829 1 8 Stuff it into you, his belly counseled him.
4 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.
5 w1-cpu-base 1089-134686-0003 2.68 112 77434 887500 0 7 Hello Bertie, any good in your mind?
6 w1-cpu-base 1089-134686-0004 5.22 112 99168 1158844 1 11 Number 10. Fresh Nelly is waiting on you. Good night husband.
7 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.
8 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.
9 w1-cpu-base 1089-134686-0007 4.28 112 81833 956001 0 8 A cold, lucid indifference reigned in his soul.
10 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.
11 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.
12 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."
13 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.
14 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.
15 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.
16 w1-cpu-base 1089-134686-0014 2.23 112 70139 820125 0 8 He tried to think how it could be.
17 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.
18 w1-cpu-base 1089-134686-0016 3.54 112 108330 1241142 1 10 Then you can ask him questions on the Catechism Daedalus.
19 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,
20 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.
21 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.
22 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.
23 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.
24 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?
25 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?
26 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?
27 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.
28 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.
29 w1-cpu-base 1089-134686-0027 2.71 112 70567 820750 0 9 He clasped his hands on the desk and said,
30 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.
31 w1-cpu-base 1089-134686-0029 4.67 112 87495 1024704 0 11 On Friday, confession will be heard all the afternoon after beads.
+181
View File
@@ -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."
1 contender file audio_secs load_ms wall_ms cpu_ms wer_errors ref_words hypothesis
2 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.
3 w1-cpu-base 1089-134686-0001 3.27 188 82564 917655 1 8 Stuff it into you, his belly counseled him.
4 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.
5 w1-cpu-base 1089-134686-0003 2.68 188 73065 818720 0 7 Hello Bertie, any good in your mind?
6 w1-cpu-base 1089-134686-0004 5.22 188 109834 1145937 1 11 Number 10. Fresh Nelly is waiting on you. Good night husband.
7 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.
8 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.
9 w1-cpu-base 1089-134686-0007 4.28 188 90484 890719 0 8 A cold, lucid indifference reigned in his soul.
10 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.
11 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.
12 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."
13 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.
14 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.
15 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.
16 w1-cpu-base 1089-134686-0014 2.23 188 66047 748750 0 8 He tried to think how it could be.
17 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.
18 w1-cpu-base 1089-134686-0016 3.54 188 88080 1015922 1 10 Then you can ask him questions on the Catechism Daedalus.
19 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,
20 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.
21 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.
22 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.
23 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.
24 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?
25 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?
26 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?
27 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.
28 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.
29 w1-cpu-base 1089-134686-0027 2.71 188 69835 812876 0 9 He clasped his hands on the desk and said,
30 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.
31 w1-cpu-base 1089-134686-0029 4.67 188 86115 1006610 0 11 On Friday, confession will be heard all the afternoon after beads.
32 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.
33 w2-vulkan-base 1089-134686-0001 3.27 248 491 172 1 8 Stuff it into you, his belly counseled him.
34 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.
35 w2-vulkan-base 1089-134686-0003 2.68 248 546 140 0 7 Hello Bertie, any good in your mind?
36 w2-vulkan-base 1089-134686-0004 5.22 248 371 219 1 11 Number 10. Fresh Nelly is waiting on you. Good night husband.
37 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.
38 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.
39 w2-vulkan-base 1089-134686-0007 4.28 248 261 125 0 8 A cold, lucid indifference reigned in his soul.
40 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.
41 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.
42 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."
43 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.
44 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.
45 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.
46 w2-vulkan-base 1089-134686-0014 2.23 248 377 126 0 8 He tried to think how it could be.
47 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.
48 w2-vulkan-base 1089-134686-0016 3.54 248 252 78 1 10 Then you can ask him questions on the Catechism Daedalus.
49 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,
50 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.
51 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.
52 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.
53 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.
54 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?
55 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?
56 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?
57 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.
58 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.
59 w2-vulkan-base 1089-134686-0027 2.71 248 603 186 0 9 He clasped his hands on the desk and said,
60 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.
61 w2-vulkan-base 1089-134686-0029 4.67 248 553 172 0 11 On Friday, confession will be heard all the afternoon after beads.
62 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.
63 w3-vulkan-medium 1089-134686-0001 3.27 724 2691 625 1 8 "Stuff it into you," his belly counseled him.
64 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.
65 w3-vulkan-medium 1089-134686-0003 2.68 724 1895 484 0 7 Hello Bertie, any good in your mind?
66 w3-vulkan-medium 1089-134686-0004 5.22 724 2074 578 1 11 Number ten! Fresh Nellie is waiting on you. Good night, husband.
67 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.
68 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.
69 w3-vulkan-medium 1089-134686-0007 4.28 724 1917 500 0 8 A cold lucid indifference reigned in his soul.
70 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.
71 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.
72 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."
73 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.
74 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.
75 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.
76 w3-vulkan-medium 1089-134686-0014 2.23 724 1847 453 0 8 He tried to think how it could be.
77 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.
78 w3-vulkan-medium 1089-134686-0016 3.54 724 2009 657 1 10 Then you can ask him questions on the catechism, Daedalus.
79 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."
80 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.
81 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.
82 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?
83 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?"
84 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?
85 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?"
86 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?
87 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.
88 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.
89 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."
90 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.
91 w3-vulkan-medium 1089-134686-0029 4.67 724 1954 672 0 11 On Friday, confession will be heard all the afternoon after beads.
92 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.
93 w4-npu-onnx 1089-134686-0001 3.27 4723 286 189 3 8 Stuffered into you, his belly counseled him.
94 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.
95 w4-npu-onnx 1089-134686-0003 2.68 4723 262 172 0 7 Hello Bertie, any good in your mind?
96 w4-npu-onnx 1089-134686-0004 5.22 4723 356 265 1 11 Number 10. Fresh Nelly is waiting on you. Good night, husband.
97 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.
98 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.
99 w4-npu-onnx 1089-134686-0007 4.28 4723 261 188 0 8 A cold, lucid indifference reigned in his soul.
100 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.
101 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.
102 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."
103 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.
104 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.
105 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.
106 w4-npu-onnx 1089-134686-0014 2.23 4723 242 156 0 8 He tried to think how it could be.
107 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.
108 w4-npu-onnx 1089-134686-0016 3.54 4723 338 249 1 10 Then you can ask him questions on the Catechism Daedalus.
109 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,
110 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.
111 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.
112 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.
113 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,
114 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?
115 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?
116 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?
117 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.
118 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.
119 w4-npu-onnx 1089-134686-0027 2.71 4723 269 203 0 9 He clasped his hands on the desk and said,
120 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.
121 w4-npu-onnx 1089-134686-0029 4.67 4723 296 233 0 11 On Friday, confession will be heard all the afternoon after beads.
122 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.
123 p1-parakeet-t8 1089-134686-0001 3.27 4576 185 1719 0 8 Stuff it into you, his belly counselled him.
124 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.
125 p1-parakeet-t8 1089-134686-0003 2.68 4576 164 1344 0 7 Hello, Bertie. Any good in your mind?
126 p1-parakeet-t8 1089-134686-0004 5.22 4576 229 1844 0 11 Number ten Fresh Nelly is waiting on you. Good night, husband.
127 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.
128 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.
129 p1-parakeet-t8 1089-134686-0007 4.28 4576 265 2454 0 8 A cold, lucid indifference reigned in his soul.
130 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,
131 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.
132 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
133 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.
134 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.
135 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.
136 p1-parakeet-t8 1089-134686-0014 2.23 4576 112 860 0 8 He tried to think how it could be
137 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
138 p1-parakeet-t8 1089-134686-0016 3.54 4576 223 2015 1 10 Then you can ask him questions on the Catechism, Daedalus.
139 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
140 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.
141 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.
142 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?
143 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?
144 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?
145 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?
146 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?
147 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.
148 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
149 p1-parakeet-t8 1089-134686-0027 2.71 4576 162 1391 0 9 He clasped his hands on the desk and said
150 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.
151 p1-parakeet-t8 1089-134686-0029 4.67 4576 209 1798 1 11 On Friday confession will be heard all the afternoon after Bedes.
152 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.
153 p1-parakeet-t4 1089-134686-0001 3.27 4552 175 968 0 8 Stuff it into you, his belly counselled him.
154 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.
155 p1-parakeet-t4 1089-134686-0003 2.68 4552 159 829 0 7 Hello, Bertie. Any good in your mind?
156 p1-parakeet-t4 1089-134686-0004 5.22 4552 247 1187 0 11 Number ten Fresh Nelly is waiting on you. Good night, husband.
157 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.
158 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.
159 p1-parakeet-t4 1089-134686-0007 4.28 4552 232 1157 0 8 A cold, lucid indifference reigned in his soul.
160 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,
161 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.
162 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
163 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.
164 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.
165 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.
166 p1-parakeet-t4 1089-134686-0014 2.23 4552 133 781 0 8 He tried to think how it could be
167 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
168 p1-parakeet-t4 1089-134686-0016 3.54 4552 206 1031 1 10 Then you can ask him questions on the Catechism, Daedalus.
169 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
170 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.
171 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.
172 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?
173 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?
174 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?
175 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?
176 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?
177 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.
178 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
179 p1-parakeet-t4 1089-134686-0027 2.71 4552 153 812 0 9 He clasped his hands on the desk and said
180 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.
181 p1-parakeet-t4 1089-134686-0029 4.67 4552 270 1375 1 11 On Friday confession will be heard all the afternoon after Bedes.
+2
View File
@@ -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."
1 contender file audio_secs load_ms wall_ms cpu_ms wer_errors ref_words hypothesis
2 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

+5
View File
@@ -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)
+9 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+149
View File
@@ -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).
+8 -1
View File
@@ -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).
+17
View File
@@ -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 тАж`).
+48
View File
@@ -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.
+101
View File
@@ -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.
+142
View 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
View File
@@ -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": {
+24
View File
@@ -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"
+47
View File
@@ -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"
+25
View File
@@ -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"
+22
View File
@@ -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 ====="
+32
View File
@@ -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");
+139 -8
View File
@@ -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
View File
@@ -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)
+46
View File
@@ -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}");
}
}
+418
View File
@@ -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
+42
View File
@@ -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",
+42
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+574
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+183
View File
@@ -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"));
}
}
+175 -2
View File
@@ -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 =
+3
View File
@@ -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()
}
+263
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -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();
}
+346
View File
@@ -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()
}
+168
View File
@@ -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
View File
@@ -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.",
},
]
}
}
+100
View File
@@ -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
}
}
+107
View File
@@ -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()
}
+31
View File
@@ -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(())
}
+95
View File
@@ -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");
}
}
+153
View File
@@ -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);
}
}
+176
View File
@@ -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
View File
@@ -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(&paragraph);
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(&paragraph);
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";
+7
View File
@@ -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")
}
File diff suppressed because it is too large Load Diff
+2077 -34
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -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,
}
}
+166
View File
@@ -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"));
}
}
+529 -43
View File
@@ -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"
);
}
}
+178 -2
View File
@@ -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);
}
}
+246 -26
View File
@@ -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");
+13 -3
View File
@@ -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"));
}
}
+6
View File
@@ -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> {
+7 -7
View File
@@ -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"
}
}
}
}
+6
View File
@@ -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();
+60
View File
@@ -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
View File
@@ -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
View File
@@ -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)),
+5 -7
View File
@@ -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>
+93
View File
@@ -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>
+335
View File
@@ -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>
+214
View File
@@ -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>
+46 -13
View File
@@ -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>
+105
View File
@@ -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>
+98
View File
@@ -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>
+9 -6
View File
@@ -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" />
+588
View File
@@ -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 ┘К╪╣┘Е┘Д ┘Б┘К ╪з┘Д╪о┘Д┘Б┘К╪й ╪и╪п┘Д╪з┘Л ┘Е┘Ж ╪з┘Д╪о╪▒┘И╪м. ╪г╪╣╪п ┘Б╪к╪н┘З ┘Е┘Ж ╪г┘К┘В┘И┘Ж╪й ╪╣┘Д╪и╪й ╪з┘Д┘Ж╪╕╪з┘Е╪Ы ┘И┘Д╪е┘Ж┘З╪з╪ж┘З ╪к┘Е╪з┘Е╪з┘Л ╪з╪│╪к╪о╪п┘Е ┬л╪о╪▒┘И╪м┬╗ ┘Е┘Ж ┘В╪з╪ж┘Е╪й ╪з┘Д╪╣┘Д╪и╪й. ┘Е┘Б╪╣┘С┘Д ╪з┘Б╪к╪▒╪з╪╢┘К╪з┘Л."
}
+588
View File
@@ -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 ржмржирзНржз ржирж╛ рж╣ржпрж╝рзЗ ржкржЯржнрзВржорж┐рждрзЗ ржЪрж▓рждрзЗ ржерж╛ржХрзЗред ржЯрзНрж░рзЗ ржЖржЗржХржи ржерзЗржХрзЗ ржЖржмрж╛рж░ ржЦрзБрж▓рзБржи; ржкрзБрж░рзЛржкрзБрж░рж┐ ржмржирзНржз ржХрж░рждрзЗ ржЯрзНрж░рзЗрж░ 'ржкрзНрж░рж╕рзНржерж╛ржи' ржмрзНржпржмрж╣рж╛рж░ ржХрж░рзБржиред ржбрж┐ржлрж▓рзНржЯрзЗ ржЪрж╛рж▓рзБред"
}
+588
View File
@@ -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."
}
+588
View File
@@ -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."
}
+588
View File
@@ -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."
}
+588
View File
@@ -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."
}
+588
View File
@@ -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├д."
}
+588
View File
@@ -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."
}
+588
View File
@@ -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."
}
+588
View File
@@ -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