diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ee57f00dcc..1bffa1536e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,6 +6,8 @@ HANDY IS UNDERGOING A FEATURE FREEZE. IF YOU ARE SUBMITTING A PR WHICH IS A NEW BUG FIXES ARE THE TOP PRIORITY. THERE ARE 60+ ISSUES TO FIX. --> +**Please submit only one fix or feature per pull request. Pull requests containing multiple fixes or features will likely be closed.** + **Please confirm you have done the following:** - [ ] I have searched [existing issues](https://github.com/cjpais/Handy/issues) and [pull requests](https://github.com/cjpais/Handy/pulls) (including closed ones) to ensure this isn't a duplicate diff --git a/.github/upstream-version b/.github/upstream-version index ed5c3a898f..06769c1caa 100644 --- a/.github/upstream-version +++ b/.github/upstream-version @@ -1 +1 @@ -v0.9.4 +v0.9.5 diff --git a/.gitignore b/.gitignore index c6020fce0b..c505900839 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ blob-report/ # Nix build output result + +# Python bytecode (scripts/) +__pycache__/ diff --git a/.nix/bun-lock-hash b/.nix/bun-lock-hash index 6a1a8b1956..fec2300a8b 100644 --- a/.nix/bun-lock-hash +++ b/.nix/bun-lock-hash @@ -1 +1 @@ -2b3f6a3dd6298cd35494ee52c6f5fdd40fd6391df54d48cd0063b0e154f5e1a8 +a7fd0739d7f757d16b24723d230d25203292c76eafb53f3daddcf43b8ec94872 diff --git a/.nix/bun.nix b/.nix/bun.nix index 6289a09d31..0b6e51d9e2 100644 --- a/.nix/bun.nix +++ b/.nix/bun.nix @@ -901,10 +901,6 @@ url = "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz"; hash = "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="; }; - "escape-string-regexp@5.0.0" = fetchurl { - url = "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz"; - hash = "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="; - }; "eslint-plugin-i18next@6.1.3" = fetchurl { url = "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.3.tgz"; hash = "sha512-z/h4oBRd9wI1ET60HqcLSU6XPeAh/EPOrBBTyCdkWeMoYrWAaUVA+DOQkWTiNIyCltG4NTmy62SQisVXxoXurw=="; @@ -1121,9 +1117,9 @@ url = "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"; hash = "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="; }; - "js-yaml@4.1.1" = fetchurl { - url = "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz"; - hash = "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="; + "js-yaml@4.3.1" = fetchurl { + url = "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz"; + hash = "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="; }; "jsesc@3.1.0" = fetchurl { url = "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz"; @@ -1241,42 +1237,10 @@ url = "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz"; hash = "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="; }; - "markdown-table@3.0.4" = fetchurl { - url = "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz"; - hash = "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="; - }; - "mdast-util-find-and-replace@3.0.2" = fetchurl { - url = "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz"; - hash = "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="; - }; "mdast-util-from-markdown@2.0.3" = fetchurl { url = "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz"; hash = "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="; }; - "mdast-util-gfm-autolink-literal@2.0.1" = fetchurl { - url = "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz"; - hash = "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="; - }; - "mdast-util-gfm-footnote@2.1.0" = fetchurl { - url = "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz"; - hash = "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="; - }; - "mdast-util-gfm-strikethrough@2.0.0" = fetchurl { - url = "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz"; - hash = "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="; - }; - "mdast-util-gfm-table@2.0.0" = fetchurl { - url = "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz"; - hash = "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="; - }; - "mdast-util-gfm-task-list-item@2.0.0" = fetchurl { - url = "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz"; - hash = "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="; - }; - "mdast-util-gfm@3.1.0" = fetchurl { - url = "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz"; - hash = "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="; - }; "mdast-util-mdx-expression@2.0.1" = fetchurl { url = "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz"; hash = "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="; @@ -1313,34 +1277,6 @@ url = "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz"; hash = "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="; }; - "micromark-extension-gfm-autolink-literal@2.1.0" = fetchurl { - url = "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz"; - hash = "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="; - }; - "micromark-extension-gfm-footnote@2.1.0" = fetchurl { - url = "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz"; - hash = "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="; - }; - "micromark-extension-gfm-strikethrough@2.1.0" = fetchurl { - url = "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz"; - hash = "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="; - }; - "micromark-extension-gfm-table@2.1.1" = fetchurl { - url = "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz"; - hash = "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="; - }; - "micromark-extension-gfm-tagfilter@2.0.0" = fetchurl { - url = "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz"; - hash = "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="; - }; - "micromark-extension-gfm-task-list-item@2.1.0" = fetchurl { - url = "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz"; - hash = "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="; - }; - "micromark-extension-gfm@3.0.0" = fetchurl { - url = "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz"; - hash = "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="; - }; "micromark-factory-destination@2.0.1" = fetchurl { url = "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz"; hash = "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="; @@ -1561,10 +1497,6 @@ url = "https://registry.npmjs.org/react/-/react-18.3.1.tgz"; hash = "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="; }; - "remark-gfm@4.0.1" = fetchurl { - url = "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz"; - hash = "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="; - }; "remark-parse@11.0.0" = fetchurl { url = "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz"; hash = "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="; @@ -1573,10 +1505,6 @@ url = "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz"; hash = "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="; }; - "remark-stringify@11.0.0" = fetchurl { - url = "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz"; - hash = "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="; - }; "requireindex@1.1.0" = fetchurl { url = "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz"; hash = "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg=="; diff --git a/BUILD.md b/BUILD.md index e3698dcc63..5b7fe16fcd 100644 --- a/BUILD.md +++ b/BUILD.md @@ -70,19 +70,19 @@ ORT_LIB_LOCATION=$(brew --prefix onnxruntime)/lib ORT_PREFER_DYNAMIC_LINK=1 bun ```bash # Ubuntu/Debian sudo apt update - sudo apt install build-essential libasound2-dev pkg-config libssl-dev libvulkan-dev vulkan-tools glslc spirv-headers glslang-tools libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libgtk-layer-shell0 libgtk-layer-shell-dev patchelf cmake + sudo apt install build-essential clang libclang-dev libevdev-dev libasound2-dev pkg-config libssl-dev libvulkan-dev vulkan-tools glslc spirv-headers glslang-tools libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libgtk-layer-shell0 libgtk-layer-shell-dev patchelf cmake # Fedora/RHEL sudo dnf groupinstall "Development Tools" - sudo dnf install alsa-lib-devel pkgconf openssl-devel vulkan-devel \ - spirv-headers-devel spirv-tools-devel glslang glslc \ + sudo dnf install alsa-lib-devel pkgconf openssl-devel vulkan-devel glslc \ + clang clang-devel libevdev-devel \ + spirv-headers-devel spirv-tools-devel glslang \ gtk3-devel webkit2gtk4.1-devel libappindicator-gtk3-devel librsvg2-devel \ gtk-layer-shell gtk-layer-shell-devel \ cmake # Arch Linux - sudo pacman -S base-devel alsa-lib pkgconf openssl vulkan-devel \ - spirv-headers glslang shaderc \ + sudo pacman -S base-devel clang libevdev shaderc spirv-headers glslang alsa-lib pkgconf openssl vulkan-devel \ gtk3 webkit2gtk-4.1 libappindicator-gtk3 librsvg gtk-layer-shell \ cmake ``` diff --git a/README.md b/README.md index 19483d6351..cf22a0c541 100644 --- a/README.md +++ b/README.md @@ -194,22 +194,24 @@ Without these tools, Handy falls back to enigo which may have limited compatibil bind = $mainMod, O, exec, handy --toggle-transcription ``` -- You can also manage global shortcuts outside of Handy via Unix signals, which lets Wayland window managers or other hotkey daemons keep ownership of keybindings: +- You can also trigger Handy externally via Unix signals or the CLI flags, which lets Wayland window managers or other hotkey daemons keep ownership of keybindings: - | Signal | Action | Example | - | --------- | ----------------------------------------- | ---------------------- | - | `SIGUSR2` | Toggle transcription | `pkill -USR2 -n handy` | - | `SIGUSR1` | Toggle transcription with post-processing | `pkill -USR1 -n handy` | + | Action | Trigger | + | ----------------------------------------- | -------------------------------------------------------- | + | Toggle transcription | `pkill -USR2 -n handy` or `handy --toggle-transcription` | + | Toggle transcription with post-processing | `handy --toggle-post-process` | Example Sway config: ```ini bindsym $mod+o exec pkill -USR2 -n handy - bindsym $mod+p exec pkill -USR1 -n handy + bindsym $mod+p exec handy --toggle-post-process ``` `pkill` here simply delivers the signal—it does not terminate the process. + > **Behavior change:** older releases also accepted `SIGUSR1` for toggling transcription with post-processing. WebKitGTK — the webview engine embedded in Handy on Linux — uses SIGUSR1 internally to coordinate JavaScript garbage collection, so listening for it caused phantom recordings and interrupted dictations every few minutes ([#1660](https://github.com/cjpais/Handy/issues/1660)). Handy no longer listens for SIGUSR1 on Linux; the post-processing toggle is still available via `handy --toggle-post-process`. **Remove any `pkill -USR1` bindings**: the signal is now delivered straight to WebKit's internal handler and can crash the app. + **Overlay & Pasting Issues (Linux):** - The recording overlay window can interfere with pasting transcribed text into target applications on Linux (X11) @@ -349,6 +351,10 @@ Download the models you want from below - Turbo (1600 MB): `https://blob.handy.computer/ggml-large-v3-turbo.bin` - Large (1100 MB): `https://blob.handy.computer/ggml-large-v3-q5_0.bin` +**Parakeet Unified EN 0.6B (single `.gguf` file, recommended):** + +- Q8_0 (731 MB): `https://huggingface.co/handy-computer/parakeet-unified-en-0.6b-gguf/resolve/main/parakeet-unified-en-0.6b-Q8_0.gguf` + **Parakeet Models (compressed archives):** - V2 (473 MB): `https://blob.handy.computer/parakeet-v2-int8.tar.gz` @@ -368,6 +374,10 @@ Simply place the `.bin` file directly into the `models` directory: └── ggml-large-v3-q5_0.bin ``` +**For GGUF Models (.gguf files):** + +Place the `.gguf` file directly into the `models` directory, exactly like the Whisper `.bin` files above. Handy also picks up models already present in the shared Hugging Face cache (`~/.cache/huggingface/hub`), so a copy downloaded by another tool works without being moved. + **For Parakeet Models (.tar.gz archives):** 1. Extract the `.tar.gz` file @@ -391,7 +401,7 @@ Final structure should look like: **Important Notes:** - For Parakeet models, the extracted directory name **must** match exactly as shown above -- Do not rename the `.bin` files for Whisper models—use the exact filenames from the download URLs +- Do not rename the `.bin` or `.gguf` files—use the exact filenames from the download URLs - After placing the files, restart Handy to detect the new models #### Step 5: Verify Installation @@ -460,6 +470,12 @@ Exec=env HANDY_NO_GTK_LAYER_SHELL=1 handy If a workaround helps you, please [open an issue](https://github.com/cjpais/Handy/issues) describing your distro, desktop environment, and session type — that information helps us narrow down the underlying bug. +### Handy Starts or Stops Recording on Its Own (Linux) + +Handy 0.9.4 and earlier listened for `SIGUSR1` as a remote-control trigger. WebKitGTK — the webview engine embedded in Handy on Linux — uses that same signal internally to coordinate JavaScript garbage collection, so GC cycles were misread as hotkey presses: recordings started on their own, or real dictations were cut off mid-sentence (typically ~2 minutes in). See [#1660](https://github.com/cjpais/Handy/issues/1660). + +Update to a newer release, and replace any `pkill -USR1 -n handy` keybindings with `handy --toggle-post-process`. + ### How to Contribute 1. **Check existing issues** at [github.com/cjpais/Handy/issues](https://github.com/cjpais/Handy/issues) diff --git a/bun.lock b/bun.lock index 7f6b711e44..6ac7785818 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,6 @@ "react-i18next": "^16.4.1", "react-markdown": "^10.1.0", "react-select": "^5.8.0", - "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwindcss": "^4.1.16", "tauri-plugin-macos-permissions-api": "2.3.0", @@ -578,7 +577,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -638,24 +637,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - - "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], - "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], - "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], - - "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], - - "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], - - "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], - - "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], - - "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], - "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], @@ -676,20 +659,6 @@ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], - "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], - - "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], - - "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], - - "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], - - "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], - - "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], - - "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], - "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], @@ -796,14 +765,10 @@ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], - "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], - "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], - "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - "requireindex": ["requireindex@1.1.0", "", {}, "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg=="], "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], @@ -952,8 +917,6 @@ "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], diff --git a/package.json b/package.json index bb319004e3..9d1e108676 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "handy-app", "private": true, - "version": "0.9.4", + "version": "0.9.5", "type": "module", "scripts": { "dev": "vite", @@ -41,7 +41,6 @@ "react-i18next": "^16.4.1", "react-markdown": "^10.1.0", "react-select": "^5.8.0", - "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwindcss": "^4.1.16", "tauri-plugin-macos-permissions-api": "2.3.0", diff --git a/scripts/gen_catalog.py b/scripts/gen_catalog.py index f6466d4fae..0d34dfcf22 100644 --- a/scripts/gen_catalog.py +++ b/scripts/gen_catalog.py @@ -18,7 +18,14 @@ from huggingface_hub import HfApi, HfFileSystem ORG = "handy-computer" -CATALOG_VERSION = 1 +CATALOG_VERSION = 2 + +# Download sources tried in order after Hugging Face itself. Each entry is a +# base URL; the full file URL is `{mirror}/{repo_id}/{revision}/{filename}` +# (the same three values that form the HF resolve URL, so a mirror is a plain +# static file host). Mirrors are untrusted: every download is verified against +# the per-file `sha256` below, so listing one only affects availability. +MIRRORS = ["https://blob.handy.computer"] # ───────────────────────── scoring (one constant each) ────────────────────── SPEED_SCALE = 8.0 # speed = 100·(1 − e^(−rtf/8)) grows toward 100 @@ -49,6 +56,10 @@ def acc_from_wer(wer): "Fun-ASR-MLT-Nano-2512": {"rank": 10, "desc": "A tiny multilingual model"}, # description-only (unranked, not recommended) — carried over from the legacy .bin entry "Breeze-ASR-25": {"desc": "Optimized for Taiwanese Mandarin. Code-switching support."}, + # hidden until the pinned transcribe-cpp ships their arch (has no `moss`/`sortformer` + # under src/arch/) — the app would offer a download it cannot load + "moss-transcribe-diarize": {"hidden": True}, + "diar_streaming_sortformer_4spk-v2.1": {"hidden": True}, } # temporary capability corrections pending a card re-push (remove once cards fixed) OVERRIDES = { @@ -90,13 +101,20 @@ def headline_wer(d): for q in ("q8_0","f16","q5_k_m","q6_k","q4_k_m","f32","bf16"): if isinstance(d, dict) and q in d: return d[q], q return None, None -def auto_desc(langn, caps): +LANG_NAMES = {"en":"English","ar":"Arabic","ja":"Japanese","ko":"Korean","ru":"Russian", + "uk":"Ukrainian","vi":"Vietnamese","zh":"Chinese"} +def auto_desc(langs, caps): feats = [] if caps["translate"]: feats.append("translation") if caps["lang_detect"]: feats.append("auto language detection") if caps["streaming"]: feats.append("streaming") if caps["timestamps"] != "none": feats.append(f"{caps['timestamps']}-level timestamps") - base = f"{langn}-language speech-to-text" if langn > 1 else "English speech-to-text" + if len(langs) > 1: + base = f"{len(langs)}-language speech-to-text" + else: + # unknown code falls back to the raw code so it's caught in diff review + lang = LANG_NAMES.get(langs[0], langs[0]) if langs else "English" + base = f"{lang} speech-to-text" return base + (" with " + ", ".join(feats) + "." if feats else ".") api = HfApi(token=os.environ.get("HF_TOKEN")) @@ -146,32 +164,46 @@ def skip(o, vt): pass # ran past the buffer (hit tokenizer) — keep what we got return out +def lfs_sha256(x): + """Content sha256 from the sibling's LFS/Xet info (dataclass or dict by hub version).""" + lfs = getattr(x, "lfs", None) + if lfs is None: return None + return getattr(lfs, "sha256", None) or (lfs.get("sha256") if isinstance(lfs, dict) else None) + def gguf_files(repo, siblings): - """Return GGUF files with mandatory size metadata. + """Return GGUF files with mandatory size + sha256 metadata. `QuantFile.size_bytes` is a non-null `u64` in Rust. Failing generation here keeps a transient/malformed HF listing from producing a catalog that panics at app startup when deserialized by `include_str!("catalog.json")`. + + `sha256` is the trust anchor for downloads (HF or mirror alike), so it is + equally mandatory. Every GGUF is LFS/Xet-tracked; a missing hash means the + listing is broken, not that the file is small. """ files = [] invalid = [] for x in siblings: if not x.rfilename.endswith(".gguf"): continue - if type(x.size) is not int or x.size <= 0: + sha = lfs_sha256(x) + if type(x.size) is not int or x.size <= 0 or not sha: invalid.append(x.rfilename) continue files.append({ "filename": x.rfilename, "quant": quant_of(x.rfilename), "size_bytes": x.size, + "sha256": sha, }) if invalid: - raise ValueError(f"{repo}: missing/invalid size metadata for {', '.join(invalid)}") + raise ValueError(f"{repo}: missing/invalid size or sha256 metadata for {', '.join(invalid)}") return sorted(files, key=lambda f: f["size_bytes"]) def build(repo): info = api.model_info(repo, files_metadata=True) + if not info.sha: + raise ValueError(f"{repo}: listing has no commit sha to pin") cd = info.card_data.to_dict() if info.card_data else {} s = slug(repo) cur = CURATION.get(s, {}) @@ -204,12 +236,16 @@ def build(repo): return { "id": repo, + # Pinned commit: downloads fetch `resolve/{revision}/{file}` so the bytes + # provably match the hashes below even if the repo moves on. Identity + # stays repo+filename; the pin only scopes acquisition. + "revision": info.sha, "slug": s, "name": gg.get("general.name") or pretty(s), # friendly name (from GGUF) "architecture": gg.get("general.architecture"), "family": family(s, info.tags), "parameters": gg.get("general.size_label"), # "0.6B" / "1.7B" / "62M" - "description": cur.get("desc") or auto_desc(len(langs), caps), + "description": cur.get("desc") or auto_desc(langs, caps), "base_model": cd.get("base_model"), "license": cd.get("license"), "language_count": len(langs), @@ -240,10 +276,11 @@ def main(): print(f"catalog generation failed for {len(failures)} repo(s)", file=sys.stderr) raise SystemExit(1) models.sort(key=lambda m: (not m["recommended"], m["recommended_rank"] or 1e9, - m["family"], -(m["speed_score"] or 0))) + m["family"], -(m["speed_score"] or 0), m["slug"])) catalog = { "catalog_version": CATALOG_VERSION, "generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), + "mirrors": MIRRORS, "models": models, } text = json.dumps(catalog, indent=2, ensure_ascii=False) @@ -251,6 +288,11 @@ def main(): text = re.sub(r'"languages": \[(.*?)\]', lambda m: '"languages": [' + ", ".join(re.findall(r'"[^"]*"', m.group(1))) + ']', text, flags=re.S) + # one line per `files[]` entry: a file is one diff line, so schema additions + # and hash changes don't cascade into per-key comma churn + text = re.sub(r'\{\s+("filename":.*?"sha256": "[0-9a-f]{64}")\s+\}', + lambda m: "{" + re.sub(r",\s+", ", ", m.group(1)) + "}", + text, flags=re.S) out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(__file__), "catalog.json") open(out, "w").write(text) print(f"wrote {out}: {len(models)} models, {os.path.getsize(out)/1024:.1f} KB", file=sys.stderr) diff --git a/scripts/mirror_models.py b/scripts/mirror_models.py new file mode 100644 index 0000000000..f0a192d1f6 --- /dev/null +++ b/scripts/mirror_models.py @@ -0,0 +1,281 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["boto3", "huggingface_hub[hf_xet]"] +# /// +""" +Mirror the catalog's GGUF files to S3-compatible blob storage (Cloudflare R2). + +Object keys are `{repo_id}/{revision}/{filename}` — the same three values that +form the HF resolve URL, so the app's mirror template is plain substitution. +A revision pins content, making keys immutable: objects are served with an +immutable cache policy, and existence implies correctness. + +The bucket is the only state. Each run HEADs every expected key and uploads +what's missing, so runs are idempotent and machine-independent; concurrent +runs from different machines at worst duplicate work with identical bytes. +Every file is hash-verified against the catalog's `sha256` between download +and upload — that verification is what lets everything downstream trust bare +key existence. When a repo's revision moves but a file's bytes didn't change, +the object is server-side copied from the old revision's key instead of +re-transferred. + +Modes: + (default) dry run — print the plan; read-only HEAD/LIST when credentials + are present, offline otherwise. Writes nothing. + --execute perform the plan. Downloads (hf_xet, parallel chunks) and + uploads (one worker thread) are pipelined, so wall time is + roughly max(total download, total upload). Ctrl-C is safe at any + point: a key only appears once its multipart upload completes + (an interrupted upload is invisible and auto-aborted by R2's + lifecycle rule), the in-flight upload finishes before exit, and + a rerun skips everything already mirrored. + --verify audit the mirror: stream every expected object back from the + bucket and hash it against the catalog. No disk writes; R2 + egress is free, so a full audit costs only time. + +Only each model's default quant is mirrored — the one quant the app offers +for download; --all-quants widens to every listed quant. + +Env: R2_ENDPOINT (https://.r2.cloudflarestorage.com) + R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET +Run: uv run scripts/mirror_models.py [--execute | --verify] [--only SUBSTR] + [--all-quants] [--tmpdir DIR] [catalog_path] +""" +import argparse +import hashlib +import json +import os +import shutil +import sys +import tempfile +import time +from collections import deque +from concurrent.futures import ThreadPoolExecutor + +import boto3 +from boto3.s3.transfer import TransferConfig +from huggingface_hub import hf_hub_download +from tqdm import tqdm + +CACHE_CONTROL = "public, max-age=31536000, immutable" +DOWNLOAD_ATTEMPTS = 3 +CHUNK = 8 * 1024 * 1024 +TRANSFER = TransferConfig(max_concurrency=16, multipart_chunksize=32 * 1024 * 1024) + + +def say(msg): + """All script output funnels through tqdm.write so lines land cleanly + above the active download bar instead of tearing through it.""" + tqdm.write(msg) + + +def make_s3(): + """R2 client and bucket from env, or (None, None) → offline dry-run.""" + endpoint, key, secret, bucket = (os.environ.get(v) for v in + ("R2_ENDPOINT", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET")) + if not all((endpoint, key, secret, bucket)): + return None, None + return boto3.client("s3", endpoint_url=endpoint, + aws_access_key_id=key, aws_secret_access_key=secret), bucket + + +def head_meta(s3, bucket, key): + """Object's user metadata, or None if the key doesn't exist.""" + try: + return s3.head_object(Bucket=bucket, Key=key).get("Metadata", {}) + except s3.exceptions.ClientError as e: + if e.response["Error"]["Code"] in ("404", "NoSuchKey", "NotFound"): + return None + raise + + +def find_copy_source(s3, bucket, repo_id, filename, sha): + """Key of the same file under an older revision with matching sha256, if any.""" + paginator = s3.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=bucket, Prefix=repo_id + "/"): + for obj in page.get("Contents", []): + key = obj["Key"] + if key.rsplit("/", 1)[-1] != filename: + continue + meta = head_meta(s3, bucket, key) + if meta is not None and meta.get("sha256") == sha: + return key + return None + + +def sha256_file(path): + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(CHUNK), b""): + h.update(chunk) + return h.hexdigest() + + +def download_verified(repo_id, revision, f, tmpdir): + """Fetch at the pinned revision (hf_xet parallel transfer, own progress + bar), then hash-verify against the catalog. + + Returns (path, scratch_dir); the caller removes scratch_dir after upload. + Retries transfer errors AND verification failures — a hash mismatch here + means a corrupted transfer, and uploading it would poison an immutable key. + """ + last_err = None + for attempt in range(1, DOWNLOAD_ATTEMPTS + 1): + scratch = tempfile.mkdtemp(dir=tmpdir, prefix="mirror-") + try: + path = hf_hub_download(repo_id, f["filename"], revision=revision, + cache_dir=scratch) + if (sha256_file(path) == f["sha256"] + and os.path.getsize(path) == f["size_bytes"]): + return path, scratch + last_err = "verification failed" + except Exception as e: + last_err = str(e) + except BaseException: # Ctrl-C mid-download: don't leak the scratch dir + shutil.rmtree(scratch, ignore_errors=True) + raise + shutil.rmtree(scratch, ignore_errors=True) + say(f" attempt {attempt}/{DOWNLOAD_ATTEMPTS} failed: {last_err}") + raise RuntimeError(f"download failed for {repo_id}/{f['filename']}@{revision}: {last_err}") + + +def upload_one(s3, bucket, key, path, scratch, sha, size): + """Runs on the uploader thread: push to R2, report once, drop the scratch.""" + extra = {"Metadata": {"sha256": sha}, + "CacheControl": CACHE_CONTROL, + "ContentType": "application/octet-stream"} + try: + t0 = time.monotonic() + s3.upload_file(path, bucket, key, ExtraArgs=extra, Config=TRANSFER) + rate = size / max(time.monotonic() - t0, 1e-9) / 1e6 + say(f"↑ done {key} ({rate:.0f} MB/s)") + finally: + shutil.rmtree(scratch, ignore_errors=True) + + +def verify_bucket(s3, bucket, jobs): + """Stream every expected object and hash it against the catalog.""" + bad = 0 + for key, f in jobs: + try: + body = s3.get_object(Bucket=bucket, Key=key)["Body"] + except s3.exceptions.ClientError as e: + if e.response["Error"]["Code"] in ("404", "NoSuchKey", "NotFound"): + say(f"MISSING {key}") + bad += 1 + continue + raise + h, t0 = hashlib.sha256(), time.monotonic() + for chunk in iter(lambda: body.read(CHUNK), b""): + h.update(chunk) + rate = f["size_bytes"] / max(time.monotonic() - t0, 1e-9) / 1e6 + if h.hexdigest() == f["sha256"]: + say(f"ok {key} ({rate:.0f} MB/s)") + else: + say(f"BAD {key} object does not match catalog sha256") + bad += 1 + return bad + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("catalog", nargs="?", default=os.path.join( + os.path.dirname(__file__), "..", "src-tauri", "src", "catalog", "catalog.json")) + ap.add_argument("--execute", action="store_true", help="perform the plan (default: dry run)") + ap.add_argument("--verify", action="store_true", help="stream-hash every mirrored object against the catalog") + ap.add_argument("--only", help="restrict to models whose id contains this substring") + ap.add_argument("--all-quants", action="store_true", + help="mirror every quant instead of just each model's default") + ap.add_argument("--tmpdir", default=None, + help="scratch dir for downloads (needs room for two files)") + args = ap.parse_args() + + catalog = json.load(open(args.catalog)) + jobs = [] # (model, file entry, object key) for every file this run covers + for m in catalog["models"]: + if args.only and args.only not in m["id"]: + continue + for f in m["files"]: + if args.all_quants or f["quant"] == m["default_quant"]: + jobs.append((m, f, f'{m["id"]}/{m["revision"]}/{f["filename"]}')) + + s3, bucket = make_s3() + if args.verify: + if s3 is None: + sys.exit("--verify requires R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET") + bad = verify_bucket(s3, bucket, [(key, f) for _, f, key in jobs]) + print(f"verified {len(jobs)} object(s), {bad} problem(s)") + sys.exit(1 if bad else 0) + if args.execute and s3 is None: + sys.exit("--execute requires R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET") + if s3 is None: + say("no R2 credentials: offline plan (every file counts as 'upload')") + + uploader = ThreadPoolExecutor(max_workers=1) + in_flight = deque() + + def drain(limit): + while len(in_flight) > limit: + in_flight.popleft().result() # re-raises upload errors here + + tally = {"skip": 0, "copy": 0, "upload": 0, "mismatch": 0} + upload_bytes = 0 + for m, f, key in jobs: + gb = f["size_bytes"] / 1e9 + copy_src = None + if s3 is not None: + meta = head_meta(s3, bucket, key) + if meta is not None: + # Immutable key already populated. "Existence implies + # correctness" only covers objects this script wrote (verified, + # hash recorded) — a disagreeing OR missing hash means some + # other writer, the one state that must never pass silently. + recorded = meta.get("sha256") + if recorded != f["sha256"]: + got = f"object sha {recorded[:12]}…" if recorded else "no sha256 metadata" + say(f"!! MISMATCH {key}: {got} != catalog {f['sha256'][:12]}…") + tally["mismatch"] += 1 + else: + tally["skip"] += 1 + continue + copy_src = find_copy_source(s3, bucket, m["id"], f["filename"], f["sha256"]) + + if not args.execute: + action = "copy" if copy_src else "upload" + say(f"DRY {action:6} {key} ({gb:.2f} GB" + (f", from {copy_src}" if copy_src else "") + ")") + tally[action] += 1 + upload_bytes += 0 if copy_src else f["size_bytes"] + continue + + if copy_src: + say(f"copy {key} (from {copy_src})") + extra = {"Metadata": {"sha256": f["sha256"]}, + "CacheControl": CACHE_CONTROL, + "ContentType": "application/octet-stream"} + # Managed copy: server-side, multipart above the 5 GB single-copy limit. + s3.copy({"Bucket": bucket, "Key": copy_src}, bucket, key, + ExtraArgs={**extra, "MetadataDirective": "REPLACE"}) + tally["copy"] += 1 + continue + + # Pipeline: cap queued uploads at one so at most two scratch dirs + # exist, then download the next file while the previous one uploads. + drain(1) + say(f"↓ {key} ({gb:.2f} GB)") + path, scratch = download_verified(m["id"], m["revision"], f, args.tmpdir) + in_flight.append(uploader.submit(upload_one, s3, bucket, key, path, scratch, + f["sha256"], f["size_bytes"])) + tally["upload"] += 1 + upload_bytes += f["size_bytes"] + + drain(0) + uploader.shutdown() + print(f'{"DRY RUN — nothing written. " if not args.execute else ""}' + f'skip {tally["skip"]}, copy {tally["copy"]}, upload {tally["upload"]}' + f' ({upload_bytes/1e9:.1f} GB), mismatch {tally["mismatch"]}') + if tally["mismatch"]: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 207fbcff25..56174fcee0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2500,7 +2500,7 @@ dependencies = [ [[package]] name = "handy" -version = "0.9.4" +version = "0.9.5" dependencies = [ "anyhow", "chrono", @@ -2517,8 +2517,13 @@ dependencies = [ "handy-keys", "hf-hub", "hound", + "libc", "log", "natural", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-service-management", "once_cell", "rdev", "regex", @@ -2564,9 +2569,9 @@ dependencies = [ [[package]] name = "handy-keys" -version = "0.3.1" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31719b86c449fc86156217bcdd2991a9d29432466540ed88f717774e9b391647" +checksum = "71f78470c4a23919ba16e6d621fa2c429451faa98b073f4cba679c496b3e8fa2" dependencies = [ "bitflags 2.11.0", "block2", @@ -4059,6 +4064,30 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-service-management" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8" +dependencies = [ + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f821d996f1..42dfd87ef2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "handy" -version = "0.9.4" +version = "0.9.5" description = "Handy" authors = ["cjpais"] edition = "2021" @@ -74,7 +74,7 @@ sha2 = "0.10" # Canary, Cohere). Per-platform backend features are added in the target tables. transcribe-rs = { version = "0.3.8", features = ["onnx"] } transcribe-cpp = { version = "0.1.3", default-features = false } -handy-keys = "0.3.1" +handy-keys = "0.3.3" ferrous-opencc = "0.2.3" clap = { version = "4", features = ["derive"] } specta = "=2.0.0-rc.22" @@ -106,10 +106,14 @@ tauri-plugin-updater = "2.10.0" windows = { version = "0.61.3", features = [ "Win32_Media_Audio_Endpoints", "Win32_System_Com_StructuredStorage", + "Win32_System_DataExchange", "Win32_System_LibraryLoader", + "Win32_System_Ole", + "Win32_System_Memory", "Win32_System_Threading", "Win32_System_Variant", "Win32_Foundation", + "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", ] } winreg = "0.55" @@ -140,10 +144,17 @@ transcribe-cpp = { version = "0.1.3", default-features = false } coreaudio-sys = { version = "0.2" } tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } transcribe-cpp = { version = "0.1.3", default-features = false, features = ["metal"] } +# Used by the receipt-sequenced ("reliable") paste path for lazy NSPasteboard +# promises. Versions track what Tauri already pulls in. +objc2 = "0.6" +objc2-foundation = "0.3" +objc2-app-kit = "0.3" +objc2-service-management = "0.3.2" [target.'cfg(target_os = "linux")'.dependencies] gtk-layer-shell = { version = "0.8", features = ["v0_6"] } gtk = "0.18" +libc = "0.2" transcribe-cpp = { version = "0.1.3", default-features = false, features = [ "dynamic-backends", "vulkan", @@ -172,6 +183,9 @@ transcribe-rs = { path = "transcribe-rs-patch" } [dev-dependencies] tempfile = "3" +# Test-only tokio features for the local socket servers exercising the +# resumable HTTP downloader; normal builds keep the leaner feature set. +tokio = { version = "1.43.0", features = ["macros", "net", "io-util", "sync", "time", "rt-multi-thread"] } [profile.release] lto = true diff --git a/src-tauri/resources/handy_warning.png b/src-tauri/resources/handy_warning.png new file mode 100644 index 0000000000..55e84e83eb Binary files /dev/null and b/src-tauri/resources/handy_warning.png differ diff --git a/src-tauri/resources/tray_idle_warning.png b/src-tauri/resources/tray_idle_warning.png new file mode 100644 index 0000000000..7fc1f6cf68 Binary files /dev/null and b/src-tauri/resources/tray_idle_warning.png differ diff --git a/src-tauri/resources/tray_idle_warning_dark.png b/src-tauri/resources/tray_idle_warning_dark.png new file mode 100644 index 0000000000..6f1619f0b3 Binary files /dev/null and b/src-tauri/resources/tray_idle_warning_dark.png differ diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 7a8175cb27..84f0a62ecf 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -40,6 +40,10 @@ impl Drop for FinishGuard { if let Some(c) = self.0.try_state::() { c.notify_processing_finished(); } + // The pipeline just freed its large transient buffers (captured PCM, + // WAV copy, engine scratch); hand the cached pages back to the OS so + // they don't sit in malloc arenas until they get swapped out (#1792). + crate::memory::trim_freed_memory(); } } @@ -62,6 +66,19 @@ fn strip_invisible_chars(s: &str) -> String { s.replace(['\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}'], "") } +/// Strip a leading `...` block. Some endpoints can't disable +/// reasoning, and some local servers put the reasoning text into `content` +/// instead of a separate field — without this the user would get the model's +/// chain of thought pasted along with the cleaned transcription. +fn strip_think_block(s: &str) -> &str { + if let Some(rest) = s.trim_start().strip_prefix("") { + if let Some(end) = rest.find("") { + return rest[end + "".len()..].trim_start(); + } + } + s +} + /// Build a system prompt from the user's prompt template. /// Removes `${output}` placeholder since the transcription is sent as the user message. fn build_system_prompt(prompt_template: &str) -> String { @@ -168,21 +185,10 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) .cloned() .unwrap_or_default(); - // Disable reasoning for providers where post-processing rarely benefits from it. - // - custom: top-level reasoning_effort (works for local OpenAI-compat servers) - // - openrouter: nested reasoning object; exclude:true also keeps reasoning text - // out of the response so it can't pollute structured-output JSON parsing - let (reasoning_effort, reasoning) = match provider.id.as_str() { - "custom" => (Some("none".to_string()), None), - "openrouter" => ( - None, - Some(crate::llm_client::ReasoningConfig { - effort: Some("none".to_string()), - exclude: Some(true), - }), - ), - _ => (None, None), - }; + // Ask these providers to skip reasoning/thinking — post-processing rarely + // benefits from it and it adds seconds of latency. llm_client picks the + // field the endpoint understands and retries without it if rejected. + let disable_reasoning = matches!(provider.id.as_str(), "custom" | "openrouter"); if provider.supports_structured_output { debug!("Using structured outputs for provider '{}'", provider.id); @@ -254,14 +260,14 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) user_content, Some(system_prompt), Some(json_schema), - reasoning_effort.clone(), - reasoning.clone(), + disable_reasoning, ) .await { Ok(Some(content)) => { // Parse the JSON response to extract the transcription field - match serde_json::from_str::(&content) { + let content = strip_think_block(&content); + match serde_json::from_str::(content) { Ok(json) => { if let Some(transcription_value) = json.get(TRANSCRIPTION_FIELD).and_then(|t| t.as_str()) @@ -275,7 +281,7 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) return Some(result); } else { error!("Structured output response missing 'transcription' field"); - return Some(strip_invisible_chars(&content)); + return Some(strip_invisible_chars(content)); } } Err(e) => { @@ -283,7 +289,7 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) "Failed to parse structured output JSON: {}. Returning raw content.", e ); - return Some(strip_invisible_chars(&content)); + return Some(strip_invisible_chars(content)); } } } @@ -310,13 +316,12 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) api_key, &model, processed_prompt, - reasoning_effort, - reasoning, + disable_reasoning, ) .await { Ok(Some(content)) => { - let content = strip_invisible_chars(&content); + let content = strip_invisible_chars(strip_think_block(&content)); debug!( "LLM post-processing succeeded for provider '{}'. Output length: {} chars", provider.id, @@ -926,7 +931,10 @@ pub static ACTION_MAP: Lazy>> = Lazy::ne #[cfg(test)] mod tests { - use super::{complete_unless_cancelled, is_blank_transcription, should_use_streaming_overlay}; + use super::{ + complete_unless_cancelled, is_blank_transcription, should_use_streaming_overlay, + strip_think_block, + }; use crate::settings::OverlayStyle; use std::future; use std::sync::atomic::{AtomicBool, Ordering}; @@ -975,6 +983,32 @@ mod tests { assert_eq!(result, None); } + #[test] + fn leading_think_block_is_stripped() { + assert_eq!( + strip_think_block("pondering...Cleaned text."), + "Cleaned text." + ); + assert_eq!( + strip_think_block(" \nmulti\nline\n Cleaned text."), + "Cleaned text." + ); + } + + #[test] + fn content_without_think_block_is_unchanged() { + assert_eq!(strip_think_block("Cleaned text."), "Cleaned text."); + assert_eq!( + strip_think_block("Mentions mid-sentence."), + "Mentions mid-sentence." + ); + // Unclosed block: leave untouched rather than guess + assert_eq!( + strip_think_block("never closed"), + "never closed" + ); + } + #[test] fn live_overlay_uses_streaming_states_only_for_streaming_models() { assert!(should_use_streaming_overlay(OverlayStyle::Live, true)); diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index 82297cb672..08a4430441 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -77,6 +77,8 @@ pub struct AudioRecorder { vad: Option, level_cb: Option) + Send + Sync + 'static>>, audio_cb: Option, + /// Which input channel to use. None = average all (original behavior). + selected_channel: Option, /// Preferred stream config cached per device name. The two HAL property /// queries in `get_preferred_config` cost ~40-85ms per open (worse on /// USB/Bluetooth), which lands on the keypress->capture path in on-demand @@ -95,6 +97,7 @@ impl AudioRecorder { vad: None, level_cb: None, audio_cb: None, + selected_channel: None, config_cache: Arc::new(Mutex::new(None)), }) } @@ -136,9 +139,25 @@ impl AudioRecorder { self } + pub fn with_selected_channel(mut self, channel: Option) -> Self { + self.set_selected_channel(channel); + self + } + + pub fn set_selected_channel(&mut self, channel: Option) { + self.selected_channel = channel.map(usize::from); + } + pub fn open(&mut self, device: Option) -> Result<(), Box> { if self.worker_handle.is_some() { - return Ok(()); // already open + if !self.is_capture_worker_dead() { + return Ok(()); // already open + } + // The worker exited on its own (see `is_capture_worker_dead`). Reap + // it so we rebuild the stream below instead of handing the caller + // back a recorder whose channels are already closed. + log::warn!("Capture worker exited; rebuilding microphone stream"); + let _ = self.close(); } let (sample_tx, sample_rx) = mpsc::channel::(); @@ -159,6 +178,7 @@ impl AudioRecorder { let level_cb = self.level_cb.clone(); // Move the optional real-time audio frame callback into the worker thread let audio_cb = self.audio_cb.clone(); + let selected_channel = self.selected_channel; let config_cache = Arc::clone(&self.config_cache); let worker = std::thread::spawn(move || { @@ -192,6 +212,20 @@ impl AudioRecorder { config.sample_format() ); + if let Some(channel) = selected_channel { + if channel < channels { + log::info!("Using selected input channel: {}", channel + 1); + } else { + log::warn!( + "Selected input channel {} is out of range for a {}-channel device; averaging all channels instead", + channel + 1, + channels + ); + } + } else { + log::info!("Averaging all {} input channels", channels); + } + let build_started = Instant::now(); let stream = match config.sample_format() { cpal::SampleFormat::U8 => AudioRecorder::build_stream::( @@ -199,6 +233,7 @@ impl AudioRecorder { &config, sample_tx, channels, + selected_channel, stop_flag_for_stream, ) .map_err(|e| format!("Failed to build input stream: {e}"))?, @@ -207,6 +242,7 @@ impl AudioRecorder { &config, sample_tx, channels, + selected_channel, stop_flag_for_stream, ) .map_err(|e| format!("Failed to build input stream: {e}"))?, @@ -215,6 +251,7 @@ impl AudioRecorder { &config, sample_tx, channels, + selected_channel, stop_flag_for_stream, ) .map_err(|e| format!("Failed to build input stream: {e}"))?, @@ -223,6 +260,7 @@ impl AudioRecorder { &config, sample_tx, channels, + selected_channel, stop_flag_for_stream, ) .map_err(|e| format!("Failed to build input stream: {e}"))?, @@ -231,6 +269,7 @@ impl AudioRecorder { &config, sample_tx, channels, + selected_channel, stop_flag_for_stream, ) .map_err(|e| format!("Failed to build input stream: {e}"))?, @@ -331,6 +370,20 @@ impl AudioRecorder { Ok(resp_rx.recv()?) // wait for the samples } + /// True once the capture worker has exited without anyone calling `close`. + /// + /// `run_consumer` is driven entirely by the sample channel, so when cpal + /// tears the stream down mid-session (device unplugged, USB/Bluetooth + /// dropout) `sample_rx.recv()` returns `Err`, the loop ends and the worker + /// thread finishes. `cmd_tx` and `worker_handle` are still populated at + /// that point, so the recorder looks open from the outside while every + /// command sent to it fails on a closed channel. + pub fn is_capture_worker_dead(&self) -> bool { + self.worker_handle + .as_ref() + .is_some_and(|handle| handle.is_finished()) + } + pub fn close(&mut self) -> Result<(), Box> { if let Some(tx) = self.cmd_tx.take() { let _ = tx.send(Cmd::Shutdown); @@ -347,6 +400,7 @@ impl AudioRecorder { config: &cpal::SupportedStreamConfig, sample_tx: mpsc::Sender, channels: usize, + selected_channel: Option, stop_flag: Arc, ) -> Result where @@ -355,6 +409,13 @@ impl AudioRecorder { { let mut output_buffer = Vec::new(); let mut eos_sent = false; + // Resolve the effective channel to use. If the selected channel is + // out of range for this device, fall back to averaging all channels. + let use_channel: Option = match selected_channel { + Some(ch) if ch < channels => Some(ch), + Some(_) => None, // out of range, fall back to average + None => None, // user chose "average all" + }; let stream_cb = move |data: &[T], _: &cpal::InputCallbackInfo| { if stop_flag.load(Ordering::Relaxed) { @@ -374,13 +435,20 @@ impl AudioRecorder { let frame_count = data.len() / channels; output_buffer.reserve(frame_count); - for frame in data.chunks_exact(channels) { - let mono_sample = frame - .iter() - .map(|&sample| sample.to_sample::()) - .sum::() - / channels as f32; - output_buffer.push(mono_sample); + if let Some(ch) = use_channel { + for frame in data.chunks_exact(channels) { + let mono_sample = frame[ch].to_sample::(); + output_buffer.push(mono_sample); + } + } else { + for frame in data.chunks_exact(channels) { + let mono_sample = frame + .iter() + .map(|&sample| sample.to_sample::()) + .sum::() + / channels as f32; + output_buffer.push(mono_sample); + } } } @@ -400,6 +468,12 @@ impl AudioRecorder { ) } + pub fn preferred_input_channel_count( + device: &cpal::Device, + ) -> Result> { + Ok(Self::get_preferred_config(device)?.channels()) + } + fn get_preferred_config( device: &cpal::Device, ) -> Result> { @@ -472,7 +546,16 @@ pub fn is_no_input_device_error(error_message: &str) -> bool { #[cfg(test)] mod tests { - use super::{is_microphone_access_denied, is_no_input_device_error}; + use super::{is_microphone_access_denied, is_no_input_device_error, AudioRecorder}; + + #[test] + fn unopened_recorder_is_not_reported_dead() { + // No worker has been spawned yet, so there is nothing to reap. Guards + // against inverting the "no worker" case, which would make every first + // open() take the rebuild path. + let recorder = AudioRecorder::new().expect("recorder"); + assert!(!recorder.is_capture_worker_dead()); + } #[test] fn detects_access_is_denied() { @@ -714,24 +797,33 @@ fn run_consumer( ); } - // ---------- spectrum processing ---------------------------------- // - if let Some(buckets) = visualizer.feed(&raw) { - if let Some(cb) = &level_cb { - cb(buckets); + // ---------- recording-time processing ---------------------------- // + // In always-on mode the capture stream stays open continuously for + // zero-latency start, so while idle (not recording) there is nothing to + // do with a chunk: handle_frame returns early when not recording, which + // means the resampled output would be discarded, and the level meter has + // no idle consumer. Skip both the level-meter FFT and the resampler while + // idle to avoid doing unnecessary work whose output is thrown away. Both + // are reset on Cmd::Start (visualizer.reset() / frame_resampler.reset()), + // so they resume cleanly the moment recording begins. + if recording { + if let Some(buckets) = visualizer.feed(&raw) { + if let Some(cb) = &level_cb { + cb(buckets); + } } - } - // ---------- existing pipeline ------------------------------------ // - frame_resampler.push(&raw, &mut |frame: &[f32]| { - handle_frame( - frame, - recording, - vad_policy, - &vad, - &audio_cb, - &mut processed_samples, - ) - }); + frame_resampler.push(&raw, &mut |frame: &[f32]| { + handle_frame( + frame, + recording, + vad_policy, + &vad, + &audio_cb, + &mut processed_samples, + ) + }); + } if recording { if let Some(started) = awaiting_first_captured_chunk.take() { diff --git a/src-tauri/src/audio_toolkit/audio/visualizer.rs b/src-tauri/src/audio_toolkit/audio/visualizer.rs index b0ef038e39..08b1e47d4f 100644 --- a/src-tauri/src/audio_toolkit/audio/visualizer.rs +++ b/src-tauri/src/audio_toolkit/audio/visualizer.rs @@ -1,8 +1,14 @@ use rustfft::{num_complex::Complex32, Fft, FftPlanner}; use std::sync::Arc; -const DB_MIN: f32 = -55.0; -const DB_MAX: f32 = -8.0; +// `db` below is not true dBFS: it's a per-bin average divided by the FFT +// window size, which lands ~20 dB low for speech. So this window is calibrated +// against measured mic audio (dictation ~-32 dBFS, room tone ~-48 dBFS) rather +// than absolute dBFS. The old -55/-8 left speech ~1 px above the overlay's +// floor, which reads as a frozen waveform (#1694). Not lowered past -68: at +// -70 a noisy room starts making the idle waveform twitch. +const DB_MIN: f32 = -68.0; +const DB_MAX: f32 = -30.0; const GAIN: f32 = 1.3; const CURVE_POWER: f32 = 0.7; diff --git a/src-tauri/src/autostart.rs b/src-tauri/src/autostart.rs new file mode 100644 index 0000000000..bd20b5cf14 --- /dev/null +++ b/src-tauri/src/autostart.rs @@ -0,0 +1,155 @@ +//! Launch-at-login (autostart) handling. +//! +//! All platforms apply the setting through tauri-plugin-autostart, except +//! macOS 13+ where the app registers itself as a login item via +//! `SMAppService`. The plugin's launch agent plist carries no app +//! association, so the System Settings Login Items pane attributes it to the +//! code-signing certificate's developer name instead of the app (#337). +//! `SMAppService` login items are attributed to the app bundle itself and +//! appear under "Open at Login" with the app's name and icon. + +use tauri::AppHandle; +use tauri_plugin_autostart::ManagerExt; + +/// Apply the user's autostart preference using the best mechanism for the +/// current platform. +/// +/// Errors are logged rather than returned: the preference is re-applied on +/// every launch, so a transient failure self-heals and must not block +/// startup. This mirrors the pre-existing behavior of ignoring +/// enable()/disable() results. +pub fn apply_autostart(app: &AppHandle, enabled: bool) { + #[cfg(target_os = "macos")] + if macos::login_item_api_available() { + macos::remove_plugin_launch_agent(app); + macos::set_login_item(enabled); + return; + } + + let manager = app.autolaunch(); + let result = if enabled { + manager.enable() + } else { + manager.disable() + }; + if let Err(e) = result { + log::warn!( + "Failed to apply autostart setting (enabled={}): {}", + enabled, + e + ); + } +} + +#[cfg(target_os = "macos")] +mod macos { + use std::path::{Path, PathBuf}; + + use objc2::runtime::AnyClass; + use objc2_service_management::{SMAppService, SMAppServiceStatus}; + use tauri::{AppHandle, Manager}; + + /// `SMAppService` requires macOS 13. The ServiceManagement framework is + /// linked unconditionally (it has existed since 10.6), so looking up the + /// class doubles as the OS version check: present exactly when the API is + /// usable. + pub fn login_item_api_available() -> bool { + AnyClass::get(c"SMAppService").is_some() + } + + /// Register or unregister the app as a login item, skipping the call when + /// the service is already in the requested state (unregistering a + /// never-registered service returns an error on every launch otherwise). + pub fn set_login_item(enabled: bool) { + let service = unsafe { SMAppService::mainAppService() }; + let status = unsafe { service.status() }; + + if enabled { + if status == SMAppServiceStatus::Enabled { + return; + } + match unsafe { service.registerAndReturnError() } { + Ok(()) => log::info!("Registered login item via SMAppService"), + // Fails in dev (no signed app bundle) and when the user has + // switched the item off in System Settings, which apps are + // not allowed to override. + Err(e) => log::warn!("Failed to register login item: {}", e), + } + } else { + if status == SMAppServiceStatus::NotRegistered || status == SMAppServiceStatus::NotFound + { + return; + } + match unsafe { service.unregisterAndReturnError() } { + Ok(()) => log::info!("Unregistered login item via SMAppService"), + Err(e) => log::warn!("Failed to unregister login item: {}", e), + } + } + } + + /// Remove the launch agent plist that tauri-plugin-autostart (via the + /// auto-launch crate) wrote on older versions, so login doesn't start the + /// app twice after migrating to `SMAppService`. Runs on every launch; + /// missing file is the normal case. + pub fn remove_plugin_launch_agent(app: &AppHandle) { + let Ok(home) = app.path().home_dir() else { + return; + }; + remove_launch_agent_file(&plugin_launch_agent_path(&home, &app.package_info().name)); + } + + /// Path of the plist the auto-launch crate writes: + /// `~/Library/LaunchAgents/{app name}.plist`. + fn plugin_launch_agent_path(home: &Path, app_name: &str) -> PathBuf { + home.join("Library") + .join("LaunchAgents") + .join(format!("{}.plist", app_name)) + } + + fn remove_launch_agent_file(path: &Path) { + match std::fs::remove_file(path) { + Ok(()) => log::info!("Removed legacy autostart launch agent {:?}", path), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => log::warn!("Failed to remove legacy launch agent {:?}: {}", path, e), + } + } + + #[cfg(test)] + mod tests { + use super::*; + + /// Validates the assumption `login_item_api_available` rests on: the + /// ServiceManagement framework is linked into the binary, so the + /// class lookup finds `SMAppService` whenever the host is macOS 13+ + /// (which anything able to build this crate is). + #[test] + fn sm_app_service_class_resolves() { + assert!(login_item_api_available()); + } + + #[test] + fn launch_agent_path_matches_auto_launch_crate() { + let path = plugin_launch_agent_path(Path::new("/Users/someone"), "Handy"); + assert_eq!( + path, + Path::new("/Users/someone/Library/LaunchAgents/Handy.plist") + ); + } + + #[test] + fn removes_existing_launch_agent() { + let dir = tempfile::tempdir().unwrap(); + let plist = dir.path().join("Handy.plist"); + std::fs::write(&plist, "").unwrap(); + + remove_launch_agent_file(&plist); + assert!(!plist.exists()); + } + + #[test] + fn missing_launch_agent_is_a_no_op() { + let dir = tempfile::tempdir().unwrap(); + remove_launch_agent_file(&dir.path().join("Handy.plist")); + } + } +} diff --git a/src-tauri/src/catalog/catalog.json b/src-tauri/src/catalog/catalog.json index 08b1a126c7..978b1837bd 100644 --- a/src-tauri/src/catalog/catalog.json +++ b/src-tauri/src/catalog/catalog.json @@ -1,9 +1,13 @@ { - "catalog_version": 1, - "generated_at": "2026-07-01T13:30:59+00:00", + "catalog_version": 2, + "generated_at": "2026-07-23T10:15:59+00:00", + "mirrors": [ + "https://blob.handy.computer" + ], "models": [ { "id": "handy-computer/parakeet-unified-en-0.6b-gguf", + "revision": "7e948f21b7bdbac698d3318db9d350f1096f3b6c", "slug": "parakeet-unified-en-0.6b", "name": "Parakeet Unified EN 0.6B", "architecture": "parakeet", @@ -23,36 +27,12 @@ "speed_score": 79, "accuracy_score": 90, "files": [ - { - "filename": "parakeet-unified-en-0.6b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 477274496 - }, - { - "filename": "parakeet-unified-en-0.6b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 540795264 - }, - { - "filename": "parakeet-unified-en-0.6b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 602191232 - }, - { - "filename": "parakeet-unified-en-0.6b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 731357568 - }, - { - "filename": "parakeet-unified-en-0.6b-F16.gguf", - "quant": "F16", - "size_bytes": 1239114240 - }, - { - "filename": "parakeet-unified-en-0.6b-F32.gguf", - "quant": "F32", - "size_bytes": 2473323520 - } + {"filename": "parakeet-unified-en-0.6b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 477274496, "sha256": "a8bf3de2b393bd14ead5a858c3748d5e3b07a20fdeabdd3b498fba4f463fa929"}, + {"filename": "parakeet-unified-en-0.6b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 540795264, "sha256": "f9def6f9b4e83ab7d006df3e1b676dfa1f973a3b6da232a9c99fcaa66bcd2836"}, + {"filename": "parakeet-unified-en-0.6b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 602191232, "sha256": "912c42ec3fd1afa31b7eda45684577feadf413249ca767fa912a89c4810e38d0"}, + {"filename": "parakeet-unified-en-0.6b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 731357568, "sha256": "4b50b6dd862bf6e346929aaf4f5eaacec003bfa3f56462d6c874b41ef2f38795"}, + {"filename": "parakeet-unified-en-0.6b-F16.gguf", "quant": "F16", "size_bytes": 1239114240, "sha256": "4a284b229bff9dc66aa00666fdcc2419c0f82a5adb5a36f7a48a4e0713c35492"}, + {"filename": "parakeet-unified-en-0.6b-F32.gguf", "quant": "F32", "size_bytes": 2473323520, "sha256": "81d3a8c7676324a4dae977dac0f5900dc1468755fdf3b5369261bb759f17fdf3"} ], "default_quant": "Q8_0", "recommended": true, @@ -60,6 +40,7 @@ }, { "id": "handy-computer/nemotron-3.5-asr-streaming-0.6b-gguf", + "revision": "6d44e540bc31b0de1dbe174a3cea87f53a7f22fb", "slug": "nemotron-3.5-asr-streaming-0.6b", "name": "Nemotron Streaming 3.5", "architecture": "parakeet", @@ -79,36 +60,12 @@ "speed_score": 84, "accuracy_score": 82, "files": [ - { - "filename": "nemotron-3.5-asr-streaming-0.6b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 495831520 - }, - { - "filename": "nemotron-3.5-asr-streaming-0.6b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 559647200 - }, - { - "filename": "nemotron-3.5-asr-streaming-0.6b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 621356512 - }, - { - "filename": "nemotron-3.5-asr-streaming-0.6b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 751094240 - }, - { - "filename": "nemotron-3.5-asr-streaming-0.6b-F16.gguf", - "quant": "F16", - "size_bytes": 1277750240 - }, - { - "filename": "nemotron-3.5-asr-streaming-0.6b-F32.gguf", - "quant": "F32", - "size_bytes": 2552277984 - } + {"filename": "nemotron-3.5-asr-streaming-0.6b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 495831520, "sha256": "41c99fa5fb6f3d35f68e79adc3e755eca2232a8d921178bd647b71194792b8fd"}, + {"filename": "nemotron-3.5-asr-streaming-0.6b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 559647200, "sha256": "86429e8c4f7fdcf9b3312269ad1ca6669478ba7805331c4aea7a2e33e9910d65"}, + {"filename": "nemotron-3.5-asr-streaming-0.6b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 621356512, "sha256": "4ff802c6207c4a7df23242003fd2aa849a1ab02bba6bc80c3db02e7e82606c28"}, + {"filename": "nemotron-3.5-asr-streaming-0.6b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 751094240, "sha256": "b94545b313b3223fda7b2857a52681da813935c2127643d1e9ff0c23d988089c"}, + {"filename": "nemotron-3.5-asr-streaming-0.6b-F16.gguf", "quant": "F16", "size_bytes": 1277750240, "sha256": "f21a0cea64d232981def7f8f2b7ab322459703a2e89359962c042c57159755b1"}, + {"filename": "nemotron-3.5-asr-streaming-0.6b-F32.gguf", "quant": "F32", "size_bytes": 2552277984, "sha256": "fbbc82e8e1084301a670fbc4d79c69c0c8980da506352fd83e74a182f38f5b78"} ], "default_quant": "Q8_0", "recommended": true, @@ -116,6 +73,7 @@ }, { "id": "handy-computer/canary-180m-flash-gguf", + "revision": "b147f9dc52b59f0998e410540a84727bd86457fd", "slug": "canary-180m-flash", "name": "Canary 180M Flash", "architecture": "canary", @@ -135,36 +93,12 @@ "speed_score": 98, "accuracy_score": 88, "files": [ - { - "filename": "canary-180m-flash-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 139223744 - }, - { - "filename": "canary-180m-flash-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 158704320 - }, - { - "filename": "canary-180m-flash-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 176291520 - }, - { - "filename": "canary-180m-flash-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 218447552 - }, - { - "filename": "canary-180m-flash-F16.gguf", - "quant": "F16", - "size_bytes": 381632192 - }, - { - "filename": "canary-180m-flash-F32.gguf", - "quant": "F32", - "size_bytes": 756498112 - } + {"filename": "canary-180m-flash-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 139223744, "sha256": "c8ae5758d7d4dc59c48d816474a33544e362ca52c635f96ee0b4b95e1b48f90c"}, + {"filename": "canary-180m-flash-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 158704320, "sha256": "a87992d84aea5329fa5d70f2eb440d3ae4fe47bd774875374ec381472d348299"}, + {"filename": "canary-180m-flash-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 176291520, "sha256": "be28948a5f6d93d59d0edc1f9580936ea30b23296130da02b719aee9f3a59e96"}, + {"filename": "canary-180m-flash-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 218447552, "sha256": "e13c7f5d0952b056a027cfffec13e3a3a134d1608babed24f983568f141e297c"}, + {"filename": "canary-180m-flash-F16.gguf", "quant": "F16", "size_bytes": 381632192, "sha256": "8042593b341836ac711a617cd483bb2ab7fe05eae14ace27b2488d8ce90ab017"}, + {"filename": "canary-180m-flash-F32.gguf", "quant": "F32", "size_bytes": 756498112, "sha256": "f42fce02dc5279545cd540f0e5d51e36b9335b69e11fd98de6e08cc1250fd7ad"} ], "default_quant": "Q8_0", "recommended": true, @@ -172,6 +106,7 @@ }, { "id": "handy-computer/cohere-transcribe-03-2026-gguf", + "revision": "dfa4adebb64f3076b7b6b90b721275cc069cb421", "slug": "cohere-transcribe-03-2026", "name": "Cohere Transcribe", "architecture": "cohere_asr", @@ -191,36 +126,12 @@ "speed_score": 63, "accuracy_score": 92, "files": [ - { - "filename": "cohere-transcribe-03-2026-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1558162944 - }, - { - "filename": "cohere-transcribe-03-2026-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1770270208 - }, - { - "filename": "cohere-transcribe-03-2026-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1972524544 - }, - { - "filename": "cohere-transcribe-03-2026-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2410655232 - }, - { - "filename": "cohere-transcribe-03-2026-BF16.gguf", - "quant": "BF16", - "size_bytes": 4105263104 - }, - { - "filename": "cohere-transcribe-03-2026-F16.gguf", - "quant": "F16", - "size_bytes": 4106644992 - } + {"filename": "cohere-transcribe-03-2026-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1558162944, "sha256": "0ea56826d8bd5d74b7143a4a04e022dc1bb75452cfae49d98b6acb0c1d16a1fb"}, + {"filename": "cohere-transcribe-03-2026-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1770270208, "sha256": "14d02f1ad6dd77b3a60f82639879012c3adb4fe25c50a5a47a2c4c661daf1558"}, + {"filename": "cohere-transcribe-03-2026-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1972524544, "sha256": "094fb65b9d82d4a52db1d944152e4d7b6629f356bb3beaffdd0891eb527f24f3"}, + {"filename": "cohere-transcribe-03-2026-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2410655232, "sha256": "931916663432fd895423a4291a8400221802b288967ca2d435fc5e3141c9e71e"}, + {"filename": "cohere-transcribe-03-2026-BF16.gguf", "quant": "BF16", "size_bytes": 4105263104, "sha256": "72e7625abc358da7b1a27348b18269b4a026eceb792e6ade53c6bb2280d0f3a7"}, + {"filename": "cohere-transcribe-03-2026-F16.gguf", "quant": "F16", "size_bytes": 4106644992, "sha256": "65ea095ba78ed938a613cc950da0599b29e16bb5a22a968d7b1fd99d71f13784"} ], "default_quant": "Q5_K_M", "recommended": true, @@ -228,6 +139,7 @@ }, { "id": "handy-computer/whisper-medium-gguf", + "revision": "ec78f06fded51aa82cde751678b78f76f78c8b7f", "slug": "whisper-medium", "name": "Whisper Medium", "architecture": "whisper", @@ -247,36 +159,12 @@ "speed_score": 42, "accuracy_score": 84, "files": [ - { - "filename": "whisper-medium-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 504102848 - }, - { - "filename": "whisper-medium-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 582746048 - }, - { - "filename": "whisper-medium-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 648019904 - }, - { - "filename": "whisper-medium-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 831538144 - }, - { - "filename": "whisper-medium-F16.gguf", - "quant": "F16", - "size_bytes": 1541931424 - }, - { - "filename": "whisper-medium-F32.gguf", - "quant": "F32", - "size_bytes": 3057437088 - } + {"filename": "whisper-medium-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 504102848, "sha256": "6c9cad9c41d1fa0fcc624898f7ee3485b66127627878081a5655c6f6f3ce20ab"}, + {"filename": "whisper-medium-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 582746048, "sha256": "4e2a8904a866b3aa7ef70d7640ec6abc5f0a05524cd950ea4b66ace12122bf53"}, + {"filename": "whisper-medium-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 648019904, "sha256": "8718bafef4f595b5cacd9d9409fb7e19ce99b1afe4c6a450198d692fa58bd380"}, + {"filename": "whisper-medium-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 831538144, "sha256": "09e6a65e7de377aa5b10bae24608bc6f8ca2ed04b3993ef10d4a02bcd9a82adf"}, + {"filename": "whisper-medium-F16.gguf", "quant": "F16", "size_bytes": 1541931424, "sha256": "62338e5194cb9ccc6734adf6f42694805a98a158028b2022e39ec060559bd517"}, + {"filename": "whisper-medium-F32.gguf", "quant": "F32", "size_bytes": 3057437088, "sha256": "d716aa436e8d1d3164898d1fd6f4bde0f7cbbb7059b8c276b18f78cbca2faf6b"} ], "default_quant": "Q8_0", "recommended": true, @@ -284,6 +172,7 @@ }, { "id": "handy-computer/Voxtral-Mini-4B-Realtime-2602-gguf", + "revision": "b3e1c979e3775cbd0a49a65878a0ec7f06789ed7", "slug": "Voxtral-Mini-4B-Realtime-2602", "name": "Voxtral Mini 4B Realtime", "architecture": "voxtral_realtime", @@ -303,36 +192,12 @@ "speed_score": 11, "accuracy_score": 87, "files": [ - { - "filename": "Voxtral-Mini-4B-Realtime-2602-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 2830493984 - }, - { - "filename": "Voxtral-Mini-4B-Realtime-2602-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 3281439008 - }, - { - "filename": "Voxtral-Mini-4B-Realtime-2602-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 3661018912 - }, - { - "filename": "Voxtral-Mini-4B-Realtime-2602-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 4731791648 - }, - { - "filename": "Voxtral-Mini-4B-Realtime-2602-BF16.gguf", - "quant": "BF16", - "size_bytes": 8868301088 - }, - { - "filename": "Voxtral-Mini-4B-Realtime-2602-F16.gguf", - "quant": "F16", - "size_bytes": 8879114528 - } + {"filename": "Voxtral-Mini-4B-Realtime-2602-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 2830493984, "sha256": "39dc1f65539373a406edea7490505822d77c12edff521744678717eef4da4723"}, + {"filename": "Voxtral-Mini-4B-Realtime-2602-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 3281439008, "sha256": "e20a7582c5cf8159454c909b7f57b184b287cb3d7c3ec85744727a2cec51b08f"}, + {"filename": "Voxtral-Mini-4B-Realtime-2602-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 3661018912, "sha256": "92038784cffa20ea8ee925f10fd28c110285124d94730eacce48bab6c214fa48"}, + {"filename": "Voxtral-Mini-4B-Realtime-2602-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 4731791648, "sha256": "6fb22249463c1e7a600d920ad732e2dd0a231510e9db59fb8061d3d1d48d0c67"}, + {"filename": "Voxtral-Mini-4B-Realtime-2602-BF16.gguf", "quant": "BF16", "size_bytes": 8868301088, "sha256": "dca6a7bece55858472e1b2df1f928642bdc1e964b1a91f3378c3a4e72fc5bc04"}, + {"filename": "Voxtral-Mini-4B-Realtime-2602-F16.gguf", "quant": "F16", "size_bytes": 8879114528, "sha256": "243258ae0ad80f748b18cfd9c8141998157d74eb32fbb131ba8f69e0c01564d7"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -340,6 +205,7 @@ }, { "id": "handy-computer/parakeet-tdt-0.6b-v3-gguf", + "revision": "85ac09ea12fc4b1112fa76810059364bc6adc9de", "slug": "parakeet-tdt-0.6b-v3", "name": "Parakeet TDT 0.6B v3", "architecture": "parakeet", @@ -359,36 +225,12 @@ "speed_score": 79, "accuracy_score": 88, "files": [ - { - "filename": "parakeet-tdt-0.6b-v3-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 485425504 - }, - { - "filename": "parakeet-tdt-0.6b-v3-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 548946272 - }, - { - "filename": "parakeet-tdt-0.6b-v3-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 610342240 - }, - { - "filename": "parakeet-tdt-0.6b-v3-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 739508576 - }, - { - "filename": "parakeet-tdt-0.6b-v3-F16.gguf", - "quant": "F16", - "size_bytes": 1255869856 - }, - { - "filename": "parakeet-tdt-0.6b-v3-F32.gguf", - "quant": "F32", - "size_bytes": 2508435616 - } + {"filename": "parakeet-tdt-0.6b-v3-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 485425504, "sha256": "b68557be1e3c40207fd7c4bd9d63f1d3316b963f15325bfb0cc16a8bb0ffd181"}, + {"filename": "parakeet-tdt-0.6b-v3-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 548946272, "sha256": "cc722e76adc1a629fc0b2535de879d99b8160d07ad4c0215e2ca7d7ea0ae4b8f"}, + {"filename": "parakeet-tdt-0.6b-v3-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 610342240, "sha256": "ab1bbcbe3e1c4c2ec12ca1df1aeafd22e9043ce7262e4b5f5c5d7d76418a9ae8"}, + {"filename": "parakeet-tdt-0.6b-v3-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 739508576, "sha256": "5859f77944efcd8eafa23a6350731960b2b55b2203df51f319665c807d802cc7"}, + {"filename": "parakeet-tdt-0.6b-v3-F16.gguf", "quant": "F16", "size_bytes": 1255869856, "sha256": "d9ec7e2c39da7b4fec3e01e71e82c7f7bbe97741e08c0c054091bf3d520a41d3"}, + {"filename": "parakeet-tdt-0.6b-v3-F32.gguf", "quant": "F32", "size_bytes": 2508435616, "sha256": "bbd36acd8c6fbb817e658e3acc9f75002cb6a74932c687e74fefccfcc7b9f47e"} ], "default_quant": "Q8_0", "recommended": false, @@ -396,6 +238,7 @@ }, { "id": "handy-computer/parakeet-tdt-0.6b-v2-gguf", + "revision": "07cee0616125a08ef619729bb47f40ef747e4bc4", "slug": "parakeet-tdt-0.6b-v2", "name": "Parakeet TDT 0.6B v2", "architecture": "parakeet", @@ -415,36 +258,12 @@ "speed_score": 85, "accuracy_score": 89, "files": [ - { - "filename": "parakeet-tdt-0.6b-v2-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 475491840 - }, - { - "filename": "parakeet-tdt-0.6b-v2-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 539012608 - }, - { - "filename": "parakeet-tdt-0.6b-v2-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 600408576 - }, - { - "filename": "parakeet-tdt-0.6b-v2-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 729574912 - }, - { - "filename": "parakeet-tdt-0.6b-v2-F16.gguf", - "quant": "F16", - "size_bytes": 1237334592 - }, - { - "filename": "parakeet-tdt-0.6b-v2-F32.gguf", - "quant": "F32", - "size_bytes": 2471550272 - } + {"filename": "parakeet-tdt-0.6b-v2-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 475491840, "sha256": "4853f9653f641d376e6f7de65d73c7a34a73677704a606727bf51acc83f999f3"}, + {"filename": "parakeet-tdt-0.6b-v2-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 539012608, "sha256": "dfa904520b95451599683613fea47766c378c5fdf5d8cddf48151226b6eaec85"}, + {"filename": "parakeet-tdt-0.6b-v2-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 600408576, "sha256": "44c7c363438e03b613cf8ced8b7e6fd1beb2b3805008136fe5e922ab3c79eea9"}, + {"filename": "parakeet-tdt-0.6b-v2-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 729574912, "sha256": "f0d0e99cebb6d3b83f1f7069b82b5d3c2e39a54545b0da039cb4bafd9c4e5caa"}, + {"filename": "parakeet-tdt-0.6b-v2-F16.gguf", "quant": "F16", "size_bytes": 1237334592, "sha256": "8440125213cb92625e3993ec751c3919dd329da9ae9149951cd6f95dd8bafe81"}, + {"filename": "parakeet-tdt-0.6b-v2-F32.gguf", "quant": "F32", "size_bytes": 2471550272, "sha256": "501ba1e61965cdb28689614c648d2c560d75895b47a2948b9010b4e8c9cf1325"} ], "default_quant": "Q8_0", "recommended": false, @@ -452,6 +271,7 @@ }, { "id": "handy-computer/Qwen3-ASR-0.6B-gguf", + "revision": "e4e16599b900eb0cb36e524514756bb92eb092b7", "slug": "Qwen3-ASR-0.6B", "name": "Qwen3-ASR 0.6B", "architecture": "qwen3_asr", @@ -471,36 +291,12 @@ "speed_score": 63, "accuracy_score": 87, "files": [ - { - "filename": "Qwen3-ASR-0.6B-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 589560480 - }, - { - "filename": "Qwen3-ASR-0.6B-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 645356192 - }, - { - "filename": "Qwen3-ASR-0.6B-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 690417824 - }, - { - "filename": "Qwen3-ASR-0.6B-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 850423456 - }, - { - "filename": "Qwen3-ASR-0.6B-BF16.gguf", - "quant": "BF16", - "size_bytes": 1571490016 - }, - { - "filename": "Qwen3-ASR-0.6B-F16.gguf", - "quant": "F16", - "size_bytes": 1579793056 - } + {"filename": "Qwen3-ASR-0.6B-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 589560480, "sha256": "5b58f32a58ffa2c8783e0b0963485623e286e6272d953dfc9e28bc3447dee0c0"}, + {"filename": "Qwen3-ASR-0.6B-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 645356192, "sha256": "062a7bcb18675c2fe5ffd0e8b354eac6a1ceada9b2af9ef7168746ae16b54358"}, + {"filename": "Qwen3-ASR-0.6B-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 690417824, "sha256": "3b051f108f03c0c91bbe1a3b2c1ee15e3ed51e4caec2a48751b01f2a21441cc3"}, + {"filename": "Qwen3-ASR-0.6B-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 850423456, "sha256": "f081b2d5e23bd669d92cc331d722a8a0681943b8e6f34b48996fd5c319b5acd8"}, + {"filename": "Qwen3-ASR-0.6B-BF16.gguf", "quant": "BF16", "size_bytes": 1571490016, "sha256": "dadb8196937aa6b998a94124a043fce694e0ab6c3c6eb0ae1669fa49d04aab84"}, + {"filename": "Qwen3-ASR-0.6B-F16.gguf", "quant": "F16", "size_bytes": 1579793056, "sha256": "5c90e4b1a72a4c59cd12afa5ebb0cc8628848148f2b337d025cc5121ae4d2eea"} ], "default_quant": "Q8_0", "recommended": false, @@ -508,6 +304,7 @@ }, { "id": "handy-computer/Fun-ASR-MLT-Nano-2512-gguf", + "revision": "0b8f9c7bc545a219658aeb1dd4eeaa55d1cf89f3", "slug": "Fun-ASR-MLT-Nano-2512", "name": "Fun-ASR Nano Multilingual", "architecture": "funasr_nano", @@ -527,36 +324,12 @@ "speed_score": 68, "accuracy_score": 89, "files": [ - { - "filename": "Fun-ASR-MLT-Nano-2512-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 556975168 - }, - { - "filename": "Fun-ASR-MLT-Nano-2512-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 631129152 - }, - { - "filename": "Fun-ASR-MLT-Nano-2512-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 690744384 - }, - { - "filename": "Fun-ASR-MLT-Nano-2512-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 891271232 - }, - { - "filename": "Fun-ASR-MLT-Nano-2512-BF16.gguf", - "quant": "BF16", - "size_bytes": 1667504192 - }, - { - "filename": "Fun-ASR-MLT-Nano-2512-F16.gguf", - "quant": "F16", - "size_bytes": 1667504192 - } + {"filename": "Fun-ASR-MLT-Nano-2512-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 556975168, "sha256": "9232584a27a7dcbcaf13640de8f9c2e7375178ea11e587f90a5328ecf06d58a1"}, + {"filename": "Fun-ASR-MLT-Nano-2512-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 631129152, "sha256": "5906267c6067d254a2681f40d9eac70c4291ae14fb58ffea4c4cd22a307517ec"}, + {"filename": "Fun-ASR-MLT-Nano-2512-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 690744384, "sha256": "e4ca67cbcb9b12c828b2afbadc871a8128ea3e73890d2747c687b1fac0ac6934"}, + {"filename": "Fun-ASR-MLT-Nano-2512-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 891271232, "sha256": "d12476d8d9f2baa0ebf738fa955fa05ed33a654f1567289033a810c45d9d9002"}, + {"filename": "Fun-ASR-MLT-Nano-2512-BF16.gguf", "quant": "BF16", "size_bytes": 1667504192, "sha256": "dcacaf5eb69651e4e3f1f79e9bdd16be518cf5f9ecaf023dada9c5aa891bde4b"}, + {"filename": "Fun-ASR-MLT-Nano-2512-F16.gguf", "quant": "F16", "size_bytes": 1667504192, "sha256": "ffd1e5258f2cfbc05dc3e467b570a32d5671a6511bcd070cd72f08657108213d"} ], "default_quant": "Q8_0", "recommended": false, @@ -564,6 +337,7 @@ }, { "id": "handy-computer/canary-1b-flash-gguf", + "revision": "b427664769b93c021df108a2fa8bfb858ae236c1", "slug": "canary-1b-flash", "name": "Canary 1B Flash", "architecture": "canary", @@ -583,36 +357,12 @@ "speed_score": 83, "accuracy_score": 90, "files": [ - { - "filename": "canary-1b-flash-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 677141280 - }, - { - "filename": "canary-1b-flash-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 769563424 - }, - { - "filename": "canary-1b-flash-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 857603872 - }, - { - "filename": "canary-1b-flash-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1048131360 - }, - { - "filename": "canary-1b-flash-F16.gguf", - "quant": "F16", - "size_bytes": 1785657120 - }, - { - "filename": "canary-1b-flash-F32.gguf", - "quant": "F32", - "size_bytes": 3560372000 - } + {"filename": "canary-1b-flash-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 677141280, "sha256": "2521a615a2b04ab11900d894e1f5c5a70405d85aa74be0806b569b9cc311a707"}, + {"filename": "canary-1b-flash-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 769563424, "sha256": "7eed3cac92f255a4adbd518c58663d3fbf65984d2619189e593f2d374b05c601"}, + {"filename": "canary-1b-flash-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 857603872, "sha256": "3faeb99e16c719047df92fbdcc501ab5cd00edfac83a5e6e7cd69b470d650730"}, + {"filename": "canary-1b-flash-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1048131360, "sha256": "9b99e0881d883467e0a03ceb0968dba888c9f8921b73355143ad8b67931e08ce"}, + {"filename": "canary-1b-flash-F16.gguf", "quant": "F16", "size_bytes": 1785657120, "sha256": "e87e172694fe6d808fa8844961fb7bc77a4a22063936227c62ac76b6fdbf3dcd"}, + {"filename": "canary-1b-flash-F32.gguf", "quant": "F32", "size_bytes": 3560372000, "sha256": "888c24f7d67c3c60d00003bf6a1e3123822afbc47927b7ca931d0b4aaf45305b"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -620,6 +370,7 @@ }, { "id": "handy-computer/canary-1b-v2-gguf", + "revision": "58d13c2c0102229aad45f7e19a77ddc42b41dd9a", "slug": "canary-1b-v2", "name": "Canary 1B v2", "architecture": "canary", @@ -639,36 +390,12 @@ "speed_score": 81, "accuracy_score": 88, "files": [ - { - "filename": "canary-1b-v2-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 735476448 - }, - { - "filename": "canary-1b-v2-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 836664032 - }, - { - "filename": "canary-1b-v2-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 931986144 - }, - { - "filename": "canary-1b-v2-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1144290016 - }, - { - "filename": "canary-1b-v2-F16.gguf", - "quant": "F16", - "size_bytes": 1966111456 - }, - { - "filename": "canary-1b-v2-F32.gguf", - "quant": "F32", - "size_bytes": 3920657120 - } + {"filename": "canary-1b-v2-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 735476448, "sha256": "49e0a67e219bec95a254c2348460b6350e75a7ac6f93a131e48244b4c7cb53b9"}, + {"filename": "canary-1b-v2-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 836664032, "sha256": "9c3a893c93795438baf9b4b1c853c39b60316c3a0d259a3ba6e284712f5ddb71"}, + {"filename": "canary-1b-v2-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 931986144, "sha256": "6567c2d601fccacf9809fc230edefde05728340f65860bbe91cfd72d0a5eea9d"}, + {"filename": "canary-1b-v2-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1144290016, "sha256": "224f83d1bc487b3303b495a7d6874912fdece93de19d1a04b550829c30a5d289"}, + {"filename": "canary-1b-v2-F16.gguf", "quant": "F16", "size_bytes": 1966111456, "sha256": "eadda53cd1652d65cd12ff7ac4b7dc64cba1ce9837aae0c86e4222e8db89e320"}, + {"filename": "canary-1b-v2-F32.gguf", "quant": "F32", "size_bytes": 3920657120, "sha256": "b0b48b62b2c68f2779c49481de102715c5eaadbe084bca7fdddc7912f9c20e8b"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -676,6 +403,7 @@ }, { "id": "handy-computer/canary-1b-gguf", + "revision": "f2eaa93b2221b6a28e5a00111a3e93f62964dff3", "slug": "canary-1b", "name": "Canary 1B", "architecture": "canary", @@ -695,36 +423,12 @@ "speed_score": 67, "accuracy_score": 90, "files": [ - { - "filename": "canary-1b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 729686848 - }, - { - "filename": "canary-1b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 837694272 - }, - { - "filename": "canary-1b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 934167616 - }, - { - "filename": "canary-1b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1162740288 - }, - { - "filename": "canary-1b-F16.gguf", - "quant": "F16", - "size_bytes": 2047537728 - }, - { - "filename": "canary-1b-F32.gguf", - "quant": "F32", - "size_bytes": 4086100544 - } + {"filename": "canary-1b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 729686848, "sha256": "ded40db7f9d25e992bf35a0c3c3743e1a7c85d0c30bdbbabd46bca932a47353d"}, + {"filename": "canary-1b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 837694272, "sha256": "1139f01aad4ed4208d2c0532cfc25939cbc26cea1e160084c83f40d5b6eaed35"}, + {"filename": "canary-1b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 934167616, "sha256": "ac3a38b09f78e7cfc5ecfcf75b6dd5ca7c77c17669c9c5d6a5f1fb3917b75c73"}, + {"filename": "canary-1b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1162740288, "sha256": "0b5d28516bcde9402d5eba34fc4a79c95b84b27954f05e5f8993d67df2857b62"}, + {"filename": "canary-1b-F16.gguf", "quant": "F16", "size_bytes": 2047537728, "sha256": "f40216c503fe14d98b384c31ab18e705fb9c004cb8e6c9976962aae2a369e5ea"}, + {"filename": "canary-1b-F32.gguf", "quant": "F32", "size_bytes": 4086100544, "sha256": "b3ec64ca239c1266f83dc8d15ebf6d4b09b2b5aa72a972d3dea1a4c2f3e1fa37"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -732,6 +436,7 @@ }, { "id": "handy-computer/canary-qwen-2.5b-gguf", + "revision": "3370d4e2f28cc70eea79dfc9f2f43fb91eef3163", "slug": "canary-qwen-2.5b", "name": "Canary-Qwen 2.5B", "architecture": "canary_qwen", @@ -751,36 +456,45 @@ "speed_score": 40, "accuracy_score": 90, "files": [ - { - "filename": "canary-qwen-2.5b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1737575808 - }, - { - "filename": "canary-qwen-2.5b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1983729024 - }, - { - "filename": "canary-qwen-2.5b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 2208697728 - }, - { - "filename": "canary-qwen-2.5b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2797548928 - }, - { - "filename": "canary-qwen-2.5b-BF16.gguf", - "quant": "BF16", - "size_bytes": 5076107136 - }, - { - "filename": "canary-qwen-2.5b-F16.gguf", - "quant": "F16", - "size_bytes": 5076972928 - } + {"filename": "canary-qwen-2.5b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1737575808, "sha256": "db5162229d6fa22597d06a613bd9b543eddb3ee02e6afc5e759120fde02bebf7"}, + {"filename": "canary-qwen-2.5b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1983729024, "sha256": "d71b1d9ae442dc538665ed4df63a69520eb9be769ec0bc78c7d2f40040928306"}, + {"filename": "canary-qwen-2.5b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 2208697728, "sha256": "25dba2a425113e73d626cc3db6148d925cbd7ead6872b631b7ef4d927788be31"}, + {"filename": "canary-qwen-2.5b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2797548928, "sha256": "d89aad1285d5bd5aa441c464d3a4cf37bd5474f70705408e71558d8627415b34"}, + {"filename": "canary-qwen-2.5b-BF16.gguf", "quant": "BF16", "size_bytes": 5076107136, "sha256": "5b29334e1d257503b95436608519c39436f0e2056251d17b7c5c001f862c00b8"}, + {"filename": "canary-qwen-2.5b-F16.gguf", "quant": "F16", "size_bytes": 5076972928, "sha256": "ae310ac25c1f190b8df4b29a301e936440e1d5306c23d6e5352e29400338ad87"} + ], + "default_quant": "Q5_K_M", + "recommended": false, + "recommended_rank": null + }, + { + "id": "handy-computer/cohere-transcribe-arabic-07-2026-gguf", + "revision": "715cbe09ca9b60fc1e497db1e4478fa9fcf4ac21", + "slug": "cohere-transcribe-arabic-07-2026", + "name": "Cohere Transcribe", + "architecture": "cohere_asr", + "family": "cohere", + "parameters": "2.0B", + "description": "2-language speech-to-text.", + "base_model": "CohereLabs/cohere-transcribe-arabic-07-2026", + "license": "apache-2.0", + "language_count": 2, + "languages": ["ar", "en"], + "capabilities": { + "streaming": false, + "translate": false, + "lang_detect": false, + "timestamps": "none" + }, + "speed_score": 63, + "accuracy_score": 48, + "files": [ + {"filename": "cohere-transcribe-arabic-07-2026-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1558162848, "sha256": "4671080cd10ffc6166e1e1b36216f9b51e1455f170d55c59cff7b81332ad4621"}, + {"filename": "cohere-transcribe-arabic-07-2026-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1770270112, "sha256": "55e61c9b047e36f0e084d367f6b0bfecc71a6a0527da6eea4f0c687f3584775f"}, + {"filename": "cohere-transcribe-arabic-07-2026-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1972524448, "sha256": "f0fc615d27da3236166cd68cf9968c3be7d257b953bbba3b2e10e5d9f3fd1ff4"}, + {"filename": "cohere-transcribe-arabic-07-2026-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2410655136, "sha256": "910de5c9c57f9fd8a280e1701f9cd96f63768878c1ca9e4ecf23638a5e0fef16"}, + {"filename": "cohere-transcribe-arabic-07-2026-BF16.gguf", "quant": "BF16", "size_bytes": 4105263008, "sha256": "4eade55ddc6ef7d9d1caeee0fa412d161212910c30ee2aa82d95725e0dfdf0a2"}, + {"filename": "cohere-transcribe-arabic-07-2026-F16.gguf", "quant": "F16", "size_bytes": 4106644896, "sha256": "c8a37345c10d1cb3b85d7d1779905e63051f39694bd6fdd5002d9434c09226fc"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -788,6 +502,7 @@ }, { "id": "handy-computer/Fun-ASR-Nano-2512-gguf", + "revision": "30360f003f99929c1e25c3f6222d02eccb04663c", "slug": "Fun-ASR-Nano-2512", "name": "Fun-ASR Nano", "architecture": "funasr_nano", @@ -807,36 +522,12 @@ "speed_score": 73, "accuracy_score": 89, "files": [ - { - "filename": "Fun-ASR-Nano-2512-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 556974848 - }, - { - "filename": "Fun-ASR-Nano-2512-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 631128832 - }, - { - "filename": "Fun-ASR-Nano-2512-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 690744064 - }, - { - "filename": "Fun-ASR-Nano-2512-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 891270912 - }, - { - "filename": "Fun-ASR-Nano-2512-BF16.gguf", - "quant": "BF16", - "size_bytes": 1667503872 - }, - { - "filename": "Fun-ASR-Nano-2512-F16.gguf", - "quant": "F16", - "size_bytes": 1667503872 - } + {"filename": "Fun-ASR-Nano-2512-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 556974848, "sha256": "444885fb7a16211a721e265dfe08c31b31e81fb9e2c4f21ab1079210cd038408"}, + {"filename": "Fun-ASR-Nano-2512-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 631128832, "sha256": "68de7b48ba46b739254fb57486edf7023943cb0af63767ab795d58d6e482096c"}, + {"filename": "Fun-ASR-Nano-2512-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 690744064, "sha256": "894153c04fee18f982823e67b9be6ffd83a9e245f92419b3e8170e26c818273b"}, + {"filename": "Fun-ASR-Nano-2512-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 891270912, "sha256": "681caef6df15a2c0e153b40ca7fe4087fdf65751fa5e6fe605d8a75dff969e61"}, + {"filename": "Fun-ASR-Nano-2512-BF16.gguf", "quant": "BF16", "size_bytes": 1667503872, "sha256": "ee183ddfeb19847e3ee7b80ffcbbae48173330dc167b07955b898a7705c68159"}, + {"filename": "Fun-ASR-Nano-2512-F16.gguf", "quant": "F16", "size_bytes": 1667503872, "sha256": "d754b627ebe8386e565ca9aabc87906d7530d73d2981ec7106fbcc3932febc16"} ], "default_quant": "Q8_0", "recommended": false, @@ -844,6 +535,7 @@ }, { "id": "handy-computer/gigaam-v3-ctc-gguf", + "revision": "c3c611444004820c21c3b68312a41c83c1e4813b", "slug": "gigaam-v3-ctc", "name": "GigaAM v3 CTC", "architecture": "gigaam", @@ -863,36 +555,12 @@ "speed_score": 98, "accuracy_score": 57, "files": [ - { - "filename": "gigaam-v3-ctc-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 182150080 - }, - { - "filename": "gigaam-v3-ctc-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 204563392 - }, - { - "filename": "gigaam-v3-ctc-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 226091968 - }, - { - "filename": "gigaam-v3-ctc-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 271803328 - }, - { - "filename": "gigaam-v3-ctc-F16.gguf", - "quant": "F16", - "size_bytes": 448750528 - }, - { - "filename": "gigaam-v3-ctc-F32.gguf", - "quant": "F32", - "size_bytes": 882913216 - } + {"filename": "gigaam-v3-ctc-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 182150080, "sha256": "16b329fed3eaa197f2191506ac3cad72f46502297ed6a0f915c79d2ac08c404e"}, + {"filename": "gigaam-v3-ctc-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 204563392, "sha256": "2002cd7603b99c318b52a88047d6676e69218083014f19b11c84da0223fb2023"}, + {"filename": "gigaam-v3-ctc-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 226091968, "sha256": "7556f1c8b9ed0f93b3e224d14a954b51cc331c5a3d2be0f5988bd0523b962c7f"}, + {"filename": "gigaam-v3-ctc-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 271803328, "sha256": "71e5c82890e9e243a6bd7575f129f5d1bd2c3ca3ae79aab75cfb1a6934c6a62b"}, + {"filename": "gigaam-v3-ctc-F16.gguf", "quant": "F16", "size_bytes": 448750528, "sha256": "1d958ddf73abf722e320ee36212190e9bdfc6bfdc89778d2e4deeaaeb5bce443"}, + {"filename": "gigaam-v3-ctc-F32.gguf", "quant": "F32", "size_bytes": 882913216, "sha256": "0feb1fdebf57d6b16769391e23d87e8bdba69549b6cf8864cbcbaa3b0f9caab6"} ], "default_quant": "Q8_0", "recommended": false, @@ -900,6 +568,7 @@ }, { "id": "handy-computer/gigaam-v3-e2e-ctc-gguf", + "revision": "075dff81f843cf23d22b4ce943ffdc4dd8650cd7", "slug": "gigaam-v3-e2e-ctc", "name": "GigaAM v3 E2E-CTC", "architecture": "gigaam", @@ -919,36 +588,12 @@ "speed_score": 98, "accuracy_score": 69, "files": [ - { - "filename": "gigaam-v3-e2e-ctc-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 182497888 - }, - { - "filename": "gigaam-v3-e2e-ctc-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 204911200 - }, - { - "filename": "gigaam-v3-e2e-ctc-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 226439776 - }, - { - "filename": "gigaam-v3-e2e-ctc-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 272151136 - }, - { - "filename": "gigaam-v3-e2e-ctc-F16.gguf", - "quant": "F16", - "size_bytes": 449098336 - }, - { - "filename": "gigaam-v3-e2e-ctc-F32.gguf", - "quant": "F32", - "size_bytes": 883603552 - } + {"filename": "gigaam-v3-e2e-ctc-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 182497888, "sha256": "35581e11e048eef785657cff07e5fced794bba6d9c75143257f6452c8aeea655"}, + {"filename": "gigaam-v3-e2e-ctc-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 204911200, "sha256": "d7c6a729f9bae8714d05cb7e66149e662c5098fe10b66206b70f4917f4f84004"}, + {"filename": "gigaam-v3-e2e-ctc-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 226439776, "sha256": "cb517247ae37589c5b281d6d9331ffb32124c77b789affe46a272be1428a5260"}, + {"filename": "gigaam-v3-e2e-ctc-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 272151136, "sha256": "9ccce4750dc813a493d96ca15ee251712bedec15ac9a02fa3d2bd732f08ae5eb"}, + {"filename": "gigaam-v3-e2e-ctc-F16.gguf", "quant": "F16", "size_bytes": 449098336, "sha256": "25a2fd66a50912645307bca1489b8c07e9060ef079b61a7cce0041248cc89b4a"}, + {"filename": "gigaam-v3-e2e-ctc-F32.gguf", "quant": "F32", "size_bytes": 883603552, "sha256": "bf300323d1bc67bee5e9f177a5fae748d78077528c27de73ecc6d7a113e09ec5"} ], "default_quant": "Q8_0", "recommended": false, @@ -956,6 +601,7 @@ }, { "id": "handy-computer/gigaam-v3-rnnt-gguf", + "revision": "8f30356b4607ae2d79353ab7dc9e5eea6dc46b48", "slug": "gigaam-v3-rnnt", "name": "GigaAM v3 RNN-T", "architecture": "gigaam", @@ -975,36 +621,12 @@ "speed_score": 96, "accuracy_score": 58, "files": [ - { - "filename": "gigaam-v3-rnnt-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 183246752 - }, - { - "filename": "gigaam-v3-rnnt-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 205690784 - }, - { - "filename": "gigaam-v3-rnnt-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 227252000 - }, - { - "filename": "gigaam-v3-rnnt-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 273022880 - }, - { - "filename": "gigaam-v3-rnnt-F16.gguf", - "quant": "F16", - "size_bytes": 451084832 - }, - { - "filename": "gigaam-v3-rnnt-F32.gguf", - "quant": "F32", - "size_bytes": 887573536 - } + {"filename": "gigaam-v3-rnnt-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 183246752, "sha256": "33b09558330ccfdc5c156e6d6b57c89759a0ac3b653bcdfcd5f1e13f20fc1e76"}, + {"filename": "gigaam-v3-rnnt-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 205690784, "sha256": "3541b4e6b2c3909e99254bcb7c61565137038f24db5651a779885d0c08257348"}, + {"filename": "gigaam-v3-rnnt-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 227252000, "sha256": "ee329ee6ef93b5da62e1915927278a9930b04ac95b2735e7c80e64b725f3f82d"}, + {"filename": "gigaam-v3-rnnt-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 273022880, "sha256": "130d54450be2922144b265d90d453b781fc2b5adaee52a16954b00f3686201f7"}, + {"filename": "gigaam-v3-rnnt-F16.gguf", "quant": "F16", "size_bytes": 451084832, "sha256": "b1fe43be121c64934e464c2320c576d74de1466cb7e91f2d8c2aa1149ebe8572"}, + {"filename": "gigaam-v3-rnnt-F32.gguf", "quant": "F32", "size_bytes": 887573536, "sha256": "6e02744a354fd44e50af9a3cb555de417d7be7163ab79561ad7ff42796990df9"} ], "default_quant": "Q8_0", "recommended": false, @@ -1012,6 +634,7 @@ }, { "id": "handy-computer/gigaam-v3-e2e-rnnt-gguf", + "revision": "f719d70812344f4d0fb8c11c0887b190501a7465", "slug": "gigaam-v3-e2e-rnnt", "name": "GigaAM v3 E2E-RNN-T", "architecture": "gigaam", @@ -1031,36 +654,12 @@ "speed_score": 94, "accuracy_score": 70, "files": [ - { - "filename": "gigaam-v3-e2e-rnnt-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 183948704 - }, - { - "filename": "gigaam-v3-e2e-rnnt-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 206392736 - }, - { - "filename": "gigaam-v3-e2e-rnnt-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 227953952 - }, - { - "filename": "gigaam-v3-e2e-rnnt-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 273724832 - }, - { - "filename": "gigaam-v3-e2e-rnnt-F16.gguf", - "quant": "F16", - "size_bytes": 452381408 - }, - { - "filename": "gigaam-v3-e2e-rnnt-F32.gguf", - "quant": "F32", - "size_bytes": 890138592 - } + {"filename": "gigaam-v3-e2e-rnnt-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 183948704, "sha256": "7d69952fb431a8d7800ed9910dc61fea37d8406bfe96d10bf24c8bd4b7c68623"}, + {"filename": "gigaam-v3-e2e-rnnt-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 206392736, "sha256": "0f5969b05f7e196e8922e243c64963132025a5ae7ec2f83d028ae120d201594f"}, + {"filename": "gigaam-v3-e2e-rnnt-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 227953952, "sha256": "1ef0f771d9ab0b0675c45ce67a3e5b79ac7497fe35ebb08a51704c4ee423edce"}, + {"filename": "gigaam-v3-e2e-rnnt-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 273724832, "sha256": "78d63b47723b7f8d78c6113a6ef983b5a86e2a86f6c273e1f5cb6967b1c4467a"}, + {"filename": "gigaam-v3-e2e-rnnt-F16.gguf", "quant": "F16", "size_bytes": 452381408, "sha256": "87b12fa3523939d3c47c4b427f587e415dda41dcafa9e4ba17f2a1cab2f0d5ad"}, + {"filename": "gigaam-v3-e2e-rnnt-F32.gguf", "quant": "F32", "size_bytes": 890138592, "sha256": "0de6fa626b8a3429dc80e4b34c4b03c0e85b02e2eca4c654efbeb27a8a0a4c5b"} ], "default_quant": "Q8_0", "recommended": false, @@ -1068,6 +667,7 @@ }, { "id": "handy-computer/granite-speech-4.1-2b-nar-gguf", + "revision": "ca53e8273416eb7e888f19bcebbcb9b6ab3edc17", "slug": "granite-speech-4.1-2b-nar", "name": "Granite Speech 4.1 2B NAR", "architecture": "granite_speech_nar", @@ -1087,36 +687,12 @@ "speed_score": 37, "accuracy_score": 92, "files": [ - { - "filename": "granite-speech-4.1-2b-nar-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1560008832 - }, - { - "filename": "granite-speech-4.1-2b-nar-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1782089344 - }, - { - "filename": "granite-speech-4.1-2b-nar-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1977417568 - }, - { - "filename": "granite-speech-4.1-2b-nar-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2498105472 - }, - { - "filename": "granite-speech-4.1-2b-nar-BF16.gguf", - "quant": "BF16", - "size_bytes": 4514736032 - }, - { - "filename": "granite-speech-4.1-2b-nar-F16.gguf", - "quant": "F16", - "size_bytes": 4515792768 - } + {"filename": "granite-speech-4.1-2b-nar-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1560008832, "sha256": "68537a51d4d6d5a6be6e50f735a08af16646afba70fe8ab80dfe60ba7623325f"}, + {"filename": "granite-speech-4.1-2b-nar-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1782089344, "sha256": "88d7c7b5b8b59c95bb6580a1e7d5d81cae63943cef71405ff477527b0bb69fca"}, + {"filename": "granite-speech-4.1-2b-nar-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1977417568, "sha256": "cfdcdf223a8132ab58bd983df43e277d84056f2a4bf2115fdbc1d40d387f42e1"}, + {"filename": "granite-speech-4.1-2b-nar-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2498105472, "sha256": "4762df797b9efa1ce0d73d2f5128517c86fdfdba2e9d00cd5cca97e624760b21"}, + {"filename": "granite-speech-4.1-2b-nar-BF16.gguf", "quant": "BF16", "size_bytes": 4514736032, "sha256": "742575c3cea9ff717ad39b18cf635da7c0453f6238fc01baa687beae223ad7e5"}, + {"filename": "granite-speech-4.1-2b-nar-F16.gguf", "quant": "F16", "size_bytes": 4515792768, "sha256": "433c4925b3c7fdfea037a343eed60e4c9655acc25d293131ec6f71fd42dfe93f"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -1124,6 +700,7 @@ }, { "id": "handy-computer/granite-4.0-1b-speech-gguf", + "revision": "5899f364fb5bc4bae48c54dec8489aff40b70253", "slug": "granite-4.0-1b-speech", "name": "Granite Speech 4.0 1B", "architecture": "granite_speech", @@ -1143,36 +720,12 @@ "speed_score": 31, "accuracy_score": 91, "files": [ - { - "filename": "granite-4.0-1b-speech-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1602904800 - }, - { - "filename": "granite-4.0-1b-speech-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1829704544 - }, - { - "filename": "granite-4.0-1b-speech-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 2024967936 - }, - { - "filename": "granite-4.0-1b-speech-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2559878848 - }, - { - "filename": "granite-4.0-1b-speech-BF16.gguf", - "quant": "BF16", - "size_bytes": 4631640064 - }, - { - "filename": "granite-4.0-1b-speech-F16.gguf", - "quant": "F16", - "size_bytes": 4632623104 - } + {"filename": "granite-4.0-1b-speech-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1602904800, "sha256": "eacd9bc06063a3fdb6bf904a2a82c2646d504922c6e4e5eb1988d61e780884d1"}, + {"filename": "granite-4.0-1b-speech-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1829704544, "sha256": "f5e371d2a894e5b30dce793d573df2a965d7705488750b4ef5a6cad1069e2572"}, + {"filename": "granite-4.0-1b-speech-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 2024967936, "sha256": "b50af8f3c99767f87ccae800589688096678d086daba5e482f6da14e59e2f99c"}, + {"filename": "granite-4.0-1b-speech-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2559878848, "sha256": "1a48716efce9468bc99f33619e15942ae6a262f7ef51883c3c39b519302a533f"}, + {"filename": "granite-4.0-1b-speech-BF16.gguf", "quant": "BF16", "size_bytes": 4631640064, "sha256": "c10ceec82a87949a6192917aab6b75ca2df5a07faed1d05140a06787cae12a76"}, + {"filename": "granite-4.0-1b-speech-F16.gguf", "quant": "F16", "size_bytes": 4632623104, "sha256": "443e5f2cb8ab47dbef8ddaba20823ff5d928662565eb16b2164215f281ec4053"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -1180,6 +733,7 @@ }, { "id": "handy-computer/granite-speech-4.1-2b-gguf", + "revision": "58e7710fd7039ded5a185668eef5f71ca5d9d919", "slug": "granite-speech-4.1-2b", "name": "Granite Speech 4.1 2B", "architecture": "granite_speech", @@ -1199,36 +753,12 @@ "speed_score": 30, "accuracy_score": 92, "files": [ - { - "filename": "granite-speech-4.1-2b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1602904800 - }, - { - "filename": "granite-speech-4.1-2b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1829704544 - }, - { - "filename": "granite-speech-4.1-2b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 2024967936 - }, - { - "filename": "granite-speech-4.1-2b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2559878848 - }, - { - "filename": "granite-speech-4.1-2b-BF16.gguf", - "quant": "BF16", - "size_bytes": 4631640064 - }, - { - "filename": "granite-speech-4.1-2b-F16.gguf", - "quant": "F16", - "size_bytes": 4632623104 - } + {"filename": "granite-speech-4.1-2b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1602904800, "sha256": "3171de08a99315481f0eac1979cbc0684911631d59907b2fe1e189dbcdce135f"}, + {"filename": "granite-speech-4.1-2b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1829704544, "sha256": "63e0d3a82fa6f0f4688af0b7d7ee784864d271b7be820f4ea43c8298c59b0ac5"}, + {"filename": "granite-speech-4.1-2b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 2024967936, "sha256": "f2de8601facb83a5be83aa61198366b2e9cae739ded451c70abcd710f43d8877"}, + {"filename": "granite-speech-4.1-2b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2559878848, "sha256": "8c0b2dce2861feb1dba91e9d59f2ab4a9c23ad808bff86465151576e096ec7a6"}, + {"filename": "granite-speech-4.1-2b-BF16.gguf", "quant": "BF16", "size_bytes": 4631640064, "sha256": "6a0489017b2410b411e62460c20019573f1fb2163f44b2f92a892846d6b0c844"}, + {"filename": "granite-speech-4.1-2b-F16.gguf", "quant": "F16", "size_bytes": 4632623104, "sha256": "28285a4aafc3979a6587084fa0166556e97aaeea55204546036b95f6f0dbb48e"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -1236,6 +766,7 @@ }, { "id": "handy-computer/granite-speech-4.1-2b-plus-gguf", + "revision": "f73a59df77fef89ac0a34bc539d09d756793b065", "slug": "granite-speech-4.1-2b-plus", "name": "Granite Speech 4.1 2B Plus", "architecture": "granite_speech", @@ -1255,36 +786,12 @@ "speed_score": 29, "accuracy_score": 90, "files": [ - { - "filename": "granite-speech-4.1-2b-plus-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1489663424 - }, - { - "filename": "granite-speech-4.1-2b-plus-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1691297088 - }, - { - "filename": "granite-speech-4.1-2b-plus-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1859821504 - }, - { - "filename": "granite-speech-4.1-2b-plus-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2345973152 - }, - { - "filename": "granite-speech-4.1-2b-plus-BF16.gguf", - "quant": "BF16", - "size_bytes": 4228988768 - }, - { - "filename": "granite-speech-4.1-2b-plus-F16.gguf", - "quant": "F16", - "size_bytes": 4229971808 - } + {"filename": "granite-speech-4.1-2b-plus-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1489663424, "sha256": "5d52b6bb9bc2b504120448b4da61aeb383da3c1bcfe88b22c6ed667f2520206f"}, + {"filename": "granite-speech-4.1-2b-plus-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1691297088, "sha256": "3415f2c2d5979ded038dc012e5a875d77602fa5a0699cc75ee7fa47991f42530"}, + {"filename": "granite-speech-4.1-2b-plus-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1859821504, "sha256": "fe281f479ea53ad41d5d1e4d29f1c7174177803717c9b63771416312a3838817"}, + {"filename": "granite-speech-4.1-2b-plus-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2345973152, "sha256": "a2ffe6c8db730f3eb3719ad0ac27016cad30c87b18cd33e214c7b2083e38ce10"}, + {"filename": "granite-speech-4.1-2b-plus-BF16.gguf", "quant": "BF16", "size_bytes": 4228988768, "sha256": "fcf1499c5bcaf71f587337501d198b52c0cf0307c73732b619b2071ed0d2360c"}, + {"filename": "granite-speech-4.1-2b-plus-F16.gguf", "quant": "F16", "size_bytes": 4229971808, "sha256": "a9eba6a2df0d446c8c5020404abe6c9e3da0d077f010f772481ef1f221957c02"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -1292,6 +799,7 @@ }, { "id": "handy-computer/medasr-gguf", + "revision": "6f481df085bb50ae922cea918fb578e664237126", "slug": "medasr", "name": "MedASR", "architecture": "medasr", @@ -1311,55 +819,32 @@ "speed_score": 100, "accuracy_score": 30, "files": [ - { - "filename": "medasr-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 83082432 - }, - { - "filename": "medasr-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 94239936 - }, - { - "filename": "medasr-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 106094784 - }, - { - "filename": "medasr-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 127712448 - }, - { - "filename": "medasr-F16.gguf", - "quant": "F16", - "size_bytes": 211455168 - }, - { - "filename": "medasr-F32.gguf", - "quant": "F32", - "size_bytes": 421301440 - } + {"filename": "medasr-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 83082432, "sha256": "b23b52db7e1e17a5a21c24f3b78add04e1055bbe026b7c7196324c34ab6f3e2f"}, + {"filename": "medasr-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 94239936, "sha256": "873c90a9a78d98446b5e02506073108496df6ff2d062d26f5f7b90f6f1368f1f"}, + {"filename": "medasr-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 106094784, "sha256": "59be9d324cde3a64c813383e7090b3009713e261bf7fdce90b48e2f7bcbc28fa"}, + {"filename": "medasr-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 127712448, "sha256": "5a391a2154416b96241b829ac2c7eed8b64d197f00372512c9bbf63f4849c978"}, + {"filename": "medasr-F16.gguf", "quant": "F16", "size_bytes": 211455168, "sha256": "70e51cf5da1d3ea0838cf6d94a19f3dc1fdf28b24d8665589f43c50eaf429580"}, + {"filename": "medasr-F32.gguf", "quant": "F32", "size_bytes": 421301440, "sha256": "4b1b5b3a3092b96299624788b4eb28d04f7c66c447f0953c0216285c518f2ed5"} ], "default_quant": "Q8_0", "recommended": false, "recommended_rank": null }, { - "id": "handy-computer/moonshine-tiny-gguf", - "slug": "moonshine-tiny", - "name": "Moonshine Tiny", - "architecture": "moonshine", + "id": "handy-computer/moonshine-streaming-tiny-gguf", + "revision": "85ddff612fa3a2cf40b2f745abcfa90ef82f293b", + "slug": "moonshine-streaming-tiny", + "name": "Moonshine Streaming Tiny", + "architecture": "moonshine_streaming", "family": "moonshine", - "parameters": "27M", - "description": "English speech-to-text.", - "base_model": "UsefulSensors/moonshine-tiny", + "parameters": "44M", + "description": "English speech-to-text with streaming.", + "base_model": "UsefulSensors/moonshine-streaming-tiny", "license": "mit", "language_count": 1, "languages": ["en"], "capabilities": { - "streaming": false, + "streaming": true, "translate": false, "lang_detect": false, "timestamps": "none" @@ -1367,40 +852,29 @@ "speed_score": 100, "accuracy_score": 74, "files": [ - { - "filename": "moonshine-tiny-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 35466912 - }, - { - "filename": "moonshine-tiny-F16.gguf", - "quant": "F16", - "size_bytes": 59244192 - }, - { - "filename": "moonshine-tiny-F32.gguf", - "quant": "F32", - "size_bytes": 109969056 - } + {"filename": "moonshine-streaming-tiny-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 50462816, "sha256": "930e4622ad3a24158b91406c30c977fa6a26b34cb32d6ac3e57cfb23383a869e"}, + {"filename": "moonshine-streaming-tiny-F16.gguf", "quant": "F16", "size_bytes": 89784416, "sha256": "cfe64aacd68b0c4244369c04dab9828e324f005e4799af5ec33bbc1d37ebd681"}, + {"filename": "moonshine-streaming-tiny-F32.gguf", "quant": "F32", "size_bytes": 177817696, "sha256": "38ad83ec7a9db0b23be02f71fea1615ef8403eb28e79a915668d26026415d5f6"} ], "default_quant": "Q8_0", "recommended": false, "recommended_rank": null }, { - "id": "handy-computer/moonshine-streaming-tiny-gguf", - "slug": "moonshine-streaming-tiny", - "name": "Moonshine Streaming Tiny", - "architecture": "moonshine_streaming", + "id": "handy-computer/moonshine-tiny-gguf", + "revision": "f5c11906eba3f44cf305eed30feb9cbfb0b4b9d0", + "slug": "moonshine-tiny", + "name": "Moonshine Tiny", + "architecture": "moonshine", "family": "moonshine", - "parameters": "44M", - "description": "English speech-to-text with streaming.", - "base_model": "UsefulSensors/moonshine-streaming-tiny", + "parameters": "27M", + "description": "English speech-to-text.", + "base_model": "UsefulSensors/moonshine-tiny", "license": "mit", "language_count": 1, "languages": ["en"], "capabilities": { - "streaming": true, + "streaming": false, "translate": false, "lang_detect": false, "timestamps": "none" @@ -1408,38 +882,27 @@ "speed_score": 100, "accuracy_score": 74, "files": [ - { - "filename": "moonshine-streaming-tiny-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 50462816 - }, - { - "filename": "moonshine-streaming-tiny-F16.gguf", - "quant": "F16", - "size_bytes": 89784416 - }, - { - "filename": "moonshine-streaming-tiny-F32.gguf", - "quant": "F32", - "size_bytes": 177817696 - } + {"filename": "moonshine-tiny-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466912, "sha256": "2fd348d7b38f97d309cc3ec6848f3f57f537b80244950f07d2637e463f95a3a1"}, + {"filename": "moonshine-tiny-F16.gguf", "quant": "F16", "size_bytes": 59244192, "sha256": "d115694627d146c347ac5209db69db48b3125b07971026a6119b1888db7563d3"}, + {"filename": "moonshine-tiny-F32.gguf", "quant": "F32", "size_bytes": 109969056, "sha256": "67e50023e13f295d6efb8500b69a538019ff7c2dd4b577e425260048e8035418"} ], "default_quant": "Q8_0", "recommended": false, "recommended_rank": null }, { - "id": "handy-computer/moonshine-tiny-vi-gguf", - "slug": "moonshine-tiny-vi", - "name": "Moonshine Tiny (Vietnamese)", + "id": "handy-computer/moonshine-tiny-ar-gguf", + "revision": "2360cb6893cb523da51875eaffc8050d2ca7b56d", + "slug": "moonshine-tiny-ar", + "name": "Moonshine Tiny (Arabic)", "architecture": "moonshine", "family": "moonshine", "parameters": "27M", - "description": "Vietnamese speech-to-text.", - "base_model": "UsefulSensors/moonshine-tiny-vi", + "description": "Arabic speech-to-text.", + "base_model": "UsefulSensors/moonshine-tiny-ar", "license": "mit", "language_count": 1, - "languages": ["vi"], + "languages": ["ar"], "capabilities": { "streaming": false, "translate": false, @@ -1447,40 +910,29 @@ "timestamps": "none" }, "speed_score": 100, - "accuracy_score": 42, + "accuracy_score": 17, "files": [ - { - "filename": "moonshine-tiny-vi-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 35466944 - }, - { - "filename": "moonshine-tiny-vi-F16.gguf", - "quant": "F16", - "size_bytes": 59244224 - }, - { - "filename": "moonshine-tiny-vi-F32.gguf", - "quant": "F32", - "size_bytes": 109969088 - } + {"filename": "moonshine-tiny-ar-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944, "sha256": "e0bfa7c3aad2fe7acce022566daa1bb6140b6b263ba79655241acec7f7e0000b"}, + {"filename": "moonshine-tiny-ar-F16.gguf", "quant": "F16", "size_bytes": 59244224, "sha256": "209dd912f51f04a67977d0d93ea7363b0190786c3d7e1a2fbbbd0e94c4001aad"}, + {"filename": "moonshine-tiny-ar-F32.gguf", "quant": "F32", "size_bytes": 109969088, "sha256": "39cacdf5c240930cca65c06eb176eb49c25aa59dd2a425cc8c04a0a14bc9cc64"} ], "default_quant": "Q8_0", "recommended": false, "recommended_rank": null }, { - "id": "handy-computer/moonshine-tiny-uk-gguf", - "slug": "moonshine-tiny-uk", - "name": "Moonshine Tiny (Ukrainian)", + "id": "handy-computer/moonshine-tiny-ja-gguf", + "revision": "627aae63193d6c47cbdf316346119dcdc65d9b0d", + "slug": "moonshine-tiny-ja", + "name": "Moonshine Tiny (Japanese)", "architecture": "moonshine", "family": "moonshine", "parameters": "27M", - "description": "Ukrainian speech-to-text.", - "base_model": "UsefulSensors/moonshine-tiny-uk", + "description": "Japanese speech-to-text.", + "base_model": "UsefulSensors/moonshine-tiny-ja", "license": "mit", "language_count": 1, - "languages": ["uk"], + "languages": ["ja"], "capabilities": { "streaming": false, "translate": false, @@ -1488,23 +940,11 @@ "timestamps": "none" }, "speed_score": 100, - "accuracy_score": 28, + "accuracy_score": 41, "files": [ - { - "filename": "moonshine-tiny-uk-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 35466944 - }, - { - "filename": "moonshine-tiny-uk-F16.gguf", - "quant": "F16", - "size_bytes": 59244224 - }, - { - "filename": "moonshine-tiny-uk-F32.gguf", - "quant": "F32", - "size_bytes": 109969088 - } + {"filename": "moonshine-tiny-ja-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944, "sha256": "b47da12b2238cd176d9deebf31d49803c9eb4e99de5fd3abf672b4c0e2773c26"}, + {"filename": "moonshine-tiny-ja-F16.gguf", "quant": "F16", "size_bytes": 59244224, "sha256": "5a8b7960b119d1d2a947509ca9b4be6e71f6759973d5c200283335bfddd8cb43"}, + {"filename": "moonshine-tiny-ja-F32.gguf", "quant": "F32", "size_bytes": 109969088, "sha256": "4c87575b72de88ba148ffd0fa87c8cde20e0761974a6fed458822131a71e239b"} ], "default_quant": "Q8_0", "recommended": false, @@ -1512,6 +952,7 @@ }, { "id": "handy-computer/moonshine-tiny-ko-gguf", + "revision": "b858a303948b69c8b2442f700e936a07ba5f64da", "slug": "moonshine-tiny-ko", "name": "Moonshine Tiny (Korean)", "architecture": "moonshine", @@ -1531,38 +972,27 @@ "speed_score": 100, "accuracy_score": 55, "files": [ - { - "filename": "moonshine-tiny-ko-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 35466944 - }, - { - "filename": "moonshine-tiny-ko-F16.gguf", - "quant": "F16", - "size_bytes": 59244224 - }, - { - "filename": "moonshine-tiny-ko-F32.gguf", - "quant": "F32", - "size_bytes": 109969088 - } + {"filename": "moonshine-tiny-ko-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944, "sha256": "3ffe3340ceed10843789e2f7bf580097213c97a3537cae7031dbfe2a409b36b7"}, + {"filename": "moonshine-tiny-ko-F16.gguf", "quant": "F16", "size_bytes": 59244224, "sha256": "4228c8e92d6308166f96708de35af09318d624c3c1495e80eb8f6180790edd5d"}, + {"filename": "moonshine-tiny-ko-F32.gguf", "quant": "F32", "size_bytes": 109969088, "sha256": "1a13e4a0b7a97c9974ca1f88b275f8e43bf6453225cf549712efe4ec7aa1ad40"} ], "default_quant": "Q8_0", "recommended": false, "recommended_rank": null }, { - "id": "handy-computer/moonshine-tiny-zh-gguf", - "slug": "moonshine-tiny-zh", - "name": "Moonshine Tiny (Chinese)", + "id": "handy-computer/moonshine-tiny-uk-gguf", + "revision": "b4c991eb4223caa4de770843185476b0c86056b4", + "slug": "moonshine-tiny-uk", + "name": "Moonshine Tiny (Ukrainian)", "architecture": "moonshine", "family": "moonshine", "parameters": "27M", - "description": "Chinese speech-to-text.", - "base_model": "UsefulSensors/moonshine-tiny-zh", + "description": "Ukrainian speech-to-text.", + "base_model": "UsefulSensors/moonshine-tiny-uk", "license": "mit", "language_count": 1, - "languages": ["zh"], + "languages": ["uk"], "capabilities": { "streaming": false, "translate": false, @@ -1570,40 +1000,29 @@ "timestamps": "none" }, "speed_score": 100, - "accuracy_score": 40, + "accuracy_score": 28, "files": [ - { - "filename": "moonshine-tiny-zh-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 35466944 - }, - { - "filename": "moonshine-tiny-zh-F16.gguf", - "quant": "F16", - "size_bytes": 59244224 - }, - { - "filename": "moonshine-tiny-zh-F32.gguf", - "quant": "F32", - "size_bytes": 109969088 - } + {"filename": "moonshine-tiny-uk-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944, "sha256": "61c372f6d99cce6c7b7c45626a51a6c935217368e715d9c4e036ce85f4496338"}, + {"filename": "moonshine-tiny-uk-F16.gguf", "quant": "F16", "size_bytes": 59244224, "sha256": "08521642651a398a6c7afac28395ce5cc6938d30f9563bde4c13fe898438ff69"}, + {"filename": "moonshine-tiny-uk-F32.gguf", "quant": "F32", "size_bytes": 109969088, "sha256": "7c2bf9f4562f9c094f12fe73d490d03dc2971bc7f31bf0b2e5b3b10b92330610"} ], "default_quant": "Q8_0", "recommended": false, "recommended_rank": null }, { - "id": "handy-computer/moonshine-tiny-ar-gguf", - "slug": "moonshine-tiny-ar", - "name": "Moonshine Tiny (Arabic)", + "id": "handy-computer/moonshine-tiny-vi-gguf", + "revision": "d97be0111a74a9058689beacc05b38969d2b68aa", + "slug": "moonshine-tiny-vi", + "name": "Moonshine Tiny (Vietnamese)", "architecture": "moonshine", "family": "moonshine", "parameters": "27M", - "description": "Arabic speech-to-text.", - "base_model": "UsefulSensors/moonshine-tiny-ar", + "description": "Vietnamese speech-to-text.", + "base_model": "UsefulSensors/moonshine-tiny-vi", "license": "mit", "language_count": 1, - "languages": ["ar"], + "languages": ["vi"], "capabilities": { "streaming": false, "translate": false, @@ -1611,40 +1030,29 @@ "timestamps": "none" }, "speed_score": 100, - "accuracy_score": 17, + "accuracy_score": 42, "files": [ - { - "filename": "moonshine-tiny-ar-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 35466944 - }, - { - "filename": "moonshine-tiny-ar-F16.gguf", - "quant": "F16", - "size_bytes": 59244224 - }, - { - "filename": "moonshine-tiny-ar-F32.gguf", - "quant": "F32", - "size_bytes": 109969088 - } + {"filename": "moonshine-tiny-vi-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944, "sha256": "a42a80ef2e3636104aa2aff87cdde3e251e319e7d0d1acb3fb594cc601d8b95f"}, + {"filename": "moonshine-tiny-vi-F16.gguf", "quant": "F16", "size_bytes": 59244224, "sha256": "94464f474fc4a574951e335a731fabaf97a2e517a96bf4fcfd82791a67fa40ce"}, + {"filename": "moonshine-tiny-vi-F32.gguf", "quant": "F32", "size_bytes": 109969088, "sha256": "7423501769416a83eb4763ea85be58e4c20ca629d9181f522a2a2da398b9c194"} ], "default_quant": "Q8_0", "recommended": false, "recommended_rank": null }, { - "id": "handy-computer/moonshine-tiny-ja-gguf", - "slug": "moonshine-tiny-ja", - "name": "Moonshine Tiny (Japanese)", + "id": "handy-computer/moonshine-tiny-zh-gguf", + "revision": "2aca379ba0dc978d878b84b76231d5ba5550e738", + "slug": "moonshine-tiny-zh", + "name": "Moonshine Tiny (Chinese)", "architecture": "moonshine", "family": "moonshine", "parameters": "27M", - "description": "Japanese speech-to-text.", - "base_model": "UsefulSensors/moonshine-tiny-ja", + "description": "Chinese speech-to-text.", + "base_model": "UsefulSensors/moonshine-tiny-zh", "license": "mit", "language_count": 1, - "languages": ["ja"], + "languages": ["zh"], "capabilities": { "streaming": false, "translate": false, @@ -1652,23 +1060,11 @@ "timestamps": "none" }, "speed_score": 100, - "accuracy_score": 41, + "accuracy_score": 40, "files": [ - { - "filename": "moonshine-tiny-ja-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 35466944 - }, - { - "filename": "moonshine-tiny-ja-F16.gguf", - "quant": "F16", - "size_bytes": 59244224 - }, - { - "filename": "moonshine-tiny-ja-F32.gguf", - "quant": "F32", - "size_bytes": 109969088 - } + {"filename": "moonshine-tiny-zh-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944, "sha256": "95387abe90625410f8e2bcec43faaaadc0d92ef1e721117eb8a69cd31a7ef692"}, + {"filename": "moonshine-tiny-zh-F16.gguf", "quant": "F16", "size_bytes": 59244224, "sha256": "33c59e565336f1b8d6da86cb6d6321e8797bb65e9bf11947258e7340d3dcd968"}, + {"filename": "moonshine-tiny-zh-F32.gguf", "quant": "F32", "size_bytes": 109969088, "sha256": "43cb182cedd45d7117846bb540bb2b33ee200ec35095b9f7a697ea3ad3c9602f"} ], "default_quant": "Q8_0", "recommended": false, @@ -1676,6 +1072,7 @@ }, { "id": "handy-computer/moonshine-base-gguf", + "revision": "3ef112378a8cf46ac8b278d9bfa2d15c846704b8", "slug": "moonshine-base", "name": "Moonshine Base", "architecture": "moonshine", @@ -1695,21 +1092,9 @@ "speed_score": 99, "accuracy_score": 80, "files": [ - { - "filename": "moonshine-base-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 77476480 - }, - { - "filename": "moonshine-base-F16.gguf", - "quant": "F16", - "size_bytes": 131789440 - }, - { - "filename": "moonshine-base-F32.gguf", - "quant": "F32", - "size_bytes": 247657088 - } + {"filename": "moonshine-base-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476480, "sha256": "7f0027dfd857d310b63a85ef57cadf183da712cc374f85a648f8bc18aaa2efc8"}, + {"filename": "moonshine-base-F16.gguf", "quant": "F16", "size_bytes": 131789440, "sha256": "c7a158ae0ad47c887041372e97c874e98c11eb0d8eff01279723dde65b358702"}, + {"filename": "moonshine-base-F32.gguf", "quant": "F32", "size_bytes": 247657088, "sha256": "b19a36db5a91e4f25cc527c5ed87f774fd037aee8ceec774397c19b7198199fb"} ], "default_quant": "Q8_0", "recommended": false, @@ -1717,6 +1102,7 @@ }, { "id": "handy-computer/moonshine-base-ar-gguf", + "revision": "1ae85af52b16eac8bcebda0287e6195ed1956e86", "slug": "moonshine-base-ar", "name": "Moonshine Base (Arabic)", "architecture": "moonshine", @@ -1736,38 +1122,27 @@ "speed_score": 99, "accuracy_score": 20, "files": [ - { - "filename": "moonshine-base-ar-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 77476480 - }, - { - "filename": "moonshine-base-ar-F16.gguf", - "quant": "F16", - "size_bytes": 131789440 - }, - { - "filename": "moonshine-base-ar-F32.gguf", - "quant": "F32", - "size_bytes": 247657088 - } + {"filename": "moonshine-base-ar-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476480, "sha256": "1a5b34653dfff0917d5bc7de1aeafca674162a5efa674472358d1b7f1644cf73"}, + {"filename": "moonshine-base-ar-F16.gguf", "quant": "F16", "size_bytes": 131789440, "sha256": "9a70242ad42fb130a417c11127f5e7ad227c03b0c4a287fa60757efa3cccd714"}, + {"filename": "moonshine-base-ar-F32.gguf", "quant": "F32", "size_bytes": 247657088, "sha256": "5c0f54279e41c195c5b5c288cdee94b0960bdc8be518dfd22aac11850254fb2b"} ], "default_quant": "Q8_0", "recommended": false, "recommended_rank": null }, { - "id": "handy-computer/moonshine-base-ko-gguf", - "slug": "moonshine-base-ko", - "name": "Moonshine Base (Korean)", + "id": "handy-computer/moonshine-base-ja-gguf", + "revision": "aac5cfff17ae28b6f17ae790a3e309ae0ca5911c", + "slug": "moonshine-base-ja", + "name": "Moonshine Base (Japanese)", "architecture": "moonshine", "family": "moonshine", "parameters": "62M", - "description": "Korean speech-to-text.", - "base_model": "UsefulSensors/moonshine-base-ko", + "description": "Japanese speech-to-text.", + "base_model": "UsefulSensors/moonshine-base-ja", "license": "mit", "language_count": 1, - "languages": ["ko"], + "languages": ["ja"], "capabilities": { "streaming": false, "translate": false, @@ -1775,40 +1150,29 @@ "timestamps": "none" }, "speed_score": 99, - "accuracy_score": 58, + "accuracy_score": 50, "files": [ - { - "filename": "moonshine-base-ko-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 77476480 - }, - { - "filename": "moonshine-base-ko-F16.gguf", - "quant": "F16", - "size_bytes": 131789440 - }, - { - "filename": "moonshine-base-ko-F32.gguf", - "quant": "F32", - "size_bytes": 247657088 - } + {"filename": "moonshine-base-ja-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476480, "sha256": "8ee6e9d7c9df69a3ea654af77659f1c0c08bf90998c2e2a269826096ffafc4f0"}, + {"filename": "moonshine-base-ja-F16.gguf", "quant": "F16", "size_bytes": 131789440, "sha256": "a3fb02bb2f6d6b5082818fb60cbc816a20e3865502adca2e1fd94068b29e5d2b"}, + {"filename": "moonshine-base-ja-F32.gguf", "quant": "F32", "size_bytes": 247657088, "sha256": "649f65dbc795d9fd4cf23f2123a09cdcdffba7e071891caa39e60d6a84c19082"} ], "default_quant": "Q8_0", "recommended": false, "recommended_rank": null }, { - "id": "handy-computer/moonshine-base-uk-gguf", - "slug": "moonshine-base-uk", - "name": "Moonshine Base (Ukrainian)", + "id": "handy-computer/moonshine-base-ko-gguf", + "revision": "03813c71abe85b40cd0671d3cfa420e831d7e333", + "slug": "moonshine-base-ko", + "name": "Moonshine Base (Korean)", "architecture": "moonshine", "family": "moonshine", "parameters": "62M", - "description": "Ukrainian speech-to-text.", - "base_model": "UsefulSensors/moonshine-base-uk", + "description": "Korean speech-to-text.", + "base_model": "UsefulSensors/moonshine-base-ko", "license": "mit", "language_count": 1, - "languages": ["uk"], + "languages": ["ko"], "capabilities": { "streaming": false, "translate": false, @@ -1816,40 +1180,29 @@ "timestamps": "none" }, "speed_score": 99, - "accuracy_score": 38, + "accuracy_score": 58, "files": [ - { - "filename": "moonshine-base-uk-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 77476512 - }, - { - "filename": "moonshine-base-uk-F16.gguf", - "quant": "F16", - "size_bytes": 131789472 - }, - { - "filename": "moonshine-base-uk-F32.gguf", - "quant": "F32", - "size_bytes": 247657120 - } + {"filename": "moonshine-base-ko-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476480, "sha256": "b430672cfdde98b894bd98adf1a8b3470308fea39ad1e6bb0f54d8fc9e114471"}, + {"filename": "moonshine-base-ko-F16.gguf", "quant": "F16", "size_bytes": 131789440, "sha256": "79b29fd6803723b7884824710aebef743f068e2db0fc3ad0488bc16b05b69c2c"}, + {"filename": "moonshine-base-ko-F32.gguf", "quant": "F32", "size_bytes": 247657088, "sha256": "7b4a358a883537ce5584f867c683f1850027ddb842c7cdb526b6b4704402eeb1"} ], "default_quant": "Q8_0", "recommended": false, "recommended_rank": null }, { - "id": "handy-computer/moonshine-base-ja-gguf", - "slug": "moonshine-base-ja", - "name": "Moonshine Base (Japanese)", + "id": "handy-computer/moonshine-base-uk-gguf", + "revision": "d6ec586909c5209c0c17243dc4f5e55164f85dfb", + "slug": "moonshine-base-uk", + "name": "Moonshine Base (Ukrainian)", "architecture": "moonshine", "family": "moonshine", "parameters": "62M", - "description": "Japanese speech-to-text.", - "base_model": "UsefulSensors/moonshine-base-ja", + "description": "Ukrainian speech-to-text.", + "base_model": "UsefulSensors/moonshine-base-uk", "license": "mit", "language_count": 1, - "languages": ["ja"], + "languages": ["uk"], "capabilities": { "streaming": false, "translate": false, @@ -1857,23 +1210,11 @@ "timestamps": "none" }, "speed_score": 99, - "accuracy_score": 50, + "accuracy_score": 38, "files": [ - { - "filename": "moonshine-base-ja-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 77476480 - }, - { - "filename": "moonshine-base-ja-F16.gguf", - "quant": "F16", - "size_bytes": 131789440 - }, - { - "filename": "moonshine-base-ja-F32.gguf", - "quant": "F32", - "size_bytes": 247657088 - } + {"filename": "moonshine-base-uk-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476512, "sha256": "6dcac8c3905706e8c8416f531a479eccaa38cdaf1eae439f776fda4d4faa88d3"}, + {"filename": "moonshine-base-uk-F16.gguf", "quant": "F16", "size_bytes": 131789472, "sha256": "e603b1e70da31060b0094f34732996156ec8e89cb1068e2f2cf64a91c3ea6cff"}, + {"filename": "moonshine-base-uk-F32.gguf", "quant": "F32", "size_bytes": 247657120, "sha256": "2d230e76f32e4e9e818b53c0abe70cfb65c15e1ee28170bcc324bce0470e7c4b"} ], "default_quant": "Q8_0", "recommended": false, @@ -1881,6 +1222,7 @@ }, { "id": "handy-computer/moonshine-base-vi-gguf", + "revision": "76ccec93f5854ae16d2dab72d5b762fe10c0df81", "slug": "moonshine-base-vi", "name": "Moonshine Base (Vietnamese)", "architecture": "moonshine", @@ -1900,21 +1242,9 @@ "speed_score": 99, "accuracy_score": 52, "files": [ - { - "filename": "moonshine-base-vi-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 77476512 - }, - { - "filename": "moonshine-base-vi-F16.gguf", - "quant": "F16", - "size_bytes": 131789472 - }, - { - "filename": "moonshine-base-vi-F32.gguf", - "quant": "F32", - "size_bytes": 247657120 - } + {"filename": "moonshine-base-vi-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476512, "sha256": "d382ec7ac6aa821d25b921ab982bf1bf29da591cad73372b65209c6a74decb36"}, + {"filename": "moonshine-base-vi-F16.gguf", "quant": "F16", "size_bytes": 131789472, "sha256": "1406f7c80cad755f93d4915d21fbcf5e65f19d4c93e8a3966f970a6a4d6f7068"}, + {"filename": "moonshine-base-vi-F32.gguf", "quant": "F32", "size_bytes": 247657120, "sha256": "b04a66041ac50f0274668e17ec62a9ec93bdae23af39b98cc0923fbca2ff2c72"} ], "default_quant": "Q8_0", "recommended": false, @@ -1922,6 +1252,7 @@ }, { "id": "handy-computer/moonshine-base-zh-gguf", + "revision": "64385c681d79767be73ce8591b4609854ac750c9", "slug": "moonshine-base-zh", "name": "Moonshine Base (Chinese)", "architecture": "moonshine", @@ -1941,21 +1272,9 @@ "speed_score": 99, "accuracy_score": 32, "files": [ - { - "filename": "moonshine-base-zh-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 77476480 - }, - { - "filename": "moonshine-base-zh-F16.gguf", - "quant": "F16", - "size_bytes": 131789440 - }, - { - "filename": "moonshine-base-zh-F32.gguf", - "quant": "F32", - "size_bytes": 247657088 - } + {"filename": "moonshine-base-zh-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476480, "sha256": "1bae1d753b29b0c34038585b576d28e4f262ed10c6c7217e023fa5d624dc15ad"}, + {"filename": "moonshine-base-zh-F16.gguf", "quant": "F16", "size_bytes": 131789440, "sha256": "bead0492ab45cee75411d5e59e95a5e796b86ab6cde3bfee4613c02cba9f2e35"}, + {"filename": "moonshine-base-zh-F32.gguf", "quant": "F32", "size_bytes": 247657088, "sha256": "1a54db0bec02f81730e6378b185f807cdf9065a477c7171f01914ca917408673"} ], "default_quant": "Q8_0", "recommended": false, @@ -1963,6 +1282,7 @@ }, { "id": "handy-computer/moonshine-streaming-small-gguf", + "revision": "41444173ed8210852a883e046fadcfba3e7bfbae", "slug": "moonshine-streaming-small", "name": "Moonshine Streaming Small", "architecture": "moonshine_streaming", @@ -1982,21 +1302,9 @@ "speed_score": 95, "accuracy_score": 84, "files": [ - { - "filename": "moonshine-streaming-small-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 198506848 - }, - { - "filename": "moonshine-streaming-small-F16.gguf", - "quant": "F16", - "size_bytes": 282092128 - }, - { - "filename": "moonshine-streaming-small-F32.gguf", - "quant": "F32", - "size_bytes": 562146912 - } + {"filename": "moonshine-streaming-small-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 198506848, "sha256": "d03670f69629b649085d0f44a63d97668b4119117cc9611a4e4ad94341713dfc"}, + {"filename": "moonshine-streaming-small-F16.gguf", "quant": "F16", "size_bytes": 282092128, "sha256": "4e0b872dd11072c3b6ae06a25e832d4eb29281ae0d6931992d1c58889c65eb2f"}, + {"filename": "moonshine-streaming-small-F32.gguf", "quant": "F32", "size_bytes": 562146912, "sha256": "cb231274a2c402d42376e5f25378d17f8b229f530cb06cef6e6cf812d39a5dc3"} ], "default_quant": "Q8_0", "recommended": false, @@ -2004,6 +1312,7 @@ }, { "id": "handy-computer/moonshine-streaming-medium-gguf", + "revision": "c722a9455a40a1844c3d25267dc84eff61d8dd84", "slug": "moonshine-streaming-medium", "name": "Moonshine Streaming Medium", "architecture": "moonshine_streaming", @@ -2023,21 +1332,9 @@ "speed_score": 83, "accuracy_score": 87, "files": [ - { - "filename": "moonshine-streaming-medium-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 295793568 - }, - { - "filename": "moonshine-streaming-medium-F16.gguf", - "quant": "F16", - "size_bytes": 533781408 - }, - { - "filename": "moonshine-streaming-medium-F32.gguf", - "quant": "F32", - "size_bytes": 1065204640 - } + {"filename": "moonshine-streaming-medium-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 295793568, "sha256": "f7c9564249b508f6012927ec4f9e536087da53a7047f858ca9975bea5f75299e"}, + {"filename": "moonshine-streaming-medium-F16.gguf", "quant": "F16", "size_bytes": 533781408, "sha256": "cf2ab2c366df44e8885e1c127c23275dc9a0aa0d10557c2fec325506058b9de8"}, + {"filename": "moonshine-streaming-medium-F32.gguf", "quant": "F32", "size_bytes": 1065204640, "sha256": "70b4fad683358feba54631e440b4ee9d813a416e239c4cf163f422189dfef03f"} ], "default_quant": "Q8_0", "recommended": false, @@ -2045,6 +1342,7 @@ }, { "id": "handy-computer/nemotron-speech-streaming-en-0.6b-gguf", + "revision": "7d9b719206789e4068d87c6398262ab4dfd4e45d", "slug": "nemotron-speech-streaming-en-0.6b", "name": "Nemotron Speech Streaming EN", "architecture": "parakeet", @@ -2064,36 +1362,12 @@ "speed_score": 80, "accuracy_score": 86, "files": [ - { - "filename": "nemotron-speech-streaming-en-0.6b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 475436032 - }, - { - "filename": "nemotron-speech-streaming-en-0.6b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 538989568 - }, - { - "filename": "nemotron-speech-streaming-en-0.6b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 600420352 - }, - { - "filename": "nemotron-speech-streaming-en-0.6b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 729650176 - }, - { - "filename": "nemotron-speech-streaming-en-0.6b-F16.gguf", - "quant": "F16", - "size_bytes": 1237652608 - }, - { - "filename": "nemotron-speech-streaming-en-0.6b-F32.gguf", - "quant": "F32", - "size_bytes": 2472386176 - } + {"filename": "nemotron-speech-streaming-en-0.6b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 475436032, "sha256": "dc959ca31499b114e395c44eb4f0778968f20e5cfb03305a08a39925b2da8e1e"}, + {"filename": "nemotron-speech-streaming-en-0.6b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 538989568, "sha256": "d6b8b36f9aca6a751779d064a29f6583f3e82c0c15646904383c8436a25c1489"}, + {"filename": "nemotron-speech-streaming-en-0.6b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 600420352, "sha256": "b31fcc5e9f3b00cb33c10ff0f05d7e71265f160eeed6b497a65cd296dddd21f3"}, + {"filename": "nemotron-speech-streaming-en-0.6b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 729650176, "sha256": "90d8c89714cd31efc88be62a40c6b2bea57e0cc2063af1ffe2c28f1a228ca110"}, + {"filename": "nemotron-speech-streaming-en-0.6b-F16.gguf", "quant": "F16", "size_bytes": 1237652608, "sha256": "dc8c4bd7dce6e1805b4e00bef391c3870aca4fa99a563ec02a6f5e0f15d2f489"}, + {"filename": "nemotron-speech-streaming-en-0.6b-F32.gguf", "quant": "F32", "size_bytes": 2472386176, "sha256": "53bdc1d4d21e419d4da513d2df31ce88784e25cc908841386490658854b13b41"} ], "default_quant": "Q8_0", "recommended": false, @@ -2101,6 +1375,7 @@ }, { "id": "handy-computer/parakeet-tdt_ctc-110m-gguf", + "revision": "9d66d34f9e1594075c5dd72c90c0f4c321b29f21", "slug": "parakeet-tdt_ctc-110m", "name": "Parakeet TDT-CTC 110M", "architecture": "parakeet", @@ -2120,36 +1395,45 @@ "speed_score": 98, "accuracy_score": 85, "files": [ - { - "filename": "parakeet-tdt_ctc-110m-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 89989600 - }, - { - "filename": "parakeet-tdt_ctc-110m-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 101335520 - }, - { - "filename": "parakeet-tdt_ctc-110m-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 112311264 - }, - { - "filename": "parakeet-tdt_ctc-110m-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 135373280 - }, - { - "filename": "parakeet-tdt_ctc-110m-F16.gguf", - "quant": "F16", - "size_bytes": 229334560 - }, - { - "filename": "parakeet-tdt_ctc-110m-F32.gguf", - "quant": "F32", - "size_bytes": 456524064 - } + {"filename": "parakeet-tdt_ctc-110m-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 89989600, "sha256": "486414fd90185a8c8a4ced7c123cfb133ff4f7958426c6b8bd9049946b56b448"}, + {"filename": "parakeet-tdt_ctc-110m-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 101335520, "sha256": "1552e707fddb59e3741b66ea917fc91a9381e336a4730739b1d2d448cb013a2e"}, + {"filename": "parakeet-tdt_ctc-110m-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 112311264, "sha256": "c20520c245adf82e5166005f599cb3b95e7cf5192117e845be4bbcd39226d483"}, + {"filename": "parakeet-tdt_ctc-110m-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 135373280, "sha256": "7dd44c74a331d788a4e5f8b16913b3feb29ced22cf5613aad0e0f6cd30516296"}, + {"filename": "parakeet-tdt_ctc-110m-F16.gguf", "quant": "F16", "size_bytes": 229334560, "sha256": "965cc3f8e171286ecb4d4e6646b407a1782a97da9aed22f88d1e0ec6c96b2358"}, + {"filename": "parakeet-tdt_ctc-110m-F32.gguf", "quant": "F32", "size_bytes": 456524064, "sha256": "99701a7de4b2435e63efdf43db060f3dce723294fe028afc6f0852f406060d93"} + ], + "default_quant": "Q8_0", + "recommended": false, + "recommended_rank": null + }, + { + "id": "handy-computer/multitalker-parakeet-streaming-0.6b-v1-gguf", + "revision": "0314bc0d2f4be9a0d79fa5eeb36ad1f3962cfcbb", + "slug": "multitalker-parakeet-streaming-0.6b-v1", + "name": "Multitalker Parakeet Streaming EN", + "architecture": "parakeet", + "family": "parakeet", + "parameters": "0.6B", + "description": "English speech-to-text with streaming, token-level timestamps.", + "base_model": "nvidia/multitalker-parakeet-streaming-0.6b-v1", + "license": "other", + "language_count": 1, + "languages": ["en"], + "capabilities": { + "streaming": true, + "translate": false, + "lang_detect": false, + "timestamps": "token" + }, + "speed_score": 96, + "accuracy_score": 86, + "files": [ + {"filename": "multitalker-parakeet-streaming-0.6b-v1-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 477812416, "sha256": "d9f05f95ab70054f37af71aae7bc64d37eabfc2d266935224a827cb0d838bb4f"}, + {"filename": "multitalker-parakeet-streaming-0.6b-v1-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 541890240, "sha256": "247a14c84dc6d0e7d4f5604b6f2b89b927af2e49eb0ed9fe350b447e849acf01"}, + {"filename": "multitalker-parakeet-streaming-0.6b-v1-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 603878080, "sha256": "c5ad23c5e59e3f73d5adebc963992818109d1ecb272f5c862b2355bc41950dac"}, + {"filename": "multitalker-parakeet-streaming-0.6b-v1-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 734123712, "sha256": "4748deea0222eb9057ebdac1679eebb970bbe882ee10a96ae34596ffe83615c7"}, + {"filename": "multitalker-parakeet-streaming-0.6b-v1-F16.gguf", "quant": "F16", "size_bytes": 1246058304, "sha256": "d2b3cbd7d202f97dd7d91da228a70cc47d5f60ba57c8431a37462b2c2d035ba7"}, + {"filename": "multitalker-parakeet-streaming-0.6b-v1-F32.gguf", "quant": "F32", "size_bytes": 2489180480, "sha256": "a73ea5c34cffd714e97ef6de22db8e9437b6c0265f86f8bd31825fd2b0f49e6c"} ], "default_quant": "Q8_0", "recommended": false, @@ -2157,6 +1441,7 @@ }, { "id": "handy-computer/parakeet-ctc-0.6b-gguf", + "revision": "cdc56f0467ec675a9509a46ef2e83f4c9e49af94", "slug": "parakeet-ctc-0.6b", "name": "Parakeet CTC 0.6B", "architecture": "parakeet", @@ -2176,36 +1461,12 @@ "speed_score": 94, "accuracy_score": 88, "files": [ - { - "filename": "parakeet-ctc-0.6b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 469302464 - }, - { - "filename": "parakeet-ctc-0.6b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 532544704 - }, - { - "filename": "parakeet-ctc-0.6b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 593644736 - }, - { - "filename": "parakeet-ctc-0.6b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 722271424 - }, - { - "filename": "parakeet-ctc-0.6b-F16.gguf", - "quant": "F16", - "size_bytes": 1220181184 - }, - { - "filename": "parakeet-ctc-0.6b-F32.gguf", - "quant": "F32", - "size_bytes": 2435482816 - } + {"filename": "parakeet-ctc-0.6b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 469302464, "sha256": "2a0dc545d06184b30cd3441a38811f09867a1d6b7b89f008f16bd75c1030ccdb"}, + {"filename": "parakeet-ctc-0.6b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 532544704, "sha256": "8837b9489682f870dae15e126940956d4c0a03fbe884d82edcd8b8c8b1dc0f22"}, + {"filename": "parakeet-ctc-0.6b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 593644736, "sha256": "3ecb616005ea4bffa67fb5e94a02f155a48b52cb40a466af7982a4a4173692f6"}, + {"filename": "parakeet-ctc-0.6b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 722271424, "sha256": "e47ae86c1b34dbaf054334592575c29aee7de3546e75d1f0ccee05904c5c49ae"}, + {"filename": "parakeet-ctc-0.6b-F16.gguf", "quant": "F16", "size_bytes": 1220181184, "sha256": "9529e692dce3cd2c84dd63c576095d02cbed245fb4ccbe264783fdf59677f1b5"}, + {"filename": "parakeet-ctc-0.6b-F32.gguf", "quant": "F32", "size_bytes": 2435482816, "sha256": "2956c41281192c0657da23ab24ab20a961278e3a4e8e038eab2cdb33145acd75"} ], "default_quant": "Q8_0", "recommended": false, @@ -2213,6 +1474,7 @@ }, { "id": "handy-computer/parakeet-rnnt-0.6b-gguf", + "revision": "6001ebcc1c64dd821cf70b7b4ffdd4d18097760a", "slug": "parakeet-rnnt-0.6b", "name": "Parakeet RNN-T 0.6B", "architecture": "parakeet", @@ -2232,36 +1494,12 @@ "speed_score": 84, "accuracy_score": 90, "files": [ - { - "filename": "parakeet-rnnt-0.6b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 476390816 - }, - { - "filename": "parakeet-rnnt-0.6b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 539714976 - }, - { - "filename": "parakeet-rnnt-0.6b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 600902048 - }, - { - "filename": "parakeet-rnnt-0.6b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 729687456 - }, - { - "filename": "parakeet-rnnt-0.6b-F16.gguf", - "quant": "F16", - "size_bytes": 1235969568 - }, - { - "filename": "parakeet-rnnt-0.6b-F32.gguf", - "quant": "F32", - "size_bytes": 2467033120 - } + {"filename": "parakeet-rnnt-0.6b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 476390816, "sha256": "70b32229e2b576a3be66176ebc484476f3d715d911c11a4e17167790ce378cf7"}, + {"filename": "parakeet-rnnt-0.6b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 539714976, "sha256": "b2910e4f81f35cc99ce830847040ac469c07aa27d289e9e517d6b73b47a4d7d5"}, + {"filename": "parakeet-rnnt-0.6b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 600902048, "sha256": "fcc8fd8e1b21311eefcc6ebabc2cc059df30cbbd296dc3013aa7727f070a3530"}, + {"filename": "parakeet-rnnt-0.6b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 729687456, "sha256": "06da5ba773736984e0513037b27ff9300e95c55eaa2b79dbc688efc34b25df51"}, + {"filename": "parakeet-rnnt-0.6b-F16.gguf", "quant": "F16", "size_bytes": 1235969568, "sha256": "5adef2718a57b02745db5717c245b90aae8b450139ccbfb2cf5ed2fb955192c0"}, + {"filename": "parakeet-rnnt-0.6b-F32.gguf", "quant": "F32", "size_bytes": 2467033120, "sha256": "9312f95f6a2271eae0f04d4e499647bfb8dcf1447e41d77709435f8d4df61f52"} ], "default_quant": "Q8_0", "recommended": false, @@ -2269,6 +1507,7 @@ }, { "id": "handy-computer/parakeet-ctc-1.1b-gguf", + "revision": "a9607fbeb480b4a66a3e8bd77efc490e8410cdd4", "slug": "parakeet-ctc-1.1b", "name": "Parakeet CTC 1.1B", "architecture": "parakeet", @@ -2288,36 +1527,12 @@ "speed_score": 83, "accuracy_score": 88, "files": [ - { - "filename": "parakeet-ctc-1.1b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 818156576 - }, - { - "filename": "parakeet-ctc-1.1b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 928584736 - }, - { - "filename": "parakeet-ctc-1.1b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1035248672 - }, - { - "filename": "parakeet-ctc-1.1b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1259869216 - }, - { - "filename": "parakeet-ctc-1.1b-F16.gguf", - "quant": "F16", - "size_bytes": 2129368096 - }, - { - "filename": "parakeet-ctc-1.1b-F32.gguf", - "quant": "F32", - "size_bytes": 4250639392 - } + {"filename": "parakeet-ctc-1.1b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 818156576, "sha256": "9e4c104086067ee3afefbf7d41929024261682926cf0a57cc5a1f557fc5b69e5"}, + {"filename": "parakeet-ctc-1.1b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 928584736, "sha256": "8b38f60bb1c56b518953a39703068adc09991993b5aab52782e038a86a6c04cc"}, + {"filename": "parakeet-ctc-1.1b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1035248672, "sha256": "23a6fd82f39cba5ef97873cb1228dbe7acc2ffd6425276ad668c2d48b020b6eb"}, + {"filename": "parakeet-ctc-1.1b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1259869216, "sha256": "4f66c66bdef2c6901384a218a01d8a614698010e880bbdfe44f3bcf0b11ae0f2"}, + {"filename": "parakeet-ctc-1.1b-F16.gguf", "quant": "F16", "size_bytes": 2129368096, "sha256": "1f4e4e6301d341655cf35f329852c7211a3fbcfa7564afd1dc9384b5142d3e32"}, + {"filename": "parakeet-ctc-1.1b-F32.gguf", "quant": "F32", "size_bytes": 4250639392, "sha256": "dbbc3ed2ccf7a0633b4aaa18e930113973f79dd98a2f2f79b5d4f1ef0db4ff05"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -2325,6 +1540,7 @@ }, { "id": "handy-computer/parakeet-tdt-1.1b-gguf", + "revision": "8c21810615694c53a4f4745996190fcca880f8e5", "slug": "parakeet-tdt-1.1b", "name": "Parakeet TDT 1.1B", "architecture": "parakeet", @@ -2344,36 +1560,12 @@ "speed_score": 76, "accuracy_score": 91, "files": [ - { - "filename": "parakeet-tdt-1.1b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 825248416 - }, - { - "filename": "parakeet-tdt-1.1b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 935758496 - }, - { - "filename": "parakeet-tdt-1.1b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1042509472 - }, - { - "filename": "parakeet-tdt-1.1b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1267288736 - }, - { - "filename": "parakeet-tdt-1.1b-F16.gguf", - "quant": "F16", - "size_bytes": 2145162976 - }, - { - "filename": "parakeet-tdt-1.1b-F32.gguf", - "quant": "F32", - "size_bytes": 4282202592 - } + {"filename": "parakeet-tdt-1.1b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 825248416, "sha256": "38e85773763afe7b79c8538fbd091367b4644123358ea209eeb665879e3fcb13"}, + {"filename": "parakeet-tdt-1.1b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 935758496, "sha256": "18c22888fed10676fde72fdd5b833fab9175a6abf4e0cb390eb0a59b0b107cd3"}, + {"filename": "parakeet-tdt-1.1b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1042509472, "sha256": "7481b215629957031205893c99e9b6472278bc713354f723c682562c39ef52f4"}, + {"filename": "parakeet-tdt-1.1b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1267288736, "sha256": "8479e1ed0b7244e293ed81f547c69074a38c00e17511d8ecae2d273bc7b2ceda"}, + {"filename": "parakeet-tdt-1.1b-F16.gguf", "quant": "F16", "size_bytes": 2145162976, "sha256": "5d5e36992fa5102a1eac29c4356b51e6de8577c5d8e55e250cfd792a018dc8f2"}, + {"filename": "parakeet-tdt-1.1b-F32.gguf", "quant": "F32", "size_bytes": 4282202592, "sha256": "627ba3def22f056fa48d62bcdb70455b75c81d5fb7f30028f63682fb9ffe1684"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -2381,6 +1573,7 @@ }, { "id": "handy-computer/parakeet-rnnt-1.1b-gguf", + "revision": "58c3f5108699a6aba6d6dbb21f8fa153fe7d3f62", "slug": "parakeet-rnnt-1.1b", "name": "Parakeet RNN-T 1.1B", "architecture": "parakeet", @@ -2400,36 +1593,12 @@ "speed_score": 75, "accuracy_score": 91, "files": [ - { - "filename": "parakeet-rnnt-1.1b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 825244928 - }, - { - "filename": "parakeet-rnnt-1.1b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 935755008 - }, - { - "filename": "parakeet-rnnt-1.1b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1042505984 - }, - { - "filename": "parakeet-rnnt-1.1b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1267285248 - }, - { - "filename": "parakeet-rnnt-1.1b-F16.gguf", - "quant": "F16", - "size_bytes": 2145156480 - }, - { - "filename": "parakeet-rnnt-1.1b-F32.gguf", - "quant": "F32", - "size_bytes": 4282189696 - } + {"filename": "parakeet-rnnt-1.1b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 825244928, "sha256": "9e8636f491867751ca5b5c18ca91adf52014b1ae406b46516a9a8680ba1049a1"}, + {"filename": "parakeet-rnnt-1.1b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 935755008, "sha256": "8ee2ead0c30773440743591cd8a9f8ac180c27f450c9b053805b2bab6bf352cb"}, + {"filename": "parakeet-rnnt-1.1b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1042505984, "sha256": "14378bc2ccd71be446d7c869fb369ec856dcd02ca5a895194da6855b1d529665"}, + {"filename": "parakeet-rnnt-1.1b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1267285248, "sha256": "e1918556aa0e2b857c12cb365cff4f33b0f1cbcc0e0769499ec38948c8363775"}, + {"filename": "parakeet-rnnt-1.1b-F16.gguf", "quant": "F16", "size_bytes": 2145156480, "sha256": "d4608d90b26a3ff3f741b091d74ee0d0d9f9d30a7888914f251cb8821c2e1358"}, + {"filename": "parakeet-rnnt-1.1b-F32.gguf", "quant": "F32", "size_bytes": 4282189696, "sha256": "875406c56378dcc355c7fb3ac1d9ba2e7a0ab76432b7840839f02cdf2ef60901"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -2437,6 +1606,7 @@ }, { "id": "handy-computer/parakeet-tdt_ctc-1.1b-gguf", + "revision": "f1ea20171bfb7a1741e8b695d02e0a9bb2855996", "slug": "parakeet-tdt_ctc-1.1b", "name": "Parakeet TDT-CTC 1.1B", "architecture": "parakeet", @@ -2456,36 +1626,12 @@ "speed_score": 75, "accuracy_score": 88, "files": [ - { - "filename": "parakeet-tdt_ctc-1.1b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 825248000 - }, - { - "filename": "parakeet-tdt_ctc-1.1b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 935758080 - }, - { - "filename": "parakeet-tdt_ctc-1.1b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1042509056 - }, - { - "filename": "parakeet-tdt_ctc-1.1b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1267288320 - }, - { - "filename": "parakeet-tdt_ctc-1.1b-F16.gguf", - "quant": "F16", - "size_bytes": 2145162560 - }, - { - "filename": "parakeet-tdt_ctc-1.1b-F32.gguf", - "quant": "F32", - "size_bytes": 4282202176 - } + {"filename": "parakeet-tdt_ctc-1.1b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 825248000, "sha256": "5c27d9289e23019c459b1cef87baa5b672343733ccd3762796e68aec447169cd"}, + {"filename": "parakeet-tdt_ctc-1.1b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 935758080, "sha256": "da23eb8c78fe3813b88f616db85e8191cfbfffdbc2b8bb8bd74a94eb8987caee"}, + {"filename": "parakeet-tdt_ctc-1.1b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1042509056, "sha256": "961ac90755f0d0bff3515a1b09eaf1fee6049af46c03b733f94ffff6b857b917"}, + {"filename": "parakeet-tdt_ctc-1.1b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1267288320, "sha256": "56e826b96d0a625eb41f18d8f51d8600e8ec5959bf5278fe3929e5efefe787d6"}, + {"filename": "parakeet-tdt_ctc-1.1b-F16.gguf", "quant": "F16", "size_bytes": 2145162560, "sha256": "185565daaf0352c33464d421ae3cab8e6057890f7d6ead9d5e2b8333cc7ecff7"}, + {"filename": "parakeet-tdt_ctc-1.1b-F32.gguf", "quant": "F32", "size_bytes": 4282202176, "sha256": "16bdd9ecd6ae993e2b89edfa103ddc7ffc6e63a63d07e4f858038b8190a5972c"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -2493,6 +1639,7 @@ }, { "id": "handy-computer/Qwen3-ASR-1.7B-gguf", + "revision": "92282af1610a2db19d66f2bef1e260f5deca782d", "slug": "Qwen3-ASR-1.7B", "name": "Qwen3-ASR 1.7B", "architecture": "qwen3_asr", @@ -2512,36 +1659,12 @@ "speed_score": 38, "accuracy_score": 90, "files": [ - { - "filename": "Qwen3-ASR-1.7B-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1319830496 - }, - { - "filename": "Qwen3-ASR-1.7B-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1517290464 - }, - { - "filename": "Qwen3-ASR-1.7B-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1692554208 - }, - { - "filename": "Qwen3-ASR-1.7B-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2185030624 - }, - { - "filename": "Qwen3-ASR-1.7B-BF16.gguf", - "quant": "BF16", - "size_bytes": 4083087904 - }, - { - "filename": "Qwen3-ASR-1.7B-F16.gguf", - "quant": "F16", - "size_bytes": 4091390944 - } + {"filename": "Qwen3-ASR-1.7B-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1319830496, "sha256": "b7afe3674f653fa84f712ed2440353c6e7cf7f93697fef76b05a26538b24844e"}, + {"filename": "Qwen3-ASR-1.7B-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1517290464, "sha256": "034c557fe92ff8fcd9a9c041cbdaad347be0a86a58d3a348f63cf3f0180879d0"}, + {"filename": "Qwen3-ASR-1.7B-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1692554208, "sha256": "c75a961b7134a6c952d89797865cb0d0376876185aee04ef6d12c31c2952e4e1"}, + {"filename": "Qwen3-ASR-1.7B-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2185030624, "sha256": "9a0d81792dfea2d5f278b8a63deb3ea6e02139ce42c2301f32ea19c4f77526b7"}, + {"filename": "Qwen3-ASR-1.7B-BF16.gguf", "quant": "BF16", "size_bytes": 4083087904, "sha256": "57db745f8ec3ad2ea391b0205661e7d74f76158877a33f151e4d4624ed2b7cc9"}, + {"filename": "Qwen3-ASR-1.7B-F16.gguf", "quant": "F16", "size_bytes": 4091390944, "sha256": "edb09c29b8f73822c639168d5ef72aa2dccdf8b4e48fc4b8518885352ff62c71"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -2549,6 +1672,7 @@ }, { "id": "handy-computer/SenseVoiceSmall-gguf", + "revision": "4a08b8e900b38a977e32eb08d5d0697d6e72ba04", "slug": "SenseVoiceSmall", "name": "SenseVoice Small", "architecture": "sensevoice", @@ -2568,36 +1692,12 @@ "speed_score": 98, "accuracy_score": 81, "files": [ - { - "filename": "SenseVoiceSmall-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 145738304 - }, - { - "filename": "SenseVoiceSmall-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 172474880 - }, - { - "filename": "SenseVoiceSmall-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 196438336 - }, - { - "filename": "SenseVoiceSmall-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 252684608 - }, - { - "filename": "SenseVoiceSmall-F16.gguf", - "quant": "F16", - "size_bytes": 470412128 - }, - { - "filename": "SenseVoiceSmall-F32.gguf", - "quant": "F32", - "size_bytes": 936617824 - } + {"filename": "SenseVoiceSmall-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 145738304, "sha256": "9d775303e6b4e793d049ef226214c400b2b1746c734ed017146a1a085b13d02b"}, + {"filename": "SenseVoiceSmall-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 172474880, "sha256": "622a819ef9b37dc5506f0079ddedca6ec22e272bb9b4fe950d8f13422d1ccd0b"}, + {"filename": "SenseVoiceSmall-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 196438336, "sha256": "5724d2ff6329f4355a29cee6a2432ecd1f60547a0a4ef7726cfceda7dafb394f"}, + {"filename": "SenseVoiceSmall-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 252684608, "sha256": "6c759ee4c9748c9b3f7a5a60ca74f0f7e685fb9d45d1378fce7cfd62f59adf29"}, + {"filename": "SenseVoiceSmall-F16.gguf", "quant": "F16", "size_bytes": 470412128, "sha256": "dc172fd5ffaff0a396393cd4edd82e18403c53f8f5873bcc544ba012b70598f6"}, + {"filename": "SenseVoiceSmall-F32.gguf", "quant": "F32", "size_bytes": 936617824, "sha256": "dda511c3eac009568643b5c64541468d62be2e2a59700113f49c72675464d13d"} ], "default_quant": "Q8_0", "recommended": false, @@ -2605,6 +1705,7 @@ }, { "id": "handy-computer/Voxtral-Mini-3B-2507-gguf", + "revision": "5690205813042c07cbaa86d2a9dcc585fcd31304", "slug": "Voxtral-Mini-3B-2507", "name": "Voxtral Mini 3B", "architecture": "voxtral", @@ -2624,36 +1725,12 @@ "speed_score": 14, "accuracy_score": 88, "files": [ - { - "filename": "Voxtral-Mini-3B-2507-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 2984721056 - }, - { - "filename": "Voxtral-Mini-3B-2507-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 3464182432 - }, - { - "filename": "Voxtral-Mini-3B-2507-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 3869489824 - }, - { - "filename": "Voxtral-Mini-3B-2507-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 5000084128 - }, - { - "filename": "Voxtral-Mini-3B-2507-BF16.gguf", - "quant": "BF16", - "size_bytes": 9365764768 - }, - { - "filename": "Voxtral-Mini-3B-2507-F16.gguf", - "quant": "F16", - "size_bytes": 9376578208 - } + {"filename": "Voxtral-Mini-3B-2507-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 2984721056, "sha256": "3a6717aa8f8989108d260cbd237584289eec43cc987e10133c33643515936205"}, + {"filename": "Voxtral-Mini-3B-2507-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 3464182432, "sha256": "9ea2eb85359f7771270715849dc2b0e80381a59dab09d9c5d85352ab9c53f675"}, + {"filename": "Voxtral-Mini-3B-2507-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 3869489824, "sha256": "0be733fd436c2a89483b96ed8d398abbc3ce5bcaa7aac64e886f5eb9d9066a43"}, + {"filename": "Voxtral-Mini-3B-2507-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 5000084128, "sha256": "06b4dd04e32953ccab0d417125bdd728ec031431596ed5e80123c3c66f29ddc2"}, + {"filename": "Voxtral-Mini-3B-2507-BF16.gguf", "quant": "BF16", "size_bytes": 9365764768, "sha256": "ddfa5eb2e5e56343ee9b3262d5581f9b48085bdecefba71c0092901cf0183ccb"}, + {"filename": "Voxtral-Mini-3B-2507-F16.gguf", "quant": "F16", "size_bytes": 9376578208, "sha256": "a7f0bebce988a32ce7e736acffe19a48d9bc739789dfab7d13ca6b622d23dd6e"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -2661,6 +1738,7 @@ }, { "id": "handy-computer/Voxtral-Small-24B-2507-gguf", + "revision": "3b85044238d7d73b0063c54ee6d0754b5f061795", "slug": "Voxtral-Small-24B-2507", "name": "Voxtral Small 24B", "architecture": "voxtral", @@ -2680,36 +1758,12 @@ "speed_score": 10, "accuracy_score": 90, "files": [ - { - "filename": "Voxtral-Small-24B-2507-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 14302261728 - }, - { - "filename": "Voxtral-Small-24B-2507-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 17138659808 - }, - { - "filename": "Voxtral-Small-24B-2507-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 19936473568 - }, - { - "filename": "Voxtral-Small-24B-2507-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 25810383328 - }, - { - "filename": "Voxtral-Small-24B-2507-BF16.gguf", - "quant": "BF16", - "size_bytes": 48537285088 - }, - { - "filename": "Voxtral-Small-24B-2507-F16.gguf", - "quant": "F16", - "size_bytes": 48548098528 - } + {"filename": "Voxtral-Small-24B-2507-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 14302261728, "sha256": "0da1b866c0eb2ce805678c18a7fb1e413320e4ad3ccbef7160707b65bae937a3"}, + {"filename": "Voxtral-Small-24B-2507-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 17138659808, "sha256": "a53f73a5f63b7663fe155977616a636acac567dac898f6ef82ca519017e8b6e7"}, + {"filename": "Voxtral-Small-24B-2507-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 19936473568, "sha256": "e85950b9576d3913d2664abaac1cdcb3a967dbbc8a60febacaf137a7f4d216f0"}, + {"filename": "Voxtral-Small-24B-2507-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 25810383328, "sha256": "ab0964350131990a364dca53e3cac5f4d9cc176dce2f273066be3ef71e252fb2"}, + {"filename": "Voxtral-Small-24B-2507-BF16.gguf", "quant": "BF16", "size_bytes": 48537285088, "sha256": "5c38ff2d6dbb413d70dec37430825ff129862536f74149e015b3bc42c11c5745"}, + {"filename": "Voxtral-Small-24B-2507-F16.gguf", "quant": "F16", "size_bytes": 48548098528, "sha256": "d5cc73553d9422dada176dd02c1920dc72a888a01eed34e20816b0bab8db207e"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -2717,6 +1771,7 @@ }, { "id": "handy-computer/whisper-tiny-gguf", + "revision": "6687f30c99641ee265df421e582354adbc8848fc", "slug": "whisper-tiny", "name": "Whisper Tiny", "architecture": "whisper", @@ -2736,36 +1791,12 @@ "speed_score": 100, "accuracy_score": 61, "files": [ - { - "filename": "whisper-tiny-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 43621792 - }, - { - "filename": "whisper-tiny-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 44211616 - }, - { - "filename": "whisper-tiny-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 44838304 - }, - { - "filename": "whisper-tiny-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 45981088 - }, - { - "filename": "whisper-tiny-F16.gguf", - "quant": "F16", - "size_bytes": 80135360 - }, - { - "filename": "whisper-tiny-F32.gguf", - "quant": "F32", - "size_bytes": 152997824 - } + {"filename": "whisper-tiny-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 43621792, "sha256": "86e1100470df173fa9079f941e33e4d52d11c03923407dd1ec51fd1937d8e609"}, + {"filename": "whisper-tiny-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 44211616, "sha256": "72cfa8ee436a635a5b6fb373cc056a828b9efe96d32d6eb8769ed3cc5b429719"}, + {"filename": "whisper-tiny-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 44838304, "sha256": "e23e79dbc42e90171e050b0e7da00f07e41343547ef09d86588324b3c5672413"}, + {"filename": "whisper-tiny-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 45981088, "sha256": "325b9c7997cd1eff81ef709d55766565e71be696130cc3a3d444713798706834"}, + {"filename": "whisper-tiny-F16.gguf", "quant": "F16", "size_bytes": 80135360, "sha256": "5b44043278b47d3b6e56fb16c6bc5bb0aa16f2e69086f4d67175ed0a30d6a987"}, + {"filename": "whisper-tiny-F32.gguf", "quant": "F32", "size_bytes": 152997824, "sha256": "f5e0ecdd9967173fdede25ceb7e5159b3294f05b8dc0c87b0876fccd36f18982"} ], "default_quant": "Q8_0", "recommended": false, @@ -2773,6 +1804,7 @@ }, { "id": "handy-computer/whisper-tiny.en-gguf", + "revision": "becb8bcb804405dc97b380a523d9975888820986", "slug": "whisper-tiny.en", "name": "Whisper Tiny (English)", "architecture": "whisper", @@ -2792,36 +1824,12 @@ "speed_score": 100, "accuracy_score": 68, "files": [ - { - "filename": "whisper-tiny.en-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 43545248 - }, - { - "filename": "whisper-tiny.en-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 44135072 - }, - { - "filename": "whisper-tiny.en-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 44761760 - }, - { - "filename": "whisper-tiny.en-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 45904544 - }, - { - "filename": "whisper-tiny.en-F16.gguf", - "quant": "F16", - "size_bytes": 80058464 - }, - { - "filename": "whisper-tiny.en-F32.gguf", - "quant": "F32", - "size_bytes": 152920160 - } + {"filename": "whisper-tiny.en-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 43545248, "sha256": "3bfa6200aa12a21409445401f7871b5c733546dc45a29eb4871fcb3c7954e08b"}, + {"filename": "whisper-tiny.en-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 44135072, "sha256": "610a75700023ffd74df1ab538353525d0a82a3e35702b923e955e69422519740"}, + {"filename": "whisper-tiny.en-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 44761760, "sha256": "33b349eabeb0808562060b44dd539264f94365f09ed5317337207b955355abe4"}, + {"filename": "whisper-tiny.en-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 45904544, "sha256": "e8c9b73c06344307d8b346e07fbe93dd88d894627854bcff31523f1ce44394fa"}, + {"filename": "whisper-tiny.en-F16.gguf", "quant": "F16", "size_bytes": 80058464, "sha256": "7ae6eae54d625472e52b6af03b09dec586f0e948ffa5872815bb1988e973ad2b"}, + {"filename": "whisper-tiny.en-F32.gguf", "quant": "F32", "size_bytes": 152920160, "sha256": "4aaf936f214a08f199ff7daecf421eb28be273e4e4b3bba0486b98efed140045"} ], "default_quant": "Q8_0", "recommended": false, @@ -2829,6 +1837,7 @@ }, { "id": "handy-computer/whisper-base-gguf", + "revision": "e0f69524f648720eca44c024d1d0dbb7027d1fa0", "slug": "whisper-base", "name": "Whisper Base", "architecture": "whisper", @@ -2848,36 +1857,12 @@ "speed_score": 99, "accuracy_score": 71, "files": [ - { - "filename": "whisper-base-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 58870848 - }, - { - "filename": "whisper-base-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 63786048 - }, - { - "filename": "whisper-base-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 67865664 - }, - { - "filename": "whisper-base-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 84962880 - }, - { - "filename": "whisper-base-F16.gguf", - "quant": "F16", - "size_bytes": 151145760 - }, - { - "filename": "whisper-base-F32.gguf", - "quant": "F32", - "size_bytes": 292335904 - } + {"filename": "whisper-base-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 58870848, "sha256": "78bc9a8cb7e16a3354b262a4c71c0ed4892f6a66b681068f474dfada0729c0ae"}, + {"filename": "whisper-base-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 63786048, "sha256": "8e0feb7bc35780353cf31821018e601bb7b7cff6c9a0e17ada5a5db23f4db867"}, + {"filename": "whisper-base-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 67865664, "sha256": "6e4ac95aef48f28b5dd11b0be26a65e785ffbf3682cb076560a27e72206ea740"}, + {"filename": "whisper-base-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 84962880, "sha256": "81c069428bc8a24551a8169cf31cf09bcfd9d4cf50389ae281323c9aa9648c81"}, + {"filename": "whisper-base-F16.gguf", "quant": "F16", "size_bytes": 151145760, "sha256": "38ab6b0ed742e9eded4d5a2ba7fc34d44fc28cdb71d6360f6321d4b306ef8039"}, + {"filename": "whisper-base-F32.gguf", "quant": "F32", "size_bytes": 292335904, "sha256": "ab30724d251b16383fdbcf88698bcca6649eb98c4e102d6a7271a44d608bb4c6"} ], "default_quant": "Q8_0", "recommended": false, @@ -2885,6 +1870,7 @@ }, { "id": "handy-computer/whisper-base.en-gguf", + "revision": "cf0804db15fb341d00c9274b90da9cbb4fe2e5c6", "slug": "whisper-base.en", "name": "Whisper Base (English)", "architecture": "whisper", @@ -2904,36 +1890,12 @@ "speed_score": 99, "accuracy_score": 76, "files": [ - { - "filename": "whisper-base.en-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 58794272 - }, - { - "filename": "whisper-base.en-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 63709472 - }, - { - "filename": "whisper-base.en-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 67789088 - }, - { - "filename": "whisper-base.en-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 84886208 - }, - { - "filename": "whisper-base.en-F16.gguf", - "quant": "F16", - "size_bytes": 151068608 - }, - { - "filename": "whisper-base.en-F32.gguf", - "quant": "F32", - "size_bytes": 292257728 - } + {"filename": "whisper-base.en-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 58794272, "sha256": "fd19959ff81f96c2b66f26fad97aada090ed6b3af681cf5b569bef3d7a073da5"}, + {"filename": "whisper-base.en-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 63709472, "sha256": "549e6e991588cd748dc6bf53b8fe47629dc08ed9038b8955270ee98fbc5e4c54"}, + {"filename": "whisper-base.en-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 67789088, "sha256": "5de8fc00f70f538af2811c9992de502513d6d60336f32ed975b4b6eb91b69e73"}, + {"filename": "whisper-base.en-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 84886208, "sha256": "3b46ca40bccbf7609c68d88a36d96077a04ca7c87f2060ede06f129fac3e7652"}, + {"filename": "whisper-base.en-F16.gguf", "quant": "F16", "size_bytes": 151068608, "sha256": "e6c447b0cc761e8ac3fc2f5e0e6918f604e415a41cff97825f1503ccde376e1e"}, + {"filename": "whisper-base.en-F32.gguf", "quant": "F32", "size_bytes": 292257728, "sha256": "f694f59682d44bafe4e74b8b9cc395dfdc3737a4d5e1910e399d45683729ad2d"} ], "default_quant": "Q8_0", "recommended": false, @@ -2941,6 +1903,7 @@ }, { "id": "handy-computer/whisper-small.en-gguf", + "revision": "41b0f75fd44415ba127a5356c5ba9ed450c1debd", "slug": "whisper-small.en", "name": "Whisper Small (English)", "architecture": "whisper", @@ -2960,36 +1923,12 @@ "speed_score": 80, "accuracy_score": 81, "files": [ - { - "filename": "whisper-small.en-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 171553856 - }, - { - "filename": "whisper-small.en-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 193672256 - }, - { - "filename": "whisper-small.en-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 212030528 - }, - { - "filename": "whisper-small.en-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 269674144 - }, - { - "filename": "whisper-small.en-F16.gguf", - "quant": "F16", - "size_bytes": 492810784 - }, - { - "filename": "whisper-small.en-F32.gguf", - "quant": "F32", - "size_bytes": 968835616 - } + {"filename": "whisper-small.en-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 171553856, "sha256": "7bf9ebce1e6332b3d18d4c27032e3097148fc124befed3e70022e0d70539a365"}, + {"filename": "whisper-small.en-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 193672256, "sha256": "e96a679e57d36f80bb26512f3893185d219ddd8880cdac4c433e5af13a001b47"}, + {"filename": "whisper-small.en-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 212030528, "sha256": "3703d6f9e4d78b5b2f196f9c3cfab5c1b6b268fe42fff311cb89137506f92019"}, + {"filename": "whisper-small.en-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 269674144, "sha256": "9614e6b7fda2d26018e4f268aece8ca25a83296ea0b534169a585b740bfd71ef"}, + {"filename": "whisper-small.en-F16.gguf", "quant": "F16", "size_bytes": 492810784, "sha256": "59eade60e072bd289c9ff39313ef14b565691bfe373275ea904ecea497e47c8a"}, + {"filename": "whisper-small.en-F32.gguf", "quant": "F32", "size_bytes": 968835616, "sha256": "fce06c84aa930d0652d292781b4470fb4d5631527e211fd4f402703f63ee7291"} ], "default_quant": "Q8_0", "recommended": false, @@ -2997,6 +1936,7 @@ }, { "id": "handy-computer/whisper-small-gguf", + "revision": "c0214bd34be9296695486f838e0142f900803159", "slug": "whisper-small", "name": "Whisper Small", "architecture": "whisper", @@ -3016,36 +1956,12 @@ "speed_score": 78, "accuracy_score": 80, "files": [ - { - "filename": "whisper-small-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 171630656 - }, - { - "filename": "whisper-small-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 193749056 - }, - { - "filename": "whisper-small-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 212107328 - }, - { - "filename": "whisper-small-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 269751136 - }, - { - "filename": "whisper-small-F16.gguf", - "quant": "F16", - "size_bytes": 492888480 - }, - { - "filename": "whisper-small-F32.gguf", - "quant": "F32", - "size_bytes": 968914848 - } + {"filename": "whisper-small-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 171630656, "sha256": "b204d2005a3e5d4fe6153bd61e5e8b32e757ff7b017ac8f61c6f051c2f80e939"}, + {"filename": "whisper-small-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 193749056, "sha256": "326cd00c3e7217c751667c7c1600eaf7e0de174e186ca2c16b4bf590251c3c3b"}, + {"filename": "whisper-small-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 212107328, "sha256": "4d14215a972101fff787fc59e30b908ee511c725600317e02c9032cb8799f32d"}, + {"filename": "whisper-small-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 269751136, "sha256": "9b9c8811bbcc82a7766f0fb0925614bdacb0923b2cc630daeac17108b655b860"}, + {"filename": "whisper-small-F16.gguf", "quant": "F16", "size_bytes": 492888480, "sha256": "bef65e1ac9d012269453243243aac0d0f67792693ba6c99b584a124e0ee326fc"}, + {"filename": "whisper-small-F32.gguf", "quant": "F32", "size_bytes": 968914848, "sha256": "6bc797078fdddcc890999130dfc33dfc30d82de5a9115a840ec73853262a9d21"} ], "default_quant": "Q8_0", "recommended": false, @@ -3053,6 +1969,7 @@ }, { "id": "handy-computer/whisper-medium.en-gguf", + "revision": "f25c70d9095dcfdad187ebb3b113d157b414aee8", "slug": "whisper-medium.en", "name": "Whisper Medium (English)", "architecture": "whisper", @@ -3072,36 +1989,12 @@ "speed_score": 44, "accuracy_score": 83, "files": [ - { - "filename": "whisper-medium.en-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 504025856 - }, - { - "filename": "whisper-medium.en-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 582669056 - }, - { - "filename": "whisper-medium.en-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 647942912 - }, - { - "filename": "whisper-medium.en-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 831460928 - }, - { - "filename": "whisper-medium.en-F16.gguf", - "quant": "F16", - "size_bytes": 1541853248 - }, - { - "filename": "whisper-medium.en-F32.gguf", - "quant": "F32", - "size_bytes": 3057356864 - } + {"filename": "whisper-medium.en-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 504025856, "sha256": "8a0eb0bb20019af88fc0ccda06d97dc949a6a0cdf35d3f2c316b967e76cd4077"}, + {"filename": "whisper-medium.en-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 582669056, "sha256": "8c90b55725036f1362fc5a583432a4e63370304dbf0f96b6245685f8a588174f"}, + {"filename": "whisper-medium.en-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 647942912, "sha256": "9abdf673a4096ccb8bd210a3c68d73665c3ac8450bdeb54fd35c255a28b63d55"}, + {"filename": "whisper-medium.en-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 831460928, "sha256": "03d7257fef498750ce272631bc6a34de322fc2b438aab5c268ff49dfd1b64c49"}, + {"filename": "whisper-medium.en-F16.gguf", "quant": "F16", "size_bytes": 1541853248, "sha256": "c8be41b68306ad35382af749d4681b33da403b7d82eb3b6d3225a01a02dc1196"}, + {"filename": "whisper-medium.en-F32.gguf", "quant": "F32", "size_bytes": 3057356864, "sha256": "6a0bcb8c3360dbc8101a1ebcf2776d1e3463538c4b9486da97b8f21e462e6039"} ], "default_quant": "Q8_0", "recommended": false, @@ -3109,67 +2002,49 @@ }, { "id": "handy-computer/whisper-large-v3-turbo-gguf", + "revision": "5eaf945c7978e564bae5b28a5b1639dd93c2bfb1", "slug": "whisper-large-v3-turbo", "name": "Whisper Large v3 Turbo", "architecture": "whisper", "family": "whisper", "parameters": "809M", - "description": "100-language speech-to-text with translation, auto language detection, segment-level timestamps.", + "description": "100-language speech-to-text with auto language detection, segment-level timestamps.", "base_model": "openai/whisper-large-v3-turbo", "license": "apache-2.0", "language_count": 100, "languages": ["en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca", "nl", "ar", "sv", "it", "id", "hi", "fi", "vi", "he", "uk", "el", "ms", "cs", "ro", "da", "hu", "ta", "no", "th", "ur", "hr", "bg", "lt", "la", "mi", "ml", "cy", "sk", "te", "fa", "lv", "bn", "sr", "az", "sl", "kn", "et", "mk", "br", "eu", "is", "hy", "ne", "mn", "bs", "kk", "sq", "sw", "gl", "mr", "pa", "si", "km", "sn", "yo", "so", "af", "oc", "ka", "be", "tg", "sd", "gu", "am", "yi", "lo", "uz", "fo", "ht", "ps", "tk", "nn", "mt", "sa", "lb", "my", "bo", "tl", "mg", "as", "tt", "haw", "ln", "ha", "ba", "jw", "su", "yue"], "capabilities": { "streaming": false, - "translate": true, + "translate": false, "lang_detect": true, "timestamps": "segment" }, "speed_score": 35, - "accuracy_score": 87, + "accuracy_score": 88, "files": [ - { - "filename": "whisper-large-v3-turbo-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 536069792 - }, - { - "filename": "whisper-large-v3-turbo-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 619628192 - }, - { - "filename": "whisper-large-v3-turbo-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 692536992 - }, - { - "filename": "whisper-large-v3-turbo-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 886381824 - }, - { - "filename": "whisper-large-v3-turbo-F16.gguf", - "quant": "F16", - "size_bytes": 1636749024 - } + {"filename": "whisper-large-v3-turbo-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 536069728, "sha256": "ecfe9b6beb4ab18fef49187cc968cc74b5168b94629c8830e2ca6b794c6e25ed"}, + {"filename": "whisper-large-v3-turbo-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 619628128, "sha256": "977b5db4e004349dffd1ab9caa10ba5aaba3fc3edd3ba72cadb84328a3203e36"}, + {"filename": "whisper-large-v3-turbo-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 692536928, "sha256": "3881dc17bf345f7ce4a3c48327074951e1550c8a1e89fa58d554d9f26a1b6274"}, + {"filename": "whisper-large-v3-turbo-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 886381760, "sha256": "b2e30cc286bc9f3aba4db9099fc7403543497c05ce7100d0d83091ddfd25a183"}, + {"filename": "whisper-large-v3-turbo-F16.gguf", "quant": "F16", "size_bytes": 1625935520, "sha256": "e1d0144e9afc9f479d9e51fc92c7dea9dc36059655eeb3819f16ad2de779046a"} ], "default_quant": "Q8_0", "recommended": false, "recommended_rank": null }, { - "id": "handy-computer/whisper-large-v3-gguf", - "slug": "whisper-large-v3", - "name": "Whisper Large v3", + "id": "handy-computer/Breeze-ASR-25-gguf", + "revision": "1e5e8d7295110e1e14e305e9cf7411d82711beaa", + "slug": "Breeze-ASR-25", + "name": "Breeze-ASR-25", "architecture": "whisper", "family": "whisper", "parameters": "1.5B", - "description": "100-language speech-to-text with translation, auto language detection, segment-level timestamps.", - "base_model": "openai/whisper-large-v3", + "description": "Optimized for Taiwanese Mandarin. Code-switching support.", + "base_model": "MediaTek-Research/Breeze-ASR-25", "license": "apache-2.0", - "language_count": 100, - "languages": ["en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca", "nl", "ar", "sv", "it", "id", "hi", "fi", "vi", "he", "uk", "el", "ms", "cs", "ro", "da", "hu", "ta", "no", "th", "ur", "hr", "bg", "lt", "la", "mi", "ml", "cy", "sk", "te", "fa", "lv", "bn", "sr", "az", "sl", "kn", "et", "mk", "br", "eu", "is", "hy", "ne", "mn", "bs", "kk", "sq", "sw", "gl", "mr", "pa", "si", "km", "sn", "yo", "so", "af", "oc", "ka", "be", "tg", "sd", "gu", "am", "yi", "lo", "uz", "fo", "ht", "ps", "tk", "nn", "mt", "sa", "lb", "my", "bo", "tl", "mg", "as", "tt", "haw", "ln", "ha", "ba", "jw", "su", "yue"], + "language_count": 2, + "languages": ["zh", "en"], "capabilities": { "streaming": false, "translate": true, @@ -3177,33 +2052,14 @@ "timestamps": "segment" }, "speed_score": 23, - "accuracy_score": 89, + "accuracy_score": 86, "files": [ - { - "filename": "whisper-large-v3-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 997303008 - }, - { - "filename": "whisper-large-v3-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1161143008 - }, - { - "filename": "whisper-large-v3-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1297130208 - }, - { - "filename": "whisper-large-v3-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1668741440 - }, - { - "filename": "whisper-large-v3-F16.gguf", - "quant": "F16", - "size_bytes": 3107236640 - } + {"filename": "Breeze-ASR-25-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 996526080, "sha256": "cb056e63e5606284637c8a9c6d35e3c544f190dd5d26335466c7e584e61815c4"}, + {"filename": "Breeze-ASR-25-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1160366080, "sha256": "c871fc811b33a16a5607c4d4166cfa2c0a1d359f7796e3da255e3e922f59139b"}, + {"filename": "Breeze-ASR-25-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1296353280, "sha256": "4c7fe8eed7b36a00dc345e9825fb0f4ac717c38cc5a9292cd552c2f141e3b258"}, + {"filename": "Breeze-ASR-25-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1667964224, "sha256": "1650c163cd1623d13b585368ffd87d7e669c5ff23014511e0fa91bb4ce994756"}, + {"filename": "Breeze-ASR-25-BF16.gguf", "quant": "BF16", "size_bytes": 3096013408, "sha256": "b2ae64afd2bea002c0188bba34e868f208eca8ab2b5d1359bd5d4b052209851e"}, + {"filename": "Breeze-ASR-25-F16.gguf", "quant": "F16", "size_bytes": 3106458208, "sha256": "1ed33e23e1dd7183c7d60e8713d159a61bfa1852019e7a65fd04ec6042a44f98"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -3211,6 +2067,7 @@ }, { "id": "handy-computer/whisper-large-gguf", + "revision": "1b99acc257d1a605153c9e4d818a065569a3fdcd", "slug": "whisper-large", "name": "Whisper Large", "architecture": "whisper", @@ -3230,36 +2087,12 @@ "speed_score": 23, "accuracy_score": 83, "files": [ - { - "filename": "whisper-large-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 996526048 - }, - { - "filename": "whisper-large-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1160366048 - }, - { - "filename": "whisper-large-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1296353248 - }, - { - "filename": "whisper-large-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1667964192 - }, - { - "filename": "whisper-large-F16.gguf", - "quant": "F16", - "size_bytes": 3106458176 - }, - { - "filename": "whisper-large-F32.gguf", - "quant": "F32", - "size_bytes": 6175245376 - } + {"filename": "whisper-large-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 996526048, "sha256": "7d7693afc06096de46f2a41309ac479909e131824e78dae4e205f35463aaec09"}, + {"filename": "whisper-large-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1160366048, "sha256": "b1dfb9fa0e9b0574d8c822f51662b8e59915831e1a3ca0d191f18ff8ba742639"}, + {"filename": "whisper-large-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1296353248, "sha256": "04756b664385d0ec26af1ef43b94bccdaf2d6dce83b52f482d2232b67642b6bc"}, + {"filename": "whisper-large-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1667964192, "sha256": "0b9c63f73c68327d6ba2386e40c4a4dc46a9e7114a78c93a464b48b72c001547"}, + {"filename": "whisper-large-F16.gguf", "quant": "F16", "size_bytes": 3106458176, "sha256": "2c6198aeb009e01bf03cce9a30374b86ecd3484cd53b245e4a9f5bf7a670c498"}, + {"filename": "whisper-large-F32.gguf", "quant": "F32", "size_bytes": 6175245376, "sha256": "fafb2d4c6f865fc6cd745be8781eaf87d8d7a289cf3556283849fb1cc6741717"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -3267,6 +2100,7 @@ }, { "id": "handy-computer/whisper-large-v2-gguf", + "revision": "b64d145562182428ef04ca992b182aec8c61e578", "slug": "whisper-large-v2", "name": "Whisper Large v2", "architecture": "whisper", @@ -3286,53 +2120,30 @@ "speed_score": 23, "accuracy_score": 84, "files": [ - { - "filename": "whisper-large-v2-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 996526080 - }, - { - "filename": "whisper-large-v2-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1160366080 - }, - { - "filename": "whisper-large-v2-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1296353280 - }, - { - "filename": "whisper-large-v2-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1667964224 - }, - { - "filename": "whisper-large-v2-F16.gguf", - "quant": "F16", - "size_bytes": 3106458208 - }, - { - "filename": "whisper-large-v2-F32.gguf", - "quant": "F32", - "size_bytes": 6175245408 - } + {"filename": "whisper-large-v2-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 996526080, "sha256": "76aa37b205abc1fb7a9e7aaf0655b8747995b81e6bb72c18f4b1acf59e222f79"}, + {"filename": "whisper-large-v2-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1160366080, "sha256": "94fab1289a98d26bf375174a8daa00f44c7b16a9944726dde05361e5a8bd07ff"}, + {"filename": "whisper-large-v2-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1296353280, "sha256": "d216cb645a4a0b158a465f55c18a8317872788c38b3dbc0895c00d5c3bba72c8"}, + {"filename": "whisper-large-v2-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1667964224, "sha256": "704603cff0f82ddd7f7f51045e12e5c8f85a37a9edec72ab10fd8ede32220702"}, + {"filename": "whisper-large-v2-F16.gguf", "quant": "F16", "size_bytes": 3106458208, "sha256": "c8ffa39202367b896cdf642cd28c28fb9ac4d90f7f12795a219952c70bc63f04"}, + {"filename": "whisper-large-v2-F32.gguf", "quant": "F32", "size_bytes": 6175245408, "sha256": "eed42074ca931551b6bb9c80e658bb0d646c80da06acb03a49cb6326d2a57ced"} ], "default_quant": "Q5_K_M", "recommended": false, "recommended_rank": null }, { - "id": "handy-computer/Breeze-ASR-25-gguf", - "slug": "Breeze-ASR-25", - "name": "Breeze-ASR-25", + "id": "handy-computer/whisper-large-v3-gguf", + "revision": "e3e29bee6389c7da4a141406f07bb80ddac5337c", + "slug": "whisper-large-v3", + "name": "Whisper Large v3", "architecture": "whisper", "family": "whisper", "parameters": "1.5B", - "description": "Optimized for Taiwanese Mandarin. Code-switching support.", - "base_model": "MediaTek-Research/Breeze-ASR-25", + "description": "100-language speech-to-text with translation, auto language detection, segment-level timestamps.", + "base_model": "openai/whisper-large-v3", "license": "apache-2.0", - "language_count": 2, - "languages": ["zh", "en"], + "language_count": 100, + "languages": ["en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca", "nl", "ar", "sv", "it", "id", "hi", "fi", "vi", "he", "uk", "el", "ms", "cs", "ro", "da", "hu", "ta", "no", "th", "ur", "hr", "bg", "lt", "la", "mi", "ml", "cy", "sk", "te", "fa", "lv", "bn", "sr", "az", "sl", "kn", "et", "mk", "br", "eu", "is", "hy", "ne", "mn", "bs", "kk", "sq", "sw", "gl", "mr", "pa", "si", "km", "sn", "yo", "so", "af", "oc", "ka", "be", "tg", "sd", "gu", "am", "yi", "lo", "uz", "fo", "ht", "ps", "tk", "nn", "mt", "sa", "lb", "my", "bo", "tl", "mg", "as", "tt", "haw", "ln", "ha", "ba", "jw", "su", "yue"], "capabilities": { "streaming": false, "translate": true, @@ -3340,42 +2151,17 @@ "timestamps": "segment" }, "speed_score": 23, - "accuracy_score": 86, + "accuracy_score": 89, "files": [ - { - "filename": "Breeze-ASR-25-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 996526080 - }, - { - "filename": "Breeze-ASR-25-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1160366080 - }, - { - "filename": "Breeze-ASR-25-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1296353280 - }, - { - "filename": "Breeze-ASR-25-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1667964224 - }, - { - "filename": "Breeze-ASR-25-BF16.gguf", - "quant": "BF16", - "size_bytes": 3096013408 - }, - { - "filename": "Breeze-ASR-25-F16.gguf", - "quant": "F16", - "size_bytes": 3106458208 - } + {"filename": "whisper-large-v3-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 997303008, "sha256": "6fe933811cec4cd3159debc46520ecd3aac6c7e322ece2ac61fcfdab184e1fe0"}, + {"filename": "whisper-large-v3-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1161143008, "sha256": "6053d0fd69a0fd48b8fea5ea7a52b9e0cde389343566fa30e453a1b2b258dc38"}, + {"filename": "whisper-large-v3-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1297130208, "sha256": "88e6dc60bd0538da145a50c19f4778e4a106fe28713adeeca43163215ee02805"}, + {"filename": "whisper-large-v3-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1668741440, "sha256": "2fa1a5f179f8a511a53e2108db270aa4af3ce08cd976af4180e2854666bb4ba3"}, + {"filename": "whisper-large-v3-F16.gguf", "quant": "F16", "size_bytes": 3107236640, "sha256": "e633ab1d74b0e98f4f57daedaee34291297dbbf01389bda3d890766600c1c584"} ], "default_quant": "Q5_K_M", "recommended": false, "recommended_rank": null } ] -} +} \ No newline at end of file diff --git a/src-tauri/src/catalog/mod.rs b/src-tauri/src/catalog/mod.rs index 3472995f87..64d0a0ceaa 100644 --- a/src-tauri/src/catalog/mod.rs +++ b/src-tauri/src/catalog/mod.rs @@ -25,6 +25,11 @@ use crate::managers::model_capabilities::{CapabilityProbe, Compatibility}; #[derive(Deserialize)] struct CatalogRoot { + /// Base URLs tried in order when the Hugging Face download fails. The full + /// file URL is `{mirror}/{repo_id}/{revision}/{filename}` — the same three + /// values that form the HF resolve URL, so a mirror is a plain static host. + #[serde(default)] + mirrors: Vec, models: Vec, } @@ -34,6 +39,12 @@ struct CatalogRoot { struct CatalogModel { /// HF repo id, e.g. `handy-computer/whisper-small-gguf`. id: String, + /// Commit sha the catalog's sizes/hashes were generated from. Both HF + /// acquisition and mirror keys use it, so downloaded bytes provably match + /// the hashes regardless of source. Cache *lookup* additionally falls back + /// to `main` (see `hf_cached_path`) so downloads that predate pinning keep + /// resolving. + revision: Option, name: String, description: String, architecture: Option, @@ -59,8 +70,8 @@ struct CatalogCaps { // `CapabilityProbe` field yet — wire it through when the probe gains one. } -impl From for ModelDescriptor { - fn from(m: CatalogModel) -> Self { +impl From<&CatalogModel> for ModelDescriptor { + fn from(m: &CatalogModel) -> Self { // The default download file. Its name is folded into the id so a catalog // entry collides (dedups) with the very same file later discovered in // the HF cache — both compute `"{repo_id}/{filename}"`. @@ -71,24 +82,27 @@ impl From for ModelDescriptor { ModelDescriptor { id: format!("{}/{}", m.id, default_filename), source: ModelSource::HuggingFace { - repo_id: m.id, - revision: "main".to_string(), + repo_id: m.id.clone(), + // Acquire at the pin: `resolve/` is immutable (CDN-friendly) + // and guarantees the bytes match the catalog's hashes. `main` + // only remains as a lookup fallback for pre-pinning caches. + revision: m.revision.clone().unwrap_or_else(|| "main".to_string()), }, - name: m.name, - description: m.description, + name: m.name.clone(), + description: m.description.clone(), engine_type: EngineType::TranscribeCpp, caps: CapabilityProbe { verdict: Compatibility::Compatible, // curated org models we ship support for display_name: None, - architecture: m.architecture, + architecture: m.architecture.clone(), variant: None, - languages: Some(m.languages), + languages: Some(m.languages.clone()), supports_streaming: Some(m.capabilities.streaming), supports_translation: Some(m.capabilities.translate), supports_language_detect: Some(m.capabilities.lang_detect), }, - files: m.files, - default_quant: m.default_quant, + files: m.files.clone(), + default_quant: m.default_quant.clone(), // catalog scores are 0–100; ModelInfo / the UI bars use 0.0–1.0. speed_score: m.speed_score.unwrap_or(0.0) / 100.0, accuracy_score: m.accuracy_score.unwrap_or(0.0) / 100.0, @@ -98,13 +112,90 @@ impl From for ModelDescriptor { } } -/// The bundled catalog, parsed once and normalised into descriptors. -pub static CATALOG: Lazy> = Lazy::new(|| { - let root: CatalogRoot = serde_json::from_str(include_str!("catalog.json")) - .expect("bundled catalog.json is valid JSON matching the catalog schema"); - root.models.into_iter().map(ModelDescriptor::from).collect() +/// The raw parsed catalog. Kept alive (not consumed) so mirror metadata that +/// deliberately stays out of [`ModelDescriptor`] can be looked up separately. +static ROOT: Lazy = Lazy::new(|| { + serde_json::from_str(include_str!("catalog.json")) + .expect("bundled catalog.json is valid JSON matching the catalog schema") }); +/// The bundled catalog, parsed once and normalised into descriptors. +pub static CATALOG: Lazy> = + Lazy::new(|| ROOT.models.iter().map(ModelDescriptor::from).collect()); + +/// A mirror copy of a catalog model's default file, with the expected content +/// hash for end-to-end verification. Mirrors are untrusted bit-pipes: the +/// sha256 here (from the catalog compiled into the binary) is the trust anchor, +/// which is why it is mandatory — a file without one is never offered from a +/// mirror at all. +pub struct MirrorFile { + pub url: String, + pub sha256: String, + /// Catalog size — drives progress totals and resume sanity checks. + pub size_bytes: u64, +} + +/// Ordered mirror URLs for a catalog model's file — any listed quant, not just +/// the default — or empty when the model isn't from the catalog / no mirrors +/// are configured. `model_id` is the registry id (`"{repo_id}/{filename}"`). +/// (The mirror may only host default quants; a miss there just 404s and the +/// caller reports it, so listing every quant here costs nothing.) +pub fn mirror_fallbacks(model_id: &str) -> Vec { + let Some((m, file)) = ROOT.models.iter().find_map(|m| { + m.files + .iter() + .find(|f| format!("{}/{}", m.id, f.filename) == model_id) + .map(|f| (m, f)) + }) else { + return Vec::new(); + }; + let Some(revision) = m.revision.as_deref() else { + return Vec::new(); + }; + // No hash means no verification means no mirror: never fetch from an + // untrusted host without the catalog trust anchor. + let Some(sha256) = file.sha256.as_deref() else { + return Vec::new(); + }; + ROOT.mirrors + .iter() + .map(|base| MirrorFile { + url: format!( + "{}/{}/{}/{}", + base.trim_end_matches('/'), + m.id, + revision, + file.filename + ), + sha256: sha256.to_string(), + size_bytes: file.size_bytes, + }) + .collect() +} + +/// The catalog descriptor + specific `files[]` entry owning `filename`, +/// matched across every listed quant (not just the default). `repo_id`, when +/// given, must also match — the HF-cache scan uses it to keep a foreign repo +/// that happens to reuse a catalog filename from masquerading as ours. +pub fn file_in_catalog( + filename: &str, + repo_id: Option<&str>, +) -> Option<(&'static ModelDescriptor, &'static QuantFile)> { + let catalog: &'static Vec = Lazy::force(&CATALOG); + catalog.iter().find_map(|d| { + if let Some(repo) = repo_id { + match &d.source { + ModelSource::HuggingFace { repo_id: r, .. } if r == repo => {} + _ => return None, + } + } + d.files + .iter() + .find(|f| f.filename == filename) + .map(|f| (d, f)) + }) +} + /// Editorial recommended rank keyed by descriptor id (the same id the model /// registry uses). Built once from the catalog. static RANK_BY_ID: Lazy> = Lazy::new(|| { @@ -148,6 +239,26 @@ mod tests { } } + #[test] + fn every_catalog_model_has_mirror_fallbacks_with_hashes() { + // The mirror fallback is the safety net for HF outages and blocked + // networks; a catalog entry without one (missing revision, missing + // sha256, empty mirrors) silently loses that net. + for d in CATALOG.iter() { + let mirrors = mirror_fallbacks(&d.id); + assert!(!mirrors.is_empty(), "{}: no mirror fallbacks", d.id); + for m in &mirrors { + assert!( + m.sha256.len() == 64, + "{}: mirror entry lacks a sha256", + d.id + ); + assert!(m.size_bytes > 0, "{}: mirror entry lacks a size", d.id); + assert!(m.url.starts_with("https://"), "{}: bad url {}", d.id, m.url); + } + } + } + #[test] fn catalog_architectures_are_known_to_capability_probe() { let missing: BTreeSet<&str> = CATALOG diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index 5e425745b8..01b60f66c4 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -22,7 +22,15 @@ fn paste_via_clipboard( paste_delay_after_ms: u64, ) -> Result<(), String> { let clipboard = app_handle.clipboard(); - let clipboard_content = clipboard.read_text().unwrap_or_default(); + let saved_text = clipboard.read_text().ok().filter(|t| !t.is_empty()); + // Only probe for an image when there is no text to restore. Text is by far the + // common case, and reading an image decodes the full bitmap, so this keeps the + // text path exactly as cheap as it was before. + let saved_image = if saved_text.is_none() { + clipboard.read_image().ok().map(|image| image.to_owned()) + } else { + None + }; // Write text to clipboard first // On Wayland, prefer wl-copy for better compatibility (especially with umlauts) @@ -55,27 +63,40 @@ fn paste_via_clipboard( // Fall back to enigo if no native tool handled it if !key_combo_sent { match paste_method { - PasteMethod::CtrlV => input::send_paste_ctrl_v(enigo)?, - PasteMethod::CtrlShiftV => input::send_paste_ctrl_shift_v(enigo)?, - PasteMethod::ShiftInsert => input::send_paste_shift_insert(enigo)?, + // The legacy path cannot detect a mistimed chord, so it keeps the + // conservative 100ms modifier hold. + PasteMethod::CtrlV => input::send_paste_ctrl_v(enigo, 100)?, + PasteMethod::CtrlShiftV => input::send_paste_ctrl_shift_v(enigo, 100)?, + PasteMethod::ShiftInsert => input::send_paste_shift_insert(enigo, 100)?, _ => return Err("Invalid paste method for clipboard paste".into()), } } std::thread::sleep(Duration::from_millis(paste_delay_after_ms)); - // Restore original clipboard content - // On Wayland, prefer wl-copy for better compatibility - #[cfg(target_os = "linux")] - if is_wayland() && is_wl_copy_available() { - let _ = write_clipboard_via_wl_copy(&clipboard_content); - } else { + // Restore original clipboard content. + // Text takes priority so this path stays identical to the previous behavior; + // an image is only restored when the clipboard held no text at all, which is + // the case that used to silently wipe screenshots. + if let Some(clipboard_content) = saved_text { + // On Wayland, prefer wl-copy for better compatibility + #[cfg(target_os = "linux")] + if is_wayland() && is_wl_copy_available() { + let _ = write_clipboard_via_wl_copy(&clipboard_content); + } else { + let _ = clipboard.write_text(&clipboard_content); + } + + #[cfg(not(target_os = "linux"))] let _ = clipboard.write_text(&clipboard_content); + } else if let Some(image) = saved_image { + info!("Restoring image to clipboard"); + let _ = clipboard.write_image(&image); + } else { + // Nothing was there to begin with — don't leave the transcription behind. + let _ = clipboard.clear(); } - #[cfg(not(target_os = "linux"))] - let _ = clipboard.write_text(&clipboard_content); - Ok(()) } @@ -406,9 +427,11 @@ fn write_clipboard_via_wl_copy(text: &str) -> Result<(), String> { #[cfg(target_os = "linux")] fn send_key_combo_via_wtype(paste_method: &PasteMethod) -> Result<(), String> { let args: Vec<&str> = match paste_method { - PasteMethod::CtrlV => vec!["-M", "ctrl", "-k", "v"], - PasteMethod::ShiftInsert => vec!["-M", "shift", "-k", "Insert"], - PasteMethod::CtrlShiftV => vec!["-M", "ctrl", "-M", "shift", "-k", "v"], + PasteMethod::CtrlV => vec!["-M", "ctrl", "-k", "v", "-m", "ctrl"], + PasteMethod::ShiftInsert => vec!["-M", "shift", "-k", "Insert", "-m", "shift"], + PasteMethod::CtrlShiftV => vec![ + "-M", "ctrl", "-M", "shift", "-k", "v", "-m", "shift", "-m", "ctrl", + ], _ => return Err("Unsupported paste method".into()), }; @@ -542,7 +565,7 @@ fn paste_direct( input::paste_text_direct(enigo, text) } -fn send_return_key(enigo: &mut Enigo, key_type: AutoSubmitKey) -> Result<(), String> { +pub(crate) fn send_return_key(enigo: &mut Enigo, key_type: AutoSubmitKey) -> Result<(), String> { match key_type { AutoSubmitKey::Enter => { enigo @@ -630,6 +653,28 @@ pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { )?; } PasteMethod::CtrlV | PasteMethod::CtrlShiftV | PasteMethod::ShiftInsert => { + // Debug-gated receipt-sequenced paste (#502): restore the clipboard + // after the target actually reads the transcript, not on a timer. + // On success it fully handles the paste (including auto-submit and + // clipboard handling) asynchronously; on failure fall through to + // the legacy path untouched. + #[cfg(any(target_os = "macos", target_os = "windows"))] + if settings.reliable_paste { + match crate::paste_tx::try_reliable_paste( + &text, + &app_handle, + &paste_method, + &mut enigo, + settings.auto_submit, + settings.auto_submit_key, + settings.clipboard_handling, + ) { + Ok(()) => return Ok(()), + Err(e) => { + log::warn!("Reliable paste unavailable ({e}); falling back to legacy paste") + } + } + } paste_via_clipboard( &mut enigo, &text, diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index c06d28ecae..07920e2527 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -1,5 +1,5 @@ use crate::audio_feedback; -use crate::audio_toolkit::audio::{list_input_devices, list_output_devices}; +use crate::audio_toolkit::audio::{list_input_devices, list_output_devices, AudioRecorder}; use crate::managers::audio::{AudioRecordingManager, MicrophoneMode}; use crate::settings::{get_settings, write_settings}; use log::warn; @@ -87,14 +87,19 @@ fn get_windows_microphone_permission_status_impl() -> WindowsMicrophonePermissio let app_access = read_registry_permission_access(HKEY_CURRENT_USER, MICROPHONE_PATH); let desktop_app_access = read_registry_permission_access(HKEY_CURRENT_USER, DESKTOP_APPS_PATH); - let overall_access = if [device_access, app_access, desktop_app_access] - .into_iter() - .any(|access| access == PermissionAccess::Denied) - { + // Handy is a desktop app, so the NonPackaged key (desktop_app_access) is + // the relevant permission scope. The UWP master key (app_access) can be + // "deny" on systems with debloaters (e.g. O&O ShutUp10) without actually + // blocking desktop app microphone access. + let overall_access = if device_access == PermissionAccess::Denied { + PermissionAccess::Denied + } else if desktop_app_access == PermissionAccess::Denied { PermissionAccess::Denied - } else if [device_access, app_access, desktop_app_access] - .into_iter() - .all(|access| access == PermissionAccess::Allowed) + } else if desktop_app_access == PermissionAccess::Allowed { + PermissionAccess::Allowed + } else if app_access == PermissionAccess::Denied { + PermissionAccess::Denied + } else if device_access == PermissionAccess::Allowed && app_access == PermissionAccess::Allowed { PermissionAccess::Allowed } else { @@ -151,21 +156,26 @@ pub fn open_microphone_privacy_settings() -> Result<(), String> { #[tauri::command] #[specta::specta] -pub fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> { - // Update settings +pub async fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> { + // Update settings (fast, stays inline) let mut settings = get_settings(&app); settings.always_on_microphone = always_on; write_settings(&app, settings); - // Update the audio manager mode - let rm = app.state::>(); + // Update the audio manager mode. update_mode can stop/start the cpal stream + // (blocking CoreAudio) and takes the manager std mutexes — run it on a + // blocking thread, NOT inline on the webview/main run loop (a slow device + // open/close would freeze the UI). + let rm = app.state::>().inner().clone(); let new_mode = if always_on { MicrophoneMode::AlwaysOn } else { MicrophoneMode::OnDemand }; - rm.update_mode(new_mode) + tokio::task::spawn_blocking(move || rm.update_mode(new_mode)) + .await + .map_err(|e| format!("audio task join failed: {}", e))? .map_err(|e| format!("Failed to update microphone mode: {}", e)) } @@ -178,28 +188,33 @@ pub fn get_microphone_mode(app: AppHandle) -> Result { #[tauri::command] #[specta::specta] -pub fn get_available_microphones() -> Result, String> { - let devices = - list_input_devices().map_err(|e| format!("Failed to list audio devices: {}", e))?; - - let mut result = vec![AudioDevice { - index: "default".to_string(), - name: "Default".to_string(), - is_default: true, - }]; - - result.extend(devices.into_iter().map(|d| AudioDevice { - index: d.index, - name: d.name, - is_default: false, // The explicit default is handled separately - })); - - Ok(result) +pub async fn get_available_microphones() -> Result, String> { + // cpal device enumeration can stall — run it off the webview/main run loop. + tokio::task::spawn_blocking(|| { + let devices = + list_input_devices().map_err(|e| format!("Failed to list audio devices: {}", e))?; + + let mut result = vec![AudioDevice { + index: "default".to_string(), + name: "Default".to_string(), + is_default: true, + }]; + + result.extend(devices.into_iter().map(|d| AudioDevice { + index: d.index, + name: d.name, + is_default: false, // The explicit default is handled separately + })); + + Ok::<_, String>(result) + }) + .await + .map_err(|e| format!("audio task join failed: {}", e))? } #[tauri::command] #[specta::specta] -pub fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<(), String> { +pub async fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<(), String> { let mut settings = get_settings(&app); settings.selected_microphone = if device_name == "default" { None @@ -208,12 +223,14 @@ pub fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<() }; write_settings(&app, settings); - // Update the audio manager to use the new device - let rm = app.state::>(); - rm.update_selected_device() - .map_err(|e| format!("Failed to update selected device: {}", e))?; - - Ok(()) + // Update the audio manager to use the new device. update_selected_device + // can restart the cpal stream (blocking CoreAudio) — run it on a blocking + // thread, not inline on the webview/main run loop. + let rm = app.state::>().inner().clone(); + tokio::task::spawn_blocking(move || rm.update_selected_device()) + .await + .map_err(|e| format!("audio task join failed: {}", e))? + .map_err(|e| format!("Failed to update selected device: {}", e)) } #[tauri::command] @@ -227,23 +244,28 @@ pub fn get_selected_microphone(app: AppHandle) -> Result { #[tauri::command] #[specta::specta] -pub fn get_available_output_devices() -> Result, String> { - let devices = - list_output_devices().map_err(|e| format!("Failed to list output devices: {}", e))?; - - let mut result = vec![AudioDevice { - index: "default".to_string(), - name: "Default".to_string(), - is_default: true, - }]; - - result.extend(devices.into_iter().map(|d| AudioDevice { - index: d.index, - name: d.name, - is_default: false, // The explicit default is handled separately - })); - - Ok(result) +pub async fn get_available_output_devices() -> Result, String> { + // cpal device enumeration can stall — run it off the webview/main run loop. + tokio::task::spawn_blocking(|| { + let devices = + list_output_devices().map_err(|e| format!("Failed to list output devices: {}", e))?; + + let mut result = vec![AudioDevice { + index: "default".to_string(), + name: "Default".to_string(), + is_default: true, + }]; + + result.extend(devices.into_iter().map(|d| AudioDevice { + index: d.index, + name: d.name, + is_default: false, // The explicit default is handled separately + })); + + Ok::<_, String>(result) + }) + .await + .map_err(|e| format!("audio task join failed: {}", e))? } #[tauri::command] @@ -310,3 +332,49 @@ pub fn is_recording(app: AppHandle) -> bool { let audio_manager = app.state::>(); audio_manager.is_recording() } + +#[tauri::command] +#[specta::specta] +pub async fn get_microphone_channels(device_name: String) -> Result { + // cpal device enumeration and config queries can stall, so keep them off + // the webview/main run loop. + tokio::task::spawn_blocking(move || { + use cpal::traits::HostTrait; + + let device = if device_name.eq_ignore_ascii_case("default") { + crate::audio_toolkit::get_cpal_host().default_input_device() + } else { + list_input_devices() + .map_err(|e| format!("Failed to list audio devices: {e}"))? + .into_iter() + .find(|device| device.name == device_name) + .map(|device| device.device) + }; + + match device { + Some(device) => AudioRecorder::preferred_input_channel_count(&device) + .map_err(|e| format!("Failed to get microphone config: {e}")), + None => Ok(1), + } + }) + .await + .map_err(|e| format!("audio task join failed: {e}"))? +} + +#[tauri::command] +#[specta::specta] +pub async fn set_selected_channel(app: AppHandle, channel: Option) -> Result<(), String> { + // Restarting cpal can block, so keep it off the webview/main run loop. Apply + // the runtime change before persisting it so a rejected active-recording + // change does not become effective on the next launch. + let manager = app.state::>().inner().clone(); + tokio::task::spawn_blocking(move || manager.update_selected_channel(channel)) + .await + .map_err(|e| format!("audio task join failed: {e}"))? + .map_err(|e| format!("Failed to update channel selection: {e}"))?; + + let mut settings = get_settings(&app); + settings.selected_channel = channel; + write_settings(&app, settings); + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 45e4fdc001..4efc2817f8 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -179,8 +179,9 @@ pub fn initialize_shortcuts(app: AppHandle) -> Result<(), String> { // Initialize shortcuts crate::shortcut::init_shortcuts(&app); - // Mark as initialized + // Mark as initialized before reconciling the macOS Secure Input fallback. app.manage(ShortcutsInitialized); + crate::secure_input::reconcile_fallback(&app); log::info!("Shortcuts initialized successfully"); Ok(()) diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs index b8647e45e3..b1bb8949ad 100644 --- a/src-tauri/src/commands/models.rs +++ b/src-tauri/src/commands/models.rs @@ -1,6 +1,7 @@ use crate::managers::model::{ModelInfo, ModelManager}; use crate::managers::transcription::{ModelStateEvent, TranscriptionManager}; use crate::settings::{get_settings, write_settings, ModelUnloadTimeout}; +use log::error; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager, State}; @@ -48,6 +49,9 @@ pub async fn download_model( .map_err(|e| e.to_string()); if let Err(ref error) = result { + // Log as well as emit: the toast is transient, and failed downloads have + // historically been undiagnosable because logs showed nothing (#1579). + error!("Model download failed for {}: {}", model_id, error); let _ = app_handle.emit( "model-download-failed", serde_json::json!({ "model_id": &model_id, "error": error }), diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index ee33317b48..ef915035b4 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -25,7 +25,14 @@ pub fn get_cursor_position(app_handle: &AppHandle) -> Option<(i32, i32)> { /// Sends a Ctrl+V or Cmd+V paste command using platform-specific virtual key codes. /// This ensures the paste works regardless of keyboard layout (e.g., Russian, AZERTY, DVORAK). /// Note: On Wayland, this may not work - callers should check for Wayland and use alternative methods. -pub fn send_paste_ctrl_v(enigo: &mut Enigo) -> Result<(), String> { +/// +/// `hold_ms` is how long the modifier stays held after the V click before being +/// released. Most applications read the modifier from the V event's flags and +/// need no hold at all, but applications that poll global keyboard state when +/// handling the key need the modifier to still be down — the hold insures +/// against those. Callers that can detect a failed chord (e.g. the +/// receipt-sequenced paste path) may use a much shorter hold. +pub fn send_paste_ctrl_v(enigo: &mut Enigo, hold_ms: u64) -> Result<(), String> { // Platform-specific key definitions #[cfg(target_os = "macos")] let (modifier_key, v_key_code) = (Key::Meta, Key::Other(9)); @@ -42,7 +49,7 @@ pub fn send_paste_ctrl_v(enigo: &mut Enigo) -> Result<(), String> { .key(v_key_code, enigo::Direction::Click) .map_err(|e| format!("Failed to click V key: {}", e))?; - std::thread::sleep(std::time::Duration::from_millis(100)); + std::thread::sleep(std::time::Duration::from_millis(hold_ms)); enigo .key(modifier_key, enigo::Direction::Release) @@ -54,7 +61,7 @@ pub fn send_paste_ctrl_v(enigo: &mut Enigo) -> Result<(), String> { /// Sends a Ctrl+Shift+V paste command. /// This is commonly used in terminal applications on Linux to paste without formatting. /// Note: On Wayland, this may not work - callers should check for Wayland and use alternative methods. -pub fn send_paste_ctrl_shift_v(enigo: &mut Enigo) -> Result<(), String> { +pub fn send_paste_ctrl_shift_v(enigo: &mut Enigo, hold_ms: u64) -> Result<(), String> { // Platform-specific key definitions #[cfg(target_os = "macos")] let (modifier_key, v_key_code) = (Key::Meta, Key::Other(9)); // Cmd+Shift+V on macOS @@ -74,7 +81,7 @@ pub fn send_paste_ctrl_shift_v(enigo: &mut Enigo) -> Result<(), String> { .key(v_key_code, enigo::Direction::Click) .map_err(|e| format!("Failed to click V key: {}", e))?; - std::thread::sleep(std::time::Duration::from_millis(100)); + std::thread::sleep(std::time::Duration::from_millis(hold_ms)); enigo .key(Key::Shift, enigo::Direction::Release) @@ -89,7 +96,7 @@ pub fn send_paste_ctrl_shift_v(enigo: &mut Enigo) -> Result<(), String> { /// Sends a Shift+Insert paste command (Windows and Linux only). /// This is more universal for terminal applications and legacy software. /// Note: On Wayland, this may not work - callers should check for Wayland and use alternative methods. -pub fn send_paste_shift_insert(enigo: &mut Enigo) -> Result<(), String> { +pub fn send_paste_shift_insert(enigo: &mut Enigo, hold_ms: u64) -> Result<(), String> { #[cfg(target_os = "windows")] let insert_key_code = Key::Other(0x2D); // VK_INSERT #[cfg(not(target_os = "windows"))] @@ -103,7 +110,7 @@ pub fn send_paste_shift_insert(enigo: &mut Enigo) -> Result<(), String> { .key(insert_key_code, enigo::Direction::Click) .map_err(|e| format!("Failed to click Insert key: {}", e))?; - std::thread::sleep(std::time::Duration::from_millis(100)); + std::thread::sleep(std::time::Duration::from_millis(hold_ms)); enigo .key(Key::Shift, enigo::Direction::Release) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 830fee75ee..5960ec1cc8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod actions; mod apple_intelligence; mod audio_feedback; pub mod audio_toolkit; +mod autostart; mod catalog; pub mod cli; mod clipboard; @@ -11,8 +12,11 @@ mod helpers; mod input; mod llm_client; mod managers; +mod memory; mod overlay; +mod paste_tx; pub mod portable; +mod secure_input; mod settings; mod shortcut; mod signal_handle; @@ -31,10 +35,6 @@ use managers::audio::AudioRecordingManager; use managers::history::HistoryManager; use managers::model::ModelManager; use managers::transcription::TranscriptionManager; -#[cfg(unix)] -use signal_hook::consts::{SIGUSR1, SIGUSR2}; -#[cfg(unix)] -use signal_hook::iterator::Signals; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::Arc; use tauri::image::Image; @@ -42,7 +42,7 @@ pub use transcription_coordinator::TranscriptionCoordinator; use tauri::tray::TrayIconBuilder; use tauri::{AppHandle, Emitter, Listener, Manager}; -use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; +use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_log::{Builder as LogBuilder, RotationStrategy, Target, TargetKind}; use crate::settings::get_settings; @@ -187,11 +187,11 @@ fn initialize_core_logic(app_handle: &AppHandle) { // after permissions are confirmed (on macOS) or after onboarding completes. // This matches the pattern used for Enigo initialization. + // Set up signal handlers for toggling transcription. On Linux, SIGUSR1 is + // deliberately not handled — it belongs to WebKitGTK's garbage collector + // (#1660) — see signal_handle.rs. #[cfg(unix)] - let signals = Signals::new([SIGUSR1, SIGUSR2]).unwrap(); - // Set up signal handlers for toggling transcription - #[cfg(unix)] - signal_handle::setup_signal_handler(app_handle.clone(), signals); + signal_handle::setup_signal_handler(app_handle.clone()); // Apply macOS Accessory policy if starting hidden and tray is available. // If the tray icon is disabled, keep the dock icon so the user can reopen. @@ -206,9 +206,9 @@ fn initialize_core_logic(app_handle: &AppHandle) { let initial_theme = tray::get_current_theme(app_handle); // Choose the appropriate initial icon based on theme - let initial_icon_path = tray::get_icon_path(initial_theme, tray::TrayIconState::Idle); + let initial_icon_path = tray::get_icon_path(initial_theme, tray::TrayIconState::Idle, false); - let tray = TrayIconBuilder::new() + let mut tray_builder = TrayIconBuilder::new() .icon( Image::from_path( app_handle @@ -219,12 +219,46 @@ fn initialize_core_logic(app_handle: &AppHandle) { .unwrap(), ) .tooltip(tray::tray_tooltip()) - .show_menu_on_left_click(true) - .icon_as_template(true) + .icon_as_template(true); + + // Windows notification-area convention: left click opens the app, right click + // shows the menu. Elsewhere (macOS menu bar, Linux) the menu stays on left click. + #[cfg(target_os = "windows")] + { + tray_builder = tray_builder + .show_menu_on_left_click(false) + .on_tray_icon_event(|tray, event| { + use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent}; + let opens_window = matches!( + event, + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } | TrayIconEvent::DoubleClick { + button: MouseButton::Left, + .. + } + ); + if opens_window { + show_main_window(tray.app_handle()); + } + }); + } + #[cfg(not(target_os = "windows"))] + { + tray_builder = tray_builder.show_menu_on_left_click(true); + } + + let tray = tray_builder .on_menu_event(|app, event| match event.id.as_ref() { "settings" => { show_main_window(app); } + "secure_input_warning" => { + // Full explanation lives in the settings-window banner + show_main_window(app); + } "check_updates" => { let settings = settings::get_settings(app); if settings.update_checks_enabled { @@ -295,17 +329,9 @@ fn initialize_core_logic(app_handle: &AppHandle) { tray::update_tray_menu(&app_handle_for_listener, None); }); - // Get the autostart manager and configure based on user setting - let autostart_manager = app_handle.autolaunch(); - let settings = settings::get_settings(app_handle); - - if settings.autostart_enabled { - // Enable autostart if user has opted in - let _ = autostart_manager.enable(); - } else { - // Disable autostart if user has opted out - let _ = autostart_manager.disable(); - } + // Apply the autostart preference (SMAppService login item on macOS 13+, + // tauri-plugin-autostart elsewhere) + autostart::apply_autostart(app_handle, settings.autostart_enabled); // Create the recording overlay window (hidden by default) utils::create_recording_overlay(app_handle); @@ -562,6 +588,11 @@ fn run_headless_transcription(app: &AppHandle, args: &CliArgs) -> i32 { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run(cli_args: CliArgs) { + // Pin glibc's dynamic mmap threshold before the first large allocation, + // so per-dictation transient buffers are returned to the OS on free + // instead of accumulating in malloc arenas (#1792). No-op off Linux/glibc. + memory::init_allocator(); + // Detect portable mode before anything else portable::init(); @@ -589,6 +620,7 @@ pub fn run(cli_args: CliArgs) { shortcut::change_extra_recording_buffer_setting, shortcut::change_paste_delay_ms_setting, shortcut::change_paste_delay_after_ms_setting, + shortcut::change_reliable_paste_setting, shortcut::change_paste_method_setting, shortcut::get_available_typing_tools, shortcut::change_typing_tool_setting, @@ -608,8 +640,8 @@ pub fn run(cli_args: CliArgs) { shortcut::delete_post_process_prompt, shortcut::set_post_process_selected_prompt, shortcut::update_custom_words, - shortcut::suspend_binding, - shortcut::resume_binding, + shortcut::suspend_all_bindings, + shortcut::resume_all_bindings, shortcut::change_mute_while_recording_setting, shortcut::change_duck_volume_percent_setting, shortcut::change_duck_fade_ms_setting, @@ -629,6 +661,8 @@ pub fn run(cli_args: CliArgs) { shortcut::get_available_accelerators, shortcut::handy_keys::start_handy_keys_recording, shortcut::handy_keys::stop_handy_keys_recording, + secure_input::get_secure_input_status, + secure_input::run_keyboard_diagnostic, trigger_update_check, show_main_window_command, commands::cancel_operation, @@ -669,6 +703,8 @@ pub fn run(cli_args: CliArgs) { commands::audio::set_clamshell_microphone, commands::audio::get_clamshell_microphone, commands::audio::is_recording, + commands::audio::get_microphone_channels, + commands::audio::set_selected_channel, commands::transcription::set_model_unload_timeout, commands::transcription::get_model_load_status, commands::transcription::unload_model_manually, @@ -846,7 +882,7 @@ pub fn run(cli_args: CliArgs) { .inner_size(680.0, 570.0) .min_inner_size(680.0, 570.0) .resizable(true) - .maximizable(false) + .maximizable(true) .visible(false); if let Some(data_dir) = portable::data_dir() { @@ -883,6 +919,11 @@ pub fn run(cli_args: CliArgs) { initialize_core_logic(&app_handle); + // Secure Input monitor (macOS): detects stuck secure input that + // silently blocks keyed shortcuts, warns the user, and activates + // the Carbon fallback. See secure_input.rs and issue #1578. + secure_input::init(&app_handle); + // Populate the overlay-enabled cache from initial settings so the // audio path (overlay::emit_levels, called ~24 Hz during recording) // can do a single atomic load instead of reading the Tauri store. diff --git a/src-tauri/src/llm_client.rs b/src-tauri/src/llm_client.rs index 2a06e1736e..f5695b517a 100644 --- a/src-tauri/src/llm_client.rs +++ b/src-tauri/src/llm_client.rs @@ -1,8 +1,11 @@ use crate::settings::PostProcessProvider; -use log::debug; +use log::{debug, error, info}; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, REFERER, USER_AGENT}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashSet; +use std::error::Error as StdError; +use std::sync::{Mutex, OnceLock}; #[derive(Debug, Serialize)] struct ChatMessage { @@ -24,24 +27,97 @@ struct ResponseFormat { json_schema: JsonSchema, } -#[derive(Debug, Serialize, Clone, Default)] -pub struct ReasoningConfig { +#[derive(Debug, Serialize, Clone, Default, PartialEq)] +struct ReasoningConfig { #[serde(skip_serializing_if = "Option::is_none")] - pub effort: Option, + effort: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub exclude: Option, + exclude: Option, +} + +/// Request fields used to ask an endpoint to skip reasoning/thinking. +/// Providers disagree on the field name and accepted values, so at most one of +/// these is set per request (see `reasoning_disable_params`). +#[derive(Debug, Serialize, Clone, Default, PartialEq)] +struct ReasoningParams { + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + thinking: Option, +} + +impl ReasoningParams { + fn is_empty(&self) -> bool { + self.reasoning_effort.is_none() && self.reasoning.is_none() && self.thinking.is_none() + } +} + +/// Pick the reasoning-disable request fields an endpoint understands. +/// Unknown endpoints get the common OpenAI-style field; if they reject it, +/// the request is retried without it (see `send_chat_completion_with_schema`). +fn reasoning_disable_params(provider: &PostProcessProvider) -> ReasoningParams { + let base_url = provider.base_url.to_lowercase(); + if base_url.contains("api.deepseek.com") { + // DeepSeek rejects reasoning_effort "none" and uses its own field: + // https://api-docs.deepseek.com/guides/thinking_mode + ReasoningParams { + thinking: Some(serde_json::json!({ "type": "disabled" })), + ..Default::default() + } + } else if provider.id == "openrouter" { + // OpenRouter nested object; exclude:true also keeps reasoning text out + // of the response so it can't pollute structured-output JSON parsing + ReasoningParams { + reasoning: Some(ReasoningConfig { + effort: Some("none".to_string()), + exclude: Some(true), + }), + ..Default::default() + } + } else { + ReasoningParams { + reasoning_effort: Some("none".to_string()), + ..Default::default() + } + } +} + +/// Endpoints (base_url|model) that rejected the reasoning-disable fields with a +/// 4xx. Remembered for the lifetime of the process so every dictation after the +/// first skips the doomed attempt and goes straight to a plain request. +fn reasoning_rejections() -> &'static Mutex> { + static REJECTED: OnceLock>> = OnceLock::new(); + REJECTED.get_or_init(|| Mutex::new(HashSet::new())) +} + +fn endpoint_key(provider: &PostProcessProvider, model: &str) -> String { + format!("{}|{}", provider.base_url.trim_end_matches('/'), model) +} + +fn is_known_rejected(key: &str) -> bool { + reasoning_rejections() + .lock() + .map(|set| set.contains(key)) + .unwrap_or(false) +} + +fn remember_rejection(key: String) { + if let Ok(mut set) = reasoning_rejections().lock() { + set.insert(key); + } } #[derive(Debug, Serialize)] struct ChatCompletionRequest { model: String, messages: Vec, + stream: bool, #[serde(skip_serializing_if = "Option::is_none")] response_format: Option, - #[serde(skip_serializing_if = "Option::is_none")] - reasoning_effort: Option, - #[serde(skip_serializing_if = "Option::is_none")] - reasoning: Option, + #[serde(flatten)] + reasoning: ReasoningParams, } #[derive(Debug, Deserialize)] @@ -102,7 +178,120 @@ fn create_client(provider: &PostProcessProvider, api_key: &str) -> Result Vec { + let mut causes = Vec::new(); + let mut source = error.source(); + + // Defensive cap in case a third-party error exposes a cyclic source chain. + for _ in 0..16 { + let Some(cause) = source else { + break; + }; + causes.push(cause.to_string()); + source = cause.source(); + } + + causes +} + +fn reqwest_error_kinds(error: &reqwest::Error) -> String { + let mut kinds = Vec::new(); + + if error.is_builder() { + kinds.push("builder"); + } + if error.is_connect() { + kinds.push("connect"); + } + if error.is_request() { + kinds.push("request"); + } + if error.is_redirect() { + kinds.push("redirect"); + } + if error.is_timeout() { + kinds.push("timeout"); + } + if error.is_status() { + kinds.push("status"); + } + if error.is_body() { + kinds.push("body"); + } + if error.is_decode() { + kinds.push("decode"); + } + if error.is_upgrade() { + kinds.push("upgrade"); + } + + if kinds.is_empty() { + "unknown".to_string() + } else { + kinds.join(", ") + } +} + +fn sanitized_url(url: &reqwest::Url) -> String { + let mut url = url.clone(); + + // Custom endpoints should not contain credentials or query-string tokens, + // but omit them from diagnostics in case one does. + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + + url.to_string() +} + +fn sanitized_url_for_log(url: &str) -> String { + reqwest::Url::parse(url) + .map(|url| sanitized_url(&url)) + // Do not echo an invalid URL: the parse failure might have been caused + // by sensitive data entered in the custom endpoint field. + .unwrap_or_else(|_| "".to_string()) +} + +fn report_reqwest_error(context: &str, error: &reqwest::Error) -> String { + let kinds = reqwest_error_kinds(error); + let url = error + .url() + .map(sanitized_url) + .map(|url| format!(", url: {url}")) + .unwrap_or_default(); + + // serde_json's error text can quote values from a malformed response. That + // response may contain transcription content, so retain the useful decode + // classification but never put its nested source in logs or UI errors. + let causes = if error.is_decode() { + Vec::new() + } else { + error_source_chain(error) + }; + let cause_details = if !causes.is_empty() { + format!(": caused by: {}", causes.join(" -> ")) + } else if error.url().is_none() { + // Reqwest's short Display text is safe when it cannot append a raw URL. + format!(": {error}") + } else { + // The sanitized URL is already included above. Avoid formatting the + // original error because its Display implementation includes the raw URL. + String::new() + }; + + let details = format!("{context} (kind: {kinds}{url}){cause_details}"); + error!("{details}"); + details } /// Send a chat completion request to an OpenAI-compatible API @@ -113,8 +302,7 @@ pub async fn send_chat_completion( api_key: String, model: &str, prompt: String, - reasoning_effort: Option, - reasoning: Option, + disable_reasoning: bool, ) -> Result, String> { send_chat_completion_with_schema( provider, @@ -123,18 +311,21 @@ pub async fn send_chat_completion( prompt, None, None, - reasoning_effort, - reasoning, + disable_reasoning, ) .await } -/// Send a chat completion request with structured output support -/// When json_schema is provided, uses structured outputs mode -/// system_prompt is used as the system message when provided -/// reasoning_effort sets the OpenAI-style top-level field (e.g., "none", "low", "medium", "high") -/// reasoning sets the OpenRouter-style nested object (effort + exclude) -#[allow(clippy::too_many_arguments)] +/// Send a chat completion request with structured output support. +/// When json_schema is provided, uses structured outputs mode. +/// system_prompt is used as the system message when provided. +/// +/// When disable_reasoning is set, the request carries the reasoning-disable +/// fields the endpoint is expected to understand. Not every OpenAI-compatible +/// endpoint accepts them (DeepSeek, Gemini's compat layer, and some OpenRouter +/// upstreams reject with 400), so a 400/422 answer to such a request triggers +/// one retry without the fields, and the rejection is remembered per +/// (base_url, model) so later requests skip the failing attempt entirely. pub async fn send_chat_completion_with_schema( provider: &PostProcessProvider, api_key: String, @@ -142,13 +333,15 @@ pub async fn send_chat_completion_with_schema( user_content: String, system_prompt: Option, json_schema: Option, - reasoning_effort: Option, - reasoning: Option, + disable_reasoning: bool, ) -> Result, String> { let base_url = provider.base_url.trim_end_matches('/'); let url = format!("{}/chat/completions", base_url); - debug!("Sending chat completion request to: {}", url); + debug!( + "Sending chat completion request to: {}", + sanitized_url_for_log(&url) + ); let client = create_client(provider, &api_key)?; @@ -179,27 +372,78 @@ pub async fn send_chat_completion_with_schema( }, }); - let request_body = ChatCompletionRequest { + let key = endpoint_key(provider, model); + let reasoning = if disable_reasoning && !is_known_rejected(&key) { + reasoning_disable_params(provider) + } else { + ReasoningParams::default() + }; + + let mut request_body = ChatCompletionRequest { model: model.to_string(), messages, + stream: false, response_format, - reasoning_effort, reasoning, }; - let response = client + let mut response = client .post(&url) .json(&request_body) .send() .await - .map_err(|e| format!("HTTP request failed: {}", e))?; + .map_err(|e| report_reqwest_error("HTTP request failed", &e))?; + let mut status = response.status(); + debug!( + "Chat completion response received with status {} over {:?} from {}", + status, + response.version(), + sanitized_url(response.url()) + ); + + // A 400/422 on a request carrying reasoning-disable fields is almost always + // the endpoint rejecting those fields — retry once without them. + if !status.is_success() + && matches!(status.as_u16(), 400 | 422) + && !request_body.reasoning.is_empty() + { + let error_text = response.text().await.unwrap_or_else(|e| { + report_reqwest_error("Failed to read reasoning rejection response", &e) + }); + info!( + "Endpoint rejected request with reasoning disabled (status {}): {}. Retrying without reasoning fields", + status, error_text + ); + + request_body.reasoning = ReasoningParams::default(); + response = client + .post(&url) + .json(&request_body) + .send() + .await + .map_err(|e| report_reqwest_error("HTTP retry failed", &e))?; + status = response.status(); + debug!( + "Chat completion retry response received with status {} over {:?} from {}", + status, + response.version(), + sanitized_url(response.url()) + ); + + if status.is_success() { + info!( + "Retry without reasoning fields succeeded; '{}' (model '{}') will skip them from now on", + sanitized_url_for_log(base_url), model + ); + remember_rejection(key); + } + } - let status = response.status(); if !status.is_success() { let error_text = response .text() .await - .unwrap_or_else(|_| "Failed to read error response".to_string()); + .unwrap_or_else(|e| report_reqwest_error("Failed to read API error response", &e)); return Err(format!( "API request failed with status {}: {}", status, error_text @@ -209,7 +453,7 @@ pub async fn send_chat_completion_with_schema( let completion: ChatCompletionResponse = response .json() .await - .map_err(|e| format!("Failed to parse API response: {}", e))?; + .map_err(|e| report_reqwest_error("Failed to parse API response", &e))?; Ok(completion .choices @@ -226,7 +470,7 @@ pub async fn fetch_models( let base_url = provider.base_url.trim_end_matches('/'); let url = format!("{}/models", base_url); - debug!("Fetching models from: {}", url); + debug!("Fetching models from: {}", sanitized_url_for_log(&url)); let client = create_client(provider, &api_key)?; @@ -234,14 +478,20 @@ pub async fn fetch_models( .get(&url) .send() .await - .map_err(|e| format!("Failed to fetch models: {}", e))?; + .map_err(|e| report_reqwest_error("Failed to fetch models", &e))?; let status = response.status(); + debug!( + "Model list response received with status {} over {:?} from {}", + status, + response.version(), + sanitized_url(response.url()) + ); if !status.is_success() { let error_text = response .text() .await - .unwrap_or_else(|_| "Unknown error".to_string()); + .unwrap_or_else(|e| report_reqwest_error("Failed to read model list error", &e)); return Err(format!( "Model list request failed ({}): {}", status, error_text @@ -251,7 +501,7 @@ pub async fn fetch_models( let parsed: serde_json::Value = response .json() .await - .map_err(|e| format!("Failed to parse response: {}", e))?; + .map_err(|e| report_reqwest_error("Failed to parse model list response", &e))?; let mut models = Vec::new(); @@ -276,3 +526,209 @@ pub async fn fetch_models( Ok(models) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fmt; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[derive(Debug)] + struct TestError { + message: &'static str, + source: Option>, + } + + impl fmt::Display for TestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.message) + } + } + + impl StdError for TestError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.source + .as_deref() + .map(|source| source as &(dyn StdError + 'static)) + } + } + + fn provider(id: &str, base_url: &str) -> PostProcessProvider { + PostProcessProvider { + id: id.to_string(), + label: id.to_string(), + base_url: base_url.to_string(), + allow_base_url_edit: true, + models_endpoint: None, + supports_structured_output: false, + } + } + + fn request_json(reasoning: ReasoningParams) -> Value { + let request = ChatCompletionRequest { + model: "test-model".to_string(), + messages: vec![ChatMessage { + role: "user".to_string(), + content: "hi".to_string(), + }], + stream: false, + response_format: None, + reasoning, + }; + serde_json::to_value(&request).unwrap() + } + + async fn serve_one_response(status: &str, body: &str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request).await.unwrap(); + stream.write_all(response.as_bytes()).await.unwrap(); + }); + + format!("http://{address}") + } + + #[test] + fn error_source_chain_includes_all_nested_causes() { + let error = TestError { + message: "request failed", + source: Some(Box::new(TestError { + message: "TLS handshake failed", + source: Some(Box::new(TestError { + message: "unknown certificate authority", + source: None, + })), + })), + }; + + assert_eq!( + error_source_chain(&error), + vec!["TLS handshake failed", "unknown certificate authority"] + ); + } + + #[test] + fn log_url_sanitization_removes_credentials_and_tokens() { + let url = "https://user:password@example.com/v1/models?api_key=secret#private"; + assert_eq!(sanitized_url_for_log(url), "https://example.com/v1/models"); + } + + #[test] + fn invalid_log_urls_are_not_echoed() { + assert_eq!( + sanitized_url_for_log("not a URL containing secret"), + "" + ); + } + + #[tokio::test] + async fn decode_error_does_not_echo_response_values() { + let base_url = + serve_one_response("200 OK", r#"{"choices":"PRIVATE TRANSCRIPTION CONTENT"}"#).await; + let error = reqwest::get(base_url) + .await + .unwrap() + .json::() + .await + .unwrap_err(); + + let details = report_reqwest_error("Failed to parse API response", &error); + assert!(details.contains("kind: decode")); + assert!(!details.contains("PRIVATE TRANSCRIPTION CONTENT")); + } + + #[tokio::test] + async fn raw_error_url_is_not_reintroduced_without_a_source() { + let base_url = serve_one_response("400 Bad Request", "bad request").await; + let error = reqwest::get(format!( + "{base_url}/private?api_key=SECRET_QUERY_TOKEN#private" + )) + .await + .unwrap() + .error_for_status() + .unwrap_err(); + + let details = report_reqwest_error("Request failed", &error); + assert!(details.contains(&format!("url: {base_url}/private"))); + assert!(!details.contains("SECRET_QUERY_TOKEN")); + assert!(!details.contains("#private")); + } + + #[test] + fn requests_explicitly_disable_streaming() { + let json = request_json(ReasoningParams::default()); + assert_eq!(json["stream"], false); + } + + #[test] + fn default_reasoning_params_serialize_to_no_fields() { + let json = request_json(ReasoningParams::default()); + assert!(json.get("reasoning_effort").is_none()); + assert!(json.get("reasoning").is_none()); + assert!(json.get("thinking").is_none()); + } + + #[test] + fn custom_provider_uses_top_level_reasoning_effort() { + let params = reasoning_disable_params(&provider("custom", "http://localhost:11434/v1")); + let json = request_json(params); + assert_eq!(json["reasoning_effort"], "none"); + assert!(json.get("reasoning").is_none()); + assert!(json.get("thinking").is_none()); + } + + #[test] + fn openrouter_uses_nested_reasoning_object() { + let params = + reasoning_disable_params(&provider("openrouter", "https://openrouter.ai/api/v1")); + let json = request_json(params); + assert!(json.get("reasoning_effort").is_none()); + assert_eq!(json["reasoning"]["effort"], "none"); + assert_eq!(json["reasoning"]["exclude"], true); + assert!(json.get("thinking").is_none()); + } + + #[test] + fn deepseek_base_url_uses_thinking_disabled() { + let params = reasoning_disable_params(&provider("custom", "https://api.deepseek.com")); + let json = request_json(params); + assert!(json.get("reasoning_effort").is_none()); + assert!(json.get("reasoning").is_none()); + assert_eq!(json["thinking"]["type"], "disabled"); + } + + #[test] + fn reasoning_params_is_empty_tracks_all_fields() { + assert!(ReasoningParams::default().is_empty()); + assert!(!ReasoningParams { + reasoning_effort: Some("none".to_string()), + ..Default::default() + } + .is_empty()); + assert!(!ReasoningParams { + thinking: Some(serde_json::json!({ "type": "disabled" })), + ..Default::default() + } + .is_empty()); + } + + #[test] + fn rejection_memo_is_keyed_by_base_url_and_model() { + let deepseek = provider("custom", "https://api.deepseek.com/"); + let key = endpoint_key(&deepseek, "deepseek-chat"); + assert_eq!(key, "https://api.deepseek.com|deepseek-chat"); + assert!(!is_known_rejected(&key)); + remember_rejection(key.clone()); + assert!(is_known_rejected(&key)); + // A different model on the same endpoint is tracked separately + assert!(!is_known_rejected(&endpoint_key(&deepseek, "other-model"))); + } +} diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index 895f68faaf..94114f6652 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -10,9 +10,9 @@ use crate::helpers::clamshell; use crate::managers::transcription::StreamRouter; use crate::settings::{get_settings, AppSettings}; use crate::utils; -use log::{debug, error, info, warn}; +use log::{debug, error, info, trace, warn}; use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tauri::Manager; @@ -109,6 +109,7 @@ fn set_mute(mute: bool) { } } +<<<<<<< HEAD #[cfg(target_os = "macos")] mod macos_coreaudio { use coreaudio_sys::{ @@ -187,10 +188,75 @@ mod macos_coreaudio { Some(vol) } else { None +======= +/// Reads the current system output mute state, mirroring `set_mute`'s backends. +/// +/// Returns `Some(true)`/`Some(false)` when the state could be determined, or +/// `None` when it couldn't (unsupported platform, missing CLI tools, or an +/// error). Callers treat `None` as "unknown" and fall back to unmuting on stop, +/// so we never strand the user's audio muted. +#[cfg(target_os = "windows")] +fn get_mute() -> Option { + unsafe { + use windows::Win32::{ + Media::Audio::{ + eMultimedia, eRender, Endpoints::IAudioEndpointVolume, IMMDeviceEnumerator, + MMDeviceEnumerator, + }, + System::Com::{CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED}, + }; + + // Matches set_mute: no-op if COM is already initialized on this thread. + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + + let all_devices: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL).ok()?; + let default_device = all_devices + .GetDefaultAudioEndpoint(eRender, eMultimedia) + .ok()?; + let volume_interface = default_device + .Activate::(CLSCTX_ALL, None) + .ok()?; + + Some(volume_interface.GetMute().ok()?.as_bool()) + } +} + +#[cfg(target_os = "linux")] +fn get_mute() -> Option { + use std::process::Command; + + // 1. PipeWire (wpctl): prints "[MUTED]" in the volume line when muted. + if let Ok(out) = Command::new("wpctl") + .args(["get-volume", "@DEFAULT_AUDIO_SINK@"]) + .output() + { + if out.status.success() { + return Some(String::from_utf8_lossy(&out.stdout).contains("[MUTED]")); + } + } + + // 2. PulseAudio (pactl): prints "Mute: yes" / "Mute: no". + // Force LC_ALL=C so a localized system still emits the parseable English + // "yes"/"no" instead of e.g. "ja"/"nein". + if let Ok(out) = Command::new("pactl") + .env("LC_ALL", "C") + .args(["get-sink-mute", "@DEFAULT_SINK@"]) + .output() + { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).to_lowercase(); + if s.contains("yes") { + return Some(true); + } + if s.contains("no") { + return Some(false); +>>>>>>> tags/v0.9.5 } } } +<<<<<<< HEAD pub fn set_volume(device: AudioDeviceID, vol: f32) { let v = vol.clamp(0.0, 1.0); // master 要素 + channel 1, 2 全部試す (デバイス差異吸収) @@ -256,6 +322,60 @@ fn macos_restore_with_fade(target_pct: u8, fade_ms: u16, saved: u8) { if i < STEPS { std::thread::sleep(step_delay); } +======= + // 3. ALSA (amixer): prints "[off]" for muted channels, "[on]" otherwise. + // LC_ALL=C keeps the "[on]"/"[off]" tokens stable across locales. + if let Ok(out) = Command::new("amixer") + .env("LC_ALL", "C") + .args(["get", "Master"]) + .output() + { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout); + if s.contains("[off]") { + return Some(true); + } + if s.contains("[on]") { + return Some(false); + } + } + } + + None +} + +#[cfg(target_os = "macos")] +fn get_mute() -> Option { + use std::process::Command; + + let out = Command::new("osascript") + .args(["-e", "output muted of (get volume settings)"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + match String::from_utf8_lossy(&out.stdout).trim() { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + +#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] +fn get_mute() -> Option { + None +} + +/// Restores the system mute state after our forced mute, given the state +/// captured just before we muted. We only ever need to unmute — and only when +/// the system was NOT already muted beforehand. If the prior state was muted, +/// we leave it muted (the user's own state). If it's unknown (`None`), we +/// default to unmuting so audio is never left stranded muted by us. +fn restore_mute(prev_muted: Option) { + if prev_muted != Some(true) { + set_mute(false); +>>>>>>> tags/v0.9.5 } } @@ -276,11 +396,22 @@ pub enum MicrophoneMode { OnDemand, } +/// Tracks our forced "mute while recording" so we can restore the user's audio +/// exactly as it was. `did_mute` is true while our mute is active; `prev_muted` +/// is the system mute state captured just before we muted, used to decide +/// whether to unmute on stop (so a system that was already muted stays muted). +#[derive(Debug, Default, Clone, Copy)] +struct MuteState { + did_mute: bool, + prev_muted: Option, +} + /* ──────────────────────────────────────────────────────────────── */ fn create_audio_recorder( vad_path: &Path, app_handle: &tauri::AppHandle, + selected_channel: Option, stream_router: Arc, ) -> Result { // A single Silero engine covers both the offline and streaming policies (never @@ -305,6 +436,7 @@ fn create_audio_recorder( VAD_OFFLINE_HANGOVER_FRAMES, VAD_STREAMING_HANGOVER_FRAMES, ) + .with_selected_channel(selected_channel) .with_level_callback({ let app_handle = app_handle.clone(); move |levels| { @@ -325,6 +457,8 @@ fn create_audio_recorder( #[derive(Clone)] pub struct AudioRecordingManager { + /// Never assign through this directly — route every write through + /// `set_state()`, which keeps `recording_active` in sync. state: Arc>, mode: Arc>, app_handle: tauri::AppHandle, @@ -332,11 +466,21 @@ pub struct AudioRecordingManager { recorder: Arc>>, is_open: Arc>, is_recording: Arc>, +<<<<<<< HEAD did_mute: Arc>, saved_volume: Arc>>, +======= + mute_state: Arc>, +>>>>>>> tags/v0.9.5 close_generation: Arc, cancel_generation: Arc, stream_router: Arc, + /// Lock-free mirror of "is the state in {Recording, Stopping}", + /// maintained by `set_state()`. The hot-path `is_recording()` reads THIS + /// instead of the std `state` mutex, so a UI poll can no longer deadlock + /// the main/webview thread when a worker holds `state` across a slow + /// CoreAudio open/close. + recording_active: Arc, /// Resolution of a *named* microphone (selected or clamshell) to its cpal /// device, cached so on-demand recording starts skip the full device /// enumeration (~40-110ms). Keyed by the resolved name, so a settings @@ -368,11 +512,16 @@ impl AudioRecordingManager { recorder: Arc::new(Mutex::new(None)), is_open: Arc::new(Mutex::new(false)), is_recording: Arc::new(Mutex::new(false)), +<<<<<<< HEAD did_mute: Arc::new(Mutex::new(false)), saved_volume: Arc::new(Mutex::new(None)), +======= + mute_state: Arc::new(Mutex::new(MuteState::default())), +>>>>>>> tags/v0.9.5 close_generation: Arc::new(AtomicU64::new(0)), cancel_generation: Arc::new(AtomicU64::new(0)), stream_router, + recording_active: Arc::new(AtomicBool::new(false)), cached_device: Arc::new(Mutex::new(None)), }; @@ -477,13 +626,37 @@ impl AudioRecordingManager { /* ---------- microphone life-cycle -------------------------------------- */ /// Applies mute if mute_while_recording is enabled and stream is open. +<<<<<<< HEAD /// macOS: duck_volume_percent > 0 のときはフェード付き ducking (低音量で鳴り続ける) +======= + /// Snapshots the system's prior mute state first so `remove_mute` can + /// restore it instead of unconditionally unmuting. +>>>>>>> tags/v0.9.5 pub fn apply_mute(&self) { let settings = get_settings(&self.app_handle); - let mut did_mute_guard = self.did_mute.lock().unwrap(); + if !settings.mute_while_recording { + return; + } +<<<<<<< HEAD if !settings.mute_while_recording || !*self.is_open.lock().unwrap() { return; +======= + // Lock order: is_open before mute_state (matches stop_microphone_stream). + let is_open = self.is_open.lock().unwrap(); + let mut mute_guard = self.mute_state.lock().unwrap(); + // Already muted this session — don't re-snapshot, or a duplicate/late + // apply would overwrite prev_muted with our own forced-muted state and + // strand audio muted on stop. + if mute_guard.did_mute { + return; + } + if *is_open { + mute_guard.prev_muted = get_mute(); + set_mute(true); + mute_guard.did_mute = true; + debug!("Mute applied (prev_muted={:?})", mute_guard.prev_muted); +>>>>>>> tags/v0.9.5 } #[cfg(target_os = "macos")] @@ -507,11 +680,23 @@ impl AudioRecordingManager { debug!("Mute applied"); } - /// Removes mute if it was applied + /// Removes mute if it was applied, restoring the system's prior mute state + /// (a system already muted before recording stays muted). pub fn remove_mute(&self) { +<<<<<<< HEAD let mut did_mute_guard = self.did_mute.lock().unwrap(); if !*did_mute_guard { return; +======= + let mut mute_guard = self.mute_state.lock().unwrap(); + if mute_guard.did_mute { + restore_mute(mute_guard.prev_muted); + mute_guard.did_mute = false; + debug!( + "Mute removed (restored prev_muted={:?})", + mute_guard.prev_muted + ); +>>>>>>> tags/v0.9.5 } #[cfg(target_os = "macos")] @@ -546,9 +731,11 @@ impl AudioRecordingManager { tauri::path::BaseDirectory::Resource, ) .map_err(|e| anyhow::anyhow!("Failed to resolve VAD path: {}", e))?; + let settings = get_settings(&self.app_handle); *recorder_opt = Some(create_audio_recorder( &vad_path, &self.app_handle, + settings.selected_channel, Arc::clone(&self.stream_router), )?); } @@ -558,15 +745,61 @@ impl AudioRecordingManager { pub fn start_microphone_stream(&self) -> Result<(), anyhow::Error> { let mut open_flag = self.is_open.lock().unwrap(); if *open_flag { - debug!("Microphone stream already active"); - return Ok(()); + // `is_open` only records that we opened a stream at some point, not + // that one is still running. If the capture worker has since exited + // (mic unplugged mid-session, USB dropout), returning Ok here hands + // the caller a dead recorder: it captures nothing, then fails in + // stop() on the closed channel, and stays wedged until the + // on-demand close timeout eventually resets the manager. + let worker_dead = self + .recorder + .lock() + .unwrap() + .as_ref() + .is_some_and(|rec| rec.is_capture_worker_dead()); + + if !worker_dead { + // trace, not debug: with the aliveness check in + // try_start_recording this now fires on every keypress in + // always-on mode. + trace!("Microphone stream already active"); + return Ok(()); + } + + warn!("Microphone stream is no longer running (device disconnected?); reopening"); + + // Torn down inline rather than via stop_microphone_stream(), which + // takes the `is_open` lock we are already holding. + { + let mut mute_guard = self.mute_state.lock().unwrap(); + if mute_guard.did_mute { + restore_mute(mute_guard.prev_muted); + mute_guard.did_mute = false; + } + } + if let Some(rec) = self.recorder.lock().unwrap().as_mut() { + // Skipping rec.stop() here: the worker is gone, so the command + // would only fail on the closed channel. + let _ = rec.close(); + } + *self.is_recording.lock().unwrap() = false; + *open_flag = false; + // Fall through and open a fresh stream. } let start_time = Instant::now(); - // Don't mute immediately - caller will handle muting after audio feedback - let mut did_mute_guard = self.did_mute.lock().unwrap(); - *did_mute_guard = false; + // Don't mute immediately - caller will handle muting after audio feedback. + // The previous stream restored audio on close, so did_mute should already + // be false here; if it somehow isn't, restore rather than just clearing the + // flag, which would strand system audio muted. + { + let mut mute_guard = self.mute_state.lock().unwrap(); + if mute_guard.did_mute { + restore_mute(mute_guard.prev_muted); + mute_guard.did_mute = false; + } + } // Get the selected device from settings, considering clamshell mode. // No pre-flight enumeration here: when nothing is configured the @@ -623,11 +856,13 @@ impl AudioRecordingManager { return; } - let mut did_mute_guard = self.did_mute.lock().unwrap(); - if *did_mute_guard { - set_mute(false); + { + let mut mute_guard = self.mute_state.lock().unwrap(); + if mute_guard.did_mute { + restore_mute(mute_guard.prev_muted); + } + mute_guard.did_mute = false; } - *did_mute_guard = false; if let Some(rec) = self.recorder.lock().unwrap().as_mut() { // If still recording, stop first. @@ -667,6 +902,21 @@ impl AudioRecordingManager { /* ---------- recording --------------------------------------------------- */ + /// The one place `state` is written. Derives `recording_active` (the + /// lock-free mirror read by `is_recording()`) from the new value itself, + /// so the two can never drift: a new `RecordingState` variant only needs + /// its active-set membership decided here, once. + fn set_state(&self, guard: &mut RecordingState, new_state: RecordingState) { + *guard = new_state; + self.recording_active.store( + matches!( + *guard, + RecordingState::Recording { .. } | RecordingState::Stopping + ), + Ordering::SeqCst, + ); + } + pub fn try_start_recording( &self, binding_id: &str, @@ -675,23 +925,29 @@ impl AudioRecordingManager { let mut state = self.state.lock().unwrap(); if let RecordingState::Idle = *state { - // Ensure microphone is open in on-demand mode - if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) { - // Cancel any pending lazy close - self.close_generation.fetch_add(1, Ordering::SeqCst); - if let Err(e) = self.start_microphone_stream() { - let msg = format!("{e}"); - error!("Failed to open microphone stream: {msg}"); - return Err(msg); - } + // Cancel any pending lazy close (no-op in always-on mode, where + // closes are never scheduled). + self.close_generation.fetch_add(1, Ordering::SeqCst); + // Opens the stream in on-demand mode. In always-on mode the stream + // is normally already open and this is a cheap aliveness check — + // but if the capture worker died (device disconnect), it rebuilds + // the stream instead of leaving every subsequent start wedged on + // "Recorder not available". + if let Err(e) = self.start_microphone_stream() { + let msg = format!("{e}"); + error!("Failed to open microphone stream: {msg}"); + return Err(msg); } if let Some(rec) = self.recorder.lock().unwrap().as_ref() { if rec.start(vad_policy).is_ok() { *self.is_recording.lock().unwrap() = true; - *state = RecordingState::Recording { - binding_id: binding_id.to_string(), - }; + self.set_state( + &mut state, + RecordingState::Recording { + binding_id: binding_id.to_string(), + }, + ); debug!("Recording started for binding {binding_id}"); return Ok(()); } @@ -703,12 +959,10 @@ impl AudioRecordingManager { } pub fn update_selected_device(&self) -> Result<(), anyhow::Error> { - // Device settings changed; drop the cached resolution so the next - // open re-enumerates. (The name-keyed cache would miss anyway; this - // just avoids holding a stale cpal::Device alive.) + // Device settings changed; re-enumerate the device and restart capture. self.invalidate_device_cache(); - // If currently open, restart the microphone stream to use the new device - if *self.is_open.lock().unwrap() { + let was_open = *self.is_open.lock().unwrap(); + if was_open { self.close_generation.fetch_add(1, Ordering::SeqCst); self.stop_microphone_stream(); self.start_microphone_stream()?; @@ -716,6 +970,41 @@ impl AudioRecordingManager { Ok(()) } + pub fn update_selected_channel( + &self, + selected_channel: Option, + ) -> Result<(), anyhow::Error> { + // Serialize against recording start/stop. Restarting an active capture + // would discard its samples and leave the manager's recording state out + // of sync with the new recorder. + let state = self.state.lock().unwrap(); + if !matches!(*state, RecordingState::Idle) { + return Err(anyhow::anyhow!( + "Cannot change the input channel while recording" + )); + } + + let previous_channel = get_settings(&self.app_handle).selected_channel; + let was_open = *self.is_open.lock().unwrap(); + if was_open { + self.close_generation.fetch_add(1, Ordering::SeqCst); + self.stop_microphone_stream(); + } + if let Some(recorder) = self.recorder.lock().unwrap().as_mut() { + recorder.set_selected_channel(selected_channel); + } + if was_open { + if let Err(error) = self.start_microphone_stream() { + if let Some(recorder) = self.recorder.lock().unwrap().as_mut() { + recorder.set_selected_channel(previous_channel); + } + return Err(error); + } + } + drop(state); + Ok(()) + } + pub fn cancel_generation(&self) -> u64 { self.cancel_generation.load(Ordering::Acquire) } @@ -731,7 +1020,7 @@ impl AudioRecordingManager { RecordingState::Recording { binding_id: ref active, } if active == binding_id => { - *state = RecordingState::Stopping; + self.set_state(&mut state, RecordingState::Stopping); drop(state); // Optionally keep recording for a bit longer to capture trailing audio. @@ -770,7 +1059,7 @@ impl AudioRecordingManager { }; *self.is_recording.lock().unwrap() = false; - *self.state.lock().unwrap() = RecordingState::Idle; + self.set_state(&mut self.state.lock().unwrap(), RecordingState::Idle); // In on-demand mode, close the mic (lazily if the setting is enabled) if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) { @@ -801,10 +1090,12 @@ impl AudioRecordingManager { } } pub fn is_recording(&self) -> bool { - matches!( - *self.state.lock().unwrap(), - RecordingState::Recording { .. } | RecordingState::Stopping - ) + // Lock-free: mirrors the `state` {Recording, Stopping} membership via + // an atomic maintained by `set_state()`. Polled from the webview/main + // thread, so it MUST NOT take the `state` mutex (a worker can hold it + // across a slow CoreAudio open/close → main-thread deadlock / UI + // freeze). + self.recording_active.load(Ordering::SeqCst) } /// Cancel any ongoing recording without returning audio samples @@ -814,7 +1105,7 @@ impl AudioRecordingManager { match *state { RecordingState::Recording { .. } => { - *state = RecordingState::Idle; + self.set_state(&mut state, RecordingState::Idle); drop(state); if let Some(rec) = self.recorder.lock().unwrap().as_ref() { diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index 7af1665491..e5fa25629a 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -4,17 +4,14 @@ use super::model_capabilities::{ use crate::settings::{get_settings, write_settings}; use anyhow::Result; use flate2::read::GzDecoder; -use futures_util::StreamExt; use hf_hub::api::tokio::{ApiBuilder, CancellationToken, Progress}; use hf_hub::{Cache, Repo, RepoType}; -use log::{debug, info, warn}; +use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use specta::Type; use std::collections::{HashMap, HashSet}; use std::fs; use std::fs::File; -use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -22,6 +19,10 @@ use std::time::{Duration, Instant}; use tar::Archive; use tauri::{AppHandle, Emitter, Manager}; +mod download; + +use download::{HttpDownloadOutcome, DOWNLOAD_STALL_TIMEOUT}; + #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub enum EngineType { /// Any GGML/GGUF model loaded through transcribe-cpp (Whisper, Parakeet, @@ -122,6 +123,10 @@ pub struct QuantFile { pub filename: String, pub quant: String, pub size_bytes: u64, + /// Content sha256 — the trust anchor for downloads from any source (HF or + /// mirror). `None` only for catalogs predating the field. + #[serde(default)] + pub sha256: Option, } /// Pick the default quant among `files`: the one whose `quant` matches @@ -184,12 +189,45 @@ impl ModelDescriptor { /// Render the frontend-facing [`ModelInfo`] by combining this spec with live /// disk `status`. pub fn to_model_info(&self, status: &DiskStatus) -> ModelInfo { - let file = self.default_file(); + self.render_model_info(self.default_file(), status) + } + + /// [`ModelInfo`] for one specific quant `file` of this catalog model — how + /// alternate-quant files found on disk surface with full catalog metadata + /// instead of as anonymous customs. The default quant keeps the plain + /// catalog name; any other quant appends it ("Name (Q4_K_M)") so two + /// quants of one model stay tellable apart, and only the default carries + /// the Recommended badge. Identity stays `"{repo_id}/{filename}"`, so the + /// default quant renders identically to the seeded entry. + pub fn to_model_info_for_file(&self, file: &QuantFile, status: &DiskStatus) -> ModelInfo { + self.render_model_info(Some(file), status) + } + + fn render_model_info(&self, file: Option<&QuantFile>, status: &DiskStatus) -> ModelInfo { + let is_default = match (file, self.default_file()) { + (Some(f), Some(d)) => f.filename == d.filename, + _ => true, + }; + let id = match (&self.source, file) { + (ModelSource::HuggingFace { repo_id, .. }, Some(f)) => { + format!("{}/{}", repo_id, f.filename) + } + _ => self.id.clone(), + }; + let name = if is_default { + self.name.clone() + } else { + format!( + "{} ({})", + self.name, + file.map(|f| f.quant.as_str()).unwrap_or("") + ) + }; let languages = canonicalize_supported_languages(self.caps.languages.clone().unwrap_or_default()); ModelInfo { - id: self.id.clone(), - name: self.name.clone(), + id, + name, description: self.description.clone(), filename: file.map(|f| f.filename.clone()).unwrap_or_default(), source: self.source.clone(), @@ -202,7 +240,7 @@ impl ModelDescriptor { accuracy_score: self.accuracy_score, speed_score: self.speed_score, supports_translation: self.caps.supports_translation.unwrap_or(false), - is_recommended: self.recommended, + is_recommended: self.recommended && is_default, supports_language_selection: languages.len() > 1, supported_languages: languages, // Catalog models are always HF-sourced downloads, never user-dropped @@ -275,14 +313,23 @@ pub struct DownloadProgress { /// Resolve a Hugging Face model file in the shared HF cache, if already present. /// Uses hf-hub's stock location (HF_HOME or ~/.cache/huggingface/hub) so /// downloads are shared with other tools. +/// +/// hf-hub resolves purely through `refs/`. Pinned downloads write +/// `refs/`, but caches populated before pinning — or by other +/// tools, which download via `main` — only have `refs/main`, so lookup falls +/// back to it. Grandfathered `main` copies may predate the pin; per policy a +/// working local model is never invalidated by routine catalog regeneration. fn hf_cached_path(repo_id: &str, revision: &str, filename: &str) -> Option { - Cache::from_env() - .repo(Repo::with_revision( - repo_id.to_string(), - RepoType::Model, - revision.to_string(), - )) - .get(filename) + let get = |rev: &str| { + Cache::from_env() + .repo(Repo::with_revision( + repo_id.to_string(), + RepoType::Model, + rev.to_string(), + )) + .get(filename) + }; + get(revision).or_else(|| (revision != "main").then(|| get("main")).flatten()) } /// Friendly name advertised by GGUF metadata, if present. Empty strings are not @@ -335,6 +382,10 @@ struct HfProgressState { total: u64, downloaded: u64, last_emit: Instant, + /// Every callback (even throttled-out ones) bumps this; the stall watchdog + /// reads it. Starts at construction so a hang before the first byte — + /// e.g. a wedged metadata/resolve request — also counts as a stall. + last_activity: Instant, } impl HfDownloadProgress { @@ -346,10 +397,16 @@ impl HfDownloadProgress { total: 0, downloaded: 0, last_emit: Instant::now(), + last_activity: Instant::now(), })), } } + /// Instant of the most recent sign of life from the transfer. + fn last_activity(&self) -> Instant { + self.state.lock().unwrap().last_activity + } + fn emit(&self, downloaded: u64, total: u64) { let percentage = if total > 0 { (downloaded as f64 / total as f64) * 100.0 @@ -375,6 +432,7 @@ impl Progress for HfDownloadProgress { st.total = size as u64; st.downloaded = 0; st.last_emit = Instant::now(); + st.last_activity = Instant::now(); } self.emit(0, size as u64); } @@ -384,6 +442,7 @@ impl Progress for HfDownloadProgress { let mut st = self.state.lock().unwrap(); st.downloaded = st.downloaded.saturating_add(size as u64); let now = Instant::now(); + st.last_activity = now; // Throttle to ~10 updates/sec, but always emit the final byte. let emit = now.duration_since(st.last_emit) >= Duration::from_millis(100) || (st.total > 0 && st.downloaded >= st.total); @@ -1337,13 +1396,33 @@ impl ModelManager { } fn update_download_status(&self) -> Result<()> { + // Snapshot in-flight download ids before taking the registry lock (the + // two locks are never nested) so a mid-download entry is never dropped. + let downloading_ids: HashSet = + self.cancel_flags.lock().unwrap().keys().cloned().collect(); let mut models = self.available_models.lock().unwrap(); + let mut vanished_alternates: Vec = Vec::new(); for model in models.values_mut() { if let ModelSource::HuggingFace { repo_id, revision } = &model.source { - model.is_downloaded = hf_cached_path(repo_id, revision, &model.filename).is_some(); + // A models-dir copy counts too: mirror-fallback downloads land + // there, and it makes manual drop-ins of catalog files work. + let local_path = self.models_dir.join(&model.filename); + let partial_path = self.models_dir.join(format!("{}.partial", &model.filename)); + model.is_downloaded = hf_cached_path(repo_id, revision, &model.filename).is_some() + || local_path.exists(); model.is_downloading = false; - model.partial_size = 0; + model.partial_size = partial_path.metadata().map(|m| m.len()).unwrap_or(0); + // Alternate-quant entries exist only because their file was + // discovered on disk — the catalog offers just the default + // quant, so they are never presented for download. When the + // file is gone, the entry goes with it. + if !model.is_downloaded + && !downloading_ids.contains(&model.id) + && Self::is_catalog_alternate_quant(repo_id, &model.filename) + { + vanished_alternates.push(model.id.clone()); + } continue; } if model.is_directory { @@ -1391,9 +1470,46 @@ impl ModelManager { } } + for id in vanished_alternates { + models.remove(&id); + } + Ok(()) } + /// Whether `filename` is a catalog-listed quant of `repo_id` other than + /// the default — the only quant the catalog seeds and offers for download. + fn is_catalog_alternate_quant(repo_id: &str, filename: &str) -> bool { + crate::catalog::file_in_catalog(filename, Some(repo_id)).is_some_and(|(desc, file)| { + desc.default_file() + .is_some_and(|d| d.filename != file.filename) + }) + } + + /// Remove a single file from the shared HF cache: the snapshot pointer for + /// the resolved revision and, when the pointer is a symlink, the blob it + /// points to. Everything else in the repo (other quants, refs) is left + /// untouched. Returns whether anything was removed. + fn delete_hf_cache_file(repo_id: &str, revision: &str, filename: &str) -> bool { + let Some(pointer) = hf_cached_path(repo_id, revision, filename) else { + return false; + }; + // Resolve the blob before the pointer goes away. On Windows the + // pointer may be a plain file (hf-hub's symlink fallback renames the + // blob into the snapshot), in which case there is no separate blob. + let is_symlink = fs::symlink_metadata(&pointer) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + if is_symlink { + if let Ok(blob) = fs::canonicalize(&pointer) { + info!("Deleting HF cache blob at: {:?}", blob); + let _ = fs::remove_file(&blob); + } + } + info!("Deleting HF cache file at: {:?}", pointer); + fs::remove_file(&pointer).is_ok() + } + fn auto_select_model_if_needed(&self) -> Result<()> { let mut settings = get_settings(&self.app_handle); @@ -1510,6 +1626,28 @@ impl ModelManager { continue; } + // A file matching ANY catalog-listed quant surfaces as that catalog + // model — full name/description/scores, quant-suffixed name for + // non-defaults — instead of as an anonymous custom entry. (Default + // quants never reach here: they're in `predefined_filenames`.) + if let Some((desc, quant_file)) = crate::catalog::file_in_catalog(&filename, None) { + let info = desc.to_model_info_for_file( + quant_file, + &DiskStatus { + is_downloaded: true, + ..Default::default() + }, + ); + if !available_models.contains_key(&info.id) { + info!( + "Discovered catalog quant in models dir: {} ({})", + info.id, filename + ); + available_models.insert(info.id.clone(), info); + } + continue; + } + // Skip if model ID already exists (shouldn't happen, but be safe) if available_models.contains_key(&model_id) { continue; @@ -1658,6 +1796,28 @@ impl ModelManager { continue; } + // Catalog-listed quants (same repo) surface with full catalog + // metadata — quant-suffixed name for non-defaults — and skip + // the header probe (the catalog is authoritative for its own + // models). Everything else keeps the generic probed path. + if let Some((desc, quant_file)) = + crate::catalog::file_in_catalog(&fname, Some(&repo_id)) + { + let info = desc.to_model_info_for_file( + quant_file, + &DiskStatus { + is_downloaded: true, + ..Default::default() + }, + ); + info!( + "Discovered catalog quant in HF cache: {} ({})", + info.id, repo_id + ); + available_models.insert(info.id.clone(), info); + continue; + } + let path = snapshot.join(&fname); let probe = prober.probe_file(&path); // Only surface models transcribe-cpp recognises. @@ -1720,56 +1880,6 @@ impl ModelManager { }) } - /// Verifies the SHA256 of `path` against `expected_sha256` (if provided). - /// On mismatch or read error the partial file is deleted and an error is returned, - /// so the next download attempt always starts from a clean state. - /// When `expected_sha256` is `None` (custom user models) verification is skipped. - fn verify_sha256(path: &Path, expected_sha256: Option<&str>, model_id: &str) -> Result<()> { - let Some(expected) = expected_sha256 else { - return Ok(()); - }; - match Self::compute_sha256(path) { - Ok(actual) if actual == expected => { - info!("SHA256 verified for model {}", model_id); - Ok(()) - } - Ok(actual) => { - warn!( - "SHA256 mismatch for model {}: expected {}, got {}", - model_id, expected, actual - ); - let _ = fs::remove_file(path); - Err(anyhow::anyhow!( - "Download verification failed for model {}: file is corrupt. Please retry.", - model_id - )) - } - Err(e) => { - let _ = fs::remove_file(path); - Err(anyhow::anyhow!( - "Failed to verify download for model {}: {}. Please retry.", - model_id, - e - )) - } - } - } - - /// Computes the SHA256 hex digest of a file, reading in 64KB chunks to handle large models. - fn compute_sha256(path: &Path) -> Result { - let mut file = File::open(path)?; - let mut hasher = Sha256::new(); - let mut buffer = [0u8; 65536]; - loop { - let n = file.read(&mut buffer)?; - if n == 0 { - break; - } - hasher.update(&buffer[..n]); - } - Ok(format!("{:x}", hasher.finalize())) - } - /// Download a Hugging Face-sourced model into the shared HF cache via /// hf-hub, reporting progress through the same `model-download-progress` /// event the URL path uses. Uses hf-hub's stock cache, but deliberately @@ -1783,8 +1893,11 @@ impl ModelManager { let model_id = model_info.id.clone(); let filename = model_info.filename.clone(); - // Already in the shared cache (possibly from another tool)? Done. - if hf_cached_path(&repo_id, &revision, &filename).is_some() { + // Already in the shared cache (possibly from another tool), or dropped + // into the models dir (mirror fallback / manual install)? Done. + if hf_cached_path(&repo_id, &revision, &filename).is_some() + || self.models_dir.join(&filename).exists() + { self.update_download_status()?; let _ = self.app_handle.emit("model-download-complete", &model_id); return Ok(()); @@ -1818,37 +1931,207 @@ impl ModelManager { model_id, repo_id, revision, filename ); - // Download chunks in parallel (default is 1 = sequential). Throughput - // scales near-linearly with this count because each connection is capped - // (~8 MB/s observed per stream), so we stack several to approach the - // link's real bandwidth. 8 stays light on CPU/RAM (~80 MB peak buffers) - // even on older machines and is browser-like in connection count. - let api = ApiBuilder::from_env() - // Ignore cached and environment-provided credentials. A stale token - // can make otherwise-public downloads fail authentication. - .with_token(None) - .with_progress(false) - .with_max_files(8) - .build() - .map_err(|e| anyhow::anyhow!("Failed to init Hugging Face API: {}", e))?; - let repo = api.repo(Repo::with_revision(repo_id, RepoType::Model, revision)); - let progress = HfDownloadProgress::new(self.app_handle.clone(), model_id.clone()); - match repo - .download_with_progress_cancellable(&filename, progress, cancel_token) - .await - { - Ok(_) => {} - Err(hf_hub::api::tokio::ApiError::Cancelled) => { - // User cancelled. hf-hub leaves the partially downloaded - // `.sync.part` in the shared cache, so a later attempt resumes - // instead of restarting. The guard resets is_downloading and - // drops the token; `cancel_download` already emitted - // `model-download-cancelled`. - info!("HF download cancelled for: {}", model_id); - return Ok(()); + // hf-hub has no working internal retry (its retry knobs are hardcoded + // to zero), so a single transient fault — dropped connection, a 429 + // from the resolve endpoint, a CDN blip — would otherwise fail the + // whole download. Each attempt resumes from the `.sync.part` + // committed-offset marker, so a retry only re-fetches what the failed + // attempt hadn't finished. + // Start moderately parallel for normal-network throughput, then stay + // sequential after the first failure. Eight simultaneous connections + // were all reset on an affected network in #1579, while one stream + // succeeded; four is a less aggressive fast path, and every retry uses + // the known-compatible request pattern. + const ATTEMPT_STREAMS: [usize; 4] = [4, 1, 1, 1]; + let mut attempt: usize = 1; + let hf_error = loop { + let stream_count = ATTEMPT_STREAMS[attempt - 1]; + info!( + "HF download attempt {}/{} for {} using {} concurrent stream(s)", + attempt, + ATTEMPT_STREAMS.len(), + model_id, + stream_count + ); + + // Fresh client per attempt so a wedged connection from the previous + // try can't poison the retry. + let api = ApiBuilder::from_env() + // Ignore cached and environment-provided credentials. A stale token + // can make otherwise-public downloads fail authentication. + .with_token(None) + .with_progress(false) + .with_max_files(stream_count) + .build() + .map_err(|e| anyhow::anyhow!("Failed to init Hugging Face API: {}", e))?; + let repo = api.repo(Repo::with_revision( + repo_id.clone(), + RepoType::Model, + revision.clone(), + )); + let progress = HfDownloadProgress::new(self.app_handle.clone(), model_id.clone()); + + // hf-hub has no internal timeouts, so a wedged connection would + // otherwise hang this attempt forever and neither the retry loop + // nor the mirror fallback would ever fire. The watchdog cancels a + // per-attempt child token when progress goes stale; a user cancel + // on the parent propagates through the same child. + let attempt_token = cancel_token.child_token(); + let watchdog = tokio::spawn({ + let probe = progress.clone(); + let attempt_token = attempt_token.clone(); + async move { + loop { + tokio::time::sleep(Duration::from_secs(5)).await; + if attempt_token.is_cancelled() { + break; + } + if probe.last_activity().elapsed() > DOWNLOAD_STALL_TIMEOUT { + attempt_token.cancel(); + break; + } + } + } + }); + // hf-hub only observes its token inside the chunk loop — the + // metadata/resolve request and cache lock run before it, so a hang + // there would ignore the cancel entirely. Race the whole future + // against the token: on cancel, grant a short grace so an attempt + // that IS in the chunk loop can unwind gracefully (committing the + // `.sync.part` resume offset), then drop the future outright, + // which aborts whatever request it was wedged in. + let mut download = std::pin::pin!(repo.download_with_progress_cancellable( + &filename, + progress, + attempt_token.clone() + )); + let result = tokio::select! { + r = &mut download => r, + _ = attempt_token.cancelled() => { + match tokio::time::timeout(Duration::from_secs(5), &mut download).await { + Ok(r) => r, + Err(_) => Err(hf_hub::api::tokio::ApiError::Cancelled), + } + } + }; + watchdog.abort(); + + match result { + Ok(_) => break None, + Err(hf_hub::api::tokio::ApiError::Cancelled) if cancel_token.is_cancelled() => { + // User cancelled. hf-hub leaves the partially downloaded + // `.sync.part` in the shared cache, so a later attempt resumes + // instead of restarting. The guard resets is_downloading and + // drops the token; `cancel_download` already emitted + // `model-download-cancelled`. + info!("HF download cancelled for: {}", model_id); + return Ok(()); + } + Err(hf_hub::api::tokio::ApiError::Cancelled) => { + let err = anyhow::anyhow!( + "transfer stalled: no progress for {}s", + DOWNLOAD_STALL_TIMEOUT.as_secs() + ); + // A parallel attempt may be what wedged the network. Give + // the connection pool a brief pause, then retry once using + // the known-compatible single-stream path. A sequential + // stall already cost DOWNLOAD_STALL_TIMEOUT, so further + // retries would likely just repeat it — use the mirror. + if stream_count == 1 || attempt >= ATTEMPT_STREAMS.len() { + break Some(err); + } + let delay = Duration::from_secs(1_u64 << attempt); + warn!( + "HF download attempt {}/{} stalled for {} using {} concurrent stream(s); retrying with {} stream(s) in {}s", + attempt, + ATTEMPT_STREAMS.len(), + model_id, + stream_count, + ATTEMPT_STREAMS[attempt], + delay.as_secs() + ); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = cancel_token.cancelled() => { + info!("HF download cancelled for: {}", model_id); + return Ok(()); + } + } + attempt += 1; + } + Err(e) => { + // {:?} keeps the error source chain (reset vs TLS vs timeout); + // Display truncates it to "error sending request". + let err = anyhow::anyhow!("{:?}", e); + if attempt >= ATTEMPT_STREAMS.len() { + break Some(err); + } + let delay = Duration::from_secs(1_u64 << attempt); + warn!( + "HF download attempt {}/{} failed for {} using {} concurrent stream(s): {}; retrying with {} stream(s) in {}s", + attempt, + ATTEMPT_STREAMS.len(), + model_id, + stream_count, + err, + ATTEMPT_STREAMS[attempt], + delay.as_secs() + ); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = cancel_token.cancelled() => { + info!("HF download cancelled for: {}", model_id); + return Ok(()); + } + } + attempt += 1; + } + } + }; + + if let Some(hf_error) = hf_error { + // `attempt`, not the schedule length: a sequential stall breaks out early. + error!( + "HF download failed for {} after {} attempt(s): {:?}", + model_id, attempt, hf_error + ); + let mirrors = crate::catalog::mirror_fallbacks(&model_id); + if mirrors.is_empty() { + return Err(anyhow::anyhow!( + "Hugging Face download failed after {} attempt(s): {}", + attempt, + hf_error + )); + } + let mut completed = false; + for mirror in &mirrors { + info!("Falling back to mirror for {}: {}", model_id, mirror.url); + match self + .download_from_mirror(&model_id, &filename, mirror, cancel_token.clone()) + .await + { + Ok(true) => { + completed = true; + break; + } + Ok(false) => { + info!("Mirror download cancelled for: {}", model_id); + return Ok(()); + } + Err(e) => { + warn!( + "Mirror download failed for {} from {}: {:?}", + model_id, mirror.url, e + ); + } + } } - Err(e) => { - return Err(anyhow::anyhow!("Hugging Face download failed: {}", e)); + if !completed { + return Err(anyhow::anyhow!( + "Download failed from Hugging Face ({}) and {} mirror(s)", + hf_error, + mirrors.len() + )); } } @@ -1860,6 +2143,47 @@ impl ModelManager { Ok(()) } + /// Direct-HTTP download of a catalog model's file from a mirror into the + /// models dir (HF-model resolution accepts that location too). Returns + /// `Ok(true)` on completion, `Ok(false)` if cancelled (partial kept). + async fn download_from_mirror( + &self, + model_id: &str, + filename: &str, + mirror: &crate::catalog::MirrorFile, + cancel_token: CancellationToken, + ) -> Result { + fs::create_dir_all(&self.models_dir)?; + let model_path = self.models_dir.join(filename); + let partial_path = self.models_dir.join(format!("{}.partial", filename)); + + if model_path.exists() { + return Ok(true); + } + + match self + .download_http_resumable( + model_id, + &mirror.url, + &partial_path, + Some(mirror.size_bytes), + Some(&mirror.sha256), + &cancel_token, + ) + .await? + { + HttpDownloadOutcome::Cancelled => Ok(false), + HttpDownloadOutcome::Completed => { + fs::rename(&partial_path, &model_path)?; + info!( + "Mirror download of {} completed and verified ({:?})", + model_id, model_path + ); + Ok(true) + } + } + } + pub async fn download_model(&self, model_id: &str) -> Result<()> { let model_info = { let models = self.available_models.lock().unwrap(); @@ -1895,16 +2219,6 @@ impl ModelManager { return Ok(()); } - // Check if we have a partial download to resume - let mut resume_from = if partial_path.exists() { - let size = partial_path.metadata()?.len(); - info!("Resuming download of model {} from byte {}", model_id, size); - size - } else { - info!("Starting fresh download of model {} from {}", model_id, url); - 0 - }; - // Mark as downloading { let mut models = self.available_models.lock().unwrap(); @@ -1929,168 +2243,28 @@ impl ModelManager { disarmed: false, }; - // Create HTTP client with range request for resuming - let client = reqwest::Client::new(); - let mut request = client.get(&url); - - if resume_from > 0 { - request = request.header("Range", format!("bytes={}-", resume_from)); - } - - let mut response = request.send().await?; - - // If we tried to resume but server returned 200 (not 206 Partial Content), - // the server doesn't support range requests. Delete partial file and restart - // fresh to avoid file corruption (appending full file to partial). - if resume_from > 0 && response.status() == reqwest::StatusCode::OK { - warn!( - "Server doesn't support range requests for model {}, restarting download", - model_id - ); - drop(response); - let _ = fs::remove_file(&partial_path); - - // Reset resume_from since we're starting fresh - resume_from = 0; - - // Restart download without range header - response = client.get(&url).send().await?; - } - - // Check for success or partial content status - if !response.status().is_success() - && response.status() != reqwest::StatusCode::PARTIAL_CONTENT + // URL sources carry no authoritative size, so the helper falls back to + // the server's content-length for progress and completeness checks. + match self + .download_http_resumable( + model_id, + &url, + &partial_path, + None, + expected_sha256.as_deref(), + &cancel_token, + ) + .await? { - return Err(anyhow::anyhow!( - "Failed to download model: HTTP {}", - response.status() - )); - } - - let total_size = if resume_from > 0 { - // For resumed downloads, add the resume point to content length - resume_from + response.content_length().unwrap_or(0) - } else { - response.content_length().unwrap_or(0) - }; - - let mut downloaded = resume_from; - let mut stream = response.bytes_stream(); - - // Open file for appending if resuming, or create new if starting fresh - let mut file = if resume_from > 0 { - std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&partial_path)? - } else { - std::fs::File::create(&partial_path)? - }; - - // Emit initial progress - let initial_progress = DownloadProgress { - model_id: model_id.to_string(), - downloaded, - total: total_size, - percentage: if total_size > 0 { - (downloaded as f64 / total_size as f64) * 100.0 - } else { - 0.0 - }, - }; - let _ = self - .app_handle - .emit("model-download-progress", &initial_progress); - - // Throttle progress events to max 10/sec (100ms intervals) - let mut last_emit = Instant::now(); - let throttle_duration = Duration::from_millis(100); - - // Download with progress - while let Some(chunk) = stream.next().await { - // Check if download was cancelled - if cancel_token.is_cancelled() { - drop(file); + HttpDownloadOutcome::Cancelled => { info!("Download cancelled for: {}", model_id); // Keep partial file for resume functionality. // Guard handles is_downloading + cancel_flags cleanup on drop. return Ok(()); } - - let chunk = chunk?; - - file.write_all(&chunk)?; - downloaded += chunk.len() as u64; - - let percentage = if total_size > 0 { - (downloaded as f64 / total_size as f64) * 100.0 - } else { - 0.0 - }; - - // Emit progress event (throttled to avoid UI freeze) - if last_emit.elapsed() >= throttle_duration { - let progress = DownloadProgress { - model_id: model_id.to_string(), - downloaded, - total: total_size, - percentage, - }; - let _ = self.app_handle.emit("model-download-progress", &progress); - last_emit = Instant::now(); - } + HttpDownloadOutcome::Completed => {} } - // Emit final progress to ensure 100% is shown - let final_progress = DownloadProgress { - model_id: model_id.to_string(), - downloaded, - total: total_size, - percentage: if total_size > 0 { - (downloaded as f64 / total_size as f64) * 100.0 - } else { - 100.0 - }, - }; - let _ = self - .app_handle - .emit("model-download-progress", &final_progress); - - file.flush()?; - drop(file); // Ensure file is closed before moving - - // Verify downloaded file size matches expected size - if total_size > 0 { - let actual_size = partial_path.metadata()?.len(); - if actual_size != total_size { - // Download is incomplete/corrupted - delete partial and return error - let _ = fs::remove_file(&partial_path); - return Err(anyhow::anyhow!( - "Download incomplete: expected {} bytes, got {} bytes", - total_size, - actual_size - )); - } - } - - // Verify SHA256 checksum. Runs in a blocking thread so the async executor is not - // stalled while hashing large model files (up to 1.6 GB). On failure the partial - // is deleted inside verify_sha256 so the next attempt always starts fresh. - let _ = self.app_handle.emit("model-verification-started", model_id); - info!("Verifying SHA256 for model {}...", model_id); - let verify_path = partial_path.clone(); - let verify_expected = expected_sha256.clone(); - let verify_model_id = model_id.to_string(); - let verify_result = tokio::task::spawn_blocking(move || { - Self::verify_sha256(&verify_path, verify_expected.as_deref(), &verify_model_id) - }) - .await - .map_err(|e| anyhow::anyhow!("SHA256 task panicked: {}", e))?; - verify_result?; - let _ = self - .app_handle - .emit("model-verification-completed", model_id); - // Handle directory-based models (extract tar.gz) vs file-based models if model_info.is_directory { // Track that this model is being extracted @@ -2222,11 +2396,18 @@ impl ModelManager { debug!("ModelManager: Found model info: {:?}", model_info); if let ModelSource::HuggingFace { repo_id, revision } = &model_info.source { - // Cached at /models--org--name/snapshots//; remove - // the whole repo dir (blobs + refs + snapshots). Per product decision, - // delete hard-removes from the shared HF cache. + let is_alternate_quant = + Self::is_catalog_alternate_quant(repo_id, &model_info.filename); let mut deleted = false; - if let Some(file) = hf_cached_path(repo_id, revision, &model_info.filename) { + if is_alternate_quant { + // Only this quant's own file: the snapshot pointer and its + // blob. The default (and any other quants) survive in the + // cache — the entry never owned more than its one file. + deleted |= Self::delete_hf_cache_file(repo_id, revision, &model_info.filename); + } else if let Some(file) = hf_cached_path(repo_id, revision, &model_info.filename) { + // Cached at /models--org--name/snapshots//; remove + // the whole repo dir (blobs + refs + snapshots). Per product decision, + // delete hard-removes from the shared HF cache. if let Some(repo_dir) = file.ancestors().nth(3) { if repo_dir .file_name() @@ -2239,9 +2420,28 @@ impl ModelManager { } } } + // Also remove a models-dir copy (mirror fallback / manual drop-in) + // and any resumable partial next to it. + for path in [ + self.models_dir.join(&model_info.filename), + self.models_dir + .join(format!("{}.partial", &model_info.filename)), + ] { + if path.exists() { + info!("Deleting model file at: {:?}", path); + fs::remove_file(&path)?; + deleted = true; + } + } if !deleted { return Err(anyhow::anyhow!("No model files found to delete")); } + // Alternate-quant entries are discovery-created (the catalog only + // seeds defaults), so deleting one un-discovers it rather than + // leaving a permanent "(Q4_K_M)" row in the list. + if is_alternate_quant { + self.available_models.lock().unwrap().remove(model_id); + } self.update_download_status()?; let _ = self.app_handle.emit("model-deleted", model_id); return Ok(()); @@ -2322,9 +2522,27 @@ impl ModelManager { } if let ModelSource::HuggingFace { repo_id, revision } = &model_info.source { - return hf_cached_path(repo_id, revision, &model_info.filename).ok_or_else(|| { - anyhow::anyhow!("Complete model file not found in HF cache: {}", model_id) - }); + if let Some(path) = hf_cached_path(repo_id, revision, &model_info.filename) { + return Ok(path); + } + // Mirror-fallback download or manual drop-in in the models dir. + // The complete file only ever appears after verification, so a + // stale `.partial` alongside it is leftover noise, not a veto — + // clear it rather than declaring the model missing. + let local_path = self.models_dir.join(&model_info.filename); + if local_path.exists() { + let partial_path = self + .models_dir + .join(format!("{}.partial", &model_info.filename)); + if partial_path.exists() { + let _ = fs::remove_file(&partial_path); + } + return Ok(local_path); + } + return Err(anyhow::anyhow!( + "Complete model file not found in HF cache or models dir: {}", + model_id + )); } let model_path = self.models_dir.join(&model_info.filename); @@ -2594,73 +2812,105 @@ mod tests { assert_eq!(models.len(), count_before); } - // ── SHA256 verification tests ───────────────────────────────────────────── - - /// Helper: write `data` to a temp file and return (TempDir, path). - /// TempDir must be kept alive for the duration of the test. - fn write_temp_file(data: &[u8]) -> (TempDir, std::path::PathBuf) { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("model.partial"); - let mut f = File::create(&path).unwrap(); - f.write_all(data).unwrap(); - (dir, path) - } - - #[test] - fn test_verify_sha256_skipped_when_none() { - // Custom models have no expected hash — verification must be a no-op. - let (_dir, path) = write_temp_file(b"anything"); - assert!(ModelManager::verify_sha256(&path, None, "custom").is_ok()); - assert!( - path.exists(), - "file must be untouched when verification is skipped" - ); - } - #[test] - fn test_verify_sha256_passes_on_correct_hash() { - // Compute the real hash so the test is self-consistent. - let (_dir, path) = write_temp_file(b"hello world"); - let actual = ModelManager::compute_sha256(&path).unwrap(); - assert!( - ModelManager::verify_sha256(&path, Some(&actual), "test_model").is_ok(), - "should pass when hash matches" - ); - assert!( - path.exists(), - "file must be kept on successful verification" - ); + fn test_catalog_quant_rendering() { + let desc = ModelDescriptor { + id: "org/repo/model-Q8_0.gguf".to_string(), + source: ModelSource::HuggingFace { + repo_id: "org/repo".to_string(), + revision: "main".to_string(), + }, + name: "Model".to_string(), + description: "desc".to_string(), + engine_type: EngineType::TranscribeCpp, + caps: CapabilityProbe::default(), + files: vec![ + QuantFile { + filename: "model-Q4_K_M.gguf".to_string(), + quant: "Q4_K_M".to_string(), + size_bytes: 1, + sha256: None, + }, + QuantFile { + filename: "model-Q8_0.gguf".to_string(), + quant: "Q8_0".to_string(), + size_bytes: 2, + sha256: None, + }, + ], + default_quant: Some("Q8_0".to_string()), + speed_score: 0.5, + accuracy_score: 0.5, + recommended_rank: None, + recommended: true, + }; + let status = DiskStatus::default(); + + // Default quant: plain name, badge intact. + let default_info = desc.to_model_info(&status); + assert_eq!(default_info.name, "Model"); + assert!(default_info.is_recommended); + + // Alternate quant: suffixed name, own id, no badge. + let alt_info = desc.to_model_info_for_file(&desc.files[0], &status); + assert_eq!(alt_info.id, "org/repo/model-Q4_K_M.gguf"); + assert_eq!(alt_info.name, "Model (Q4_K_M)"); + assert_eq!(alt_info.filename, "model-Q4_K_M.gguf"); + assert!(!alt_info.is_recommended); + assert!(!alt_info.is_custom); + + // The default rendered through the per-file path matches to_model_info, + // so seeded entries and discovered defaults can never diverge. + let same = desc.to_model_info_for_file(&desc.files[1], &status); + assert_eq!(same.id, default_info.id); + assert_eq!(same.name, default_info.name); + assert_eq!(same.is_recommended, default_info.is_recommended); } #[test] - fn test_verify_sha256_fails_and_deletes_partial_on_mismatch() { - let (_dir, path) = write_temp_file(b"this is not the real model"); - let wrong_hash = "0000000000000000000000000000000000000000000000000000000000000000"; - - let result = ModelManager::verify_sha256(&path, Some(wrong_hash), "bad_model"); - - assert!(result.is_err(), "mismatch must return an error"); - assert!( - result.unwrap_err().to_string().contains("corrupt"), - "error message should mention corruption" - ); - assert!( - !path.exists(), - "partial file must be deleted after hash mismatch" - ); - } + fn test_discover_catalog_alternate_quant_in_models_dir() { + // A real catalog model with more than one quant. + let desc = crate::catalog::CATALOG + .iter() + .find(|d| d.files.len() > 1) + .expect("catalog has multi-quant models"); + let default_filename = default_quant_file(&desc.files, desc.default_quant.as_deref()) + .unwrap() + .filename + .clone(); + let alt = desc + .files + .iter() + .find(|f| f.filename != default_filename) + .unwrap(); - #[test] - fn test_verify_sha256_fails_and_deletes_partial_when_file_missing() { - // Simulate a partial file that was already removed (e.g. disk full mid-download). - let dir = TempDir::new().unwrap(); - let missing_path = dir.path().join("gone.partial"); - // Don't create the file — it should not exist. + let temp_dir = TempDir::new().unwrap(); + // Content is never probed for catalog-matched files, so empty files do. + fs::write(temp_dir.path().join(&alt.filename), b"").unwrap(); + fs::write(temp_dir.path().join(&default_filename), b"").unwrap(); - let result = - ModelManager::verify_sha256(&missing_path, Some("anyexpectedhash"), "missing_model"); + let mut models = HashMap::new(); + ModelManager::seed_catalog_models(&mut models); + let seeded = models.len(); + ModelManager::discover_custom_transcribe_models(temp_dir.path(), &mut models).unwrap(); - assert!(result.is_err(), "missing file must return an error"); + // The alternate quant surfaces as a catalog-grade HF entry… + let ModelSource::HuggingFace { repo_id, .. } = &desc.source else { + panic!("catalog descriptors are HF-sourced"); + }; + let alt_id = format!("{}/{}", repo_id, alt.filename); + let info = models.get(&alt_id).expect("alternate quant discovered"); + assert_eq!(info.name, format!("{} ({})", desc.name, alt.quant)); + assert_eq!(info.description, desc.description); + assert!(info.is_downloaded); + assert!(!info.is_custom); + assert!(matches!(info.source, ModelSource::HuggingFace { .. })); + + // …while the default-quant file dedups onto its seeded entry: exactly + // one new id, and no filename-stem custom entries for either file. + assert_eq!(models.len(), seeded + 1); + assert!(!models.contains_key(alt.filename.trim_end_matches(".gguf"))); + assert!(!models.contains_key(default_filename.trim_end_matches(".gguf"))); } fn push_gguf_str(out: &mut Vec, val: &str) { diff --git a/src-tauri/src/managers/model/download.rs b/src-tauri/src/managers/model/download.rs new file mode 100644 index 0000000000..288d67f63c --- /dev/null +++ b/src-tauri/src/managers/model/download.rs @@ -0,0 +1,384 @@ +//! Shared direct-HTTP model download transport. +//! +//! Both legacy URL models and Hugging Face mirror fallbacks use this module; +//! source-specific orchestration and finalization remain in the parent module. + +use super::{DownloadProgress, ModelManager}; +use anyhow::Result; +use futures_util::StreamExt; +use hf_hub::api::tokio::CancellationToken; +use log::{info, warn}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::fs::File; +use std::io::{Read, Write}; +use std::path::Path; +use std::time::{Duration, Instant}; +use tauri::Emitter; + +/// Bound on connection setup for direct HTTP downloads (mirror + URL models). +const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); + +/// No headers, body bytes, or hf-hub progress for this long means the transfer +/// is wedged, not slow: direct downloads error out (keeping the partial for +/// resume) and HF attempts are cancelled by a watchdog — either way the retry +/// loop and mirror fallback take over instead of hanging forever. +pub(super) const DOWNLOAD_STALL_TIMEOUT: Duration = Duration::from_secs(60); + +/// Start offset of a `Content-Range: bytes -/` header. +fn content_range_start(value: &str) -> Option { + let range = value.trim().strip_prefix("bytes")?.trim_start(); + range.split('-').next()?.trim().parse().ok() +} + +/// How a [`ModelManager::download_http_resumable`] call ended, cancellation +/// being an outcome (partial kept, no error surfaced) rather than a failure. +#[derive(Debug)] +pub(super) enum HttpDownloadOutcome { + Completed, + Cancelled, +} + +/// Side-channel notifications from the resumable HTTP downloader, decoupled +/// from Tauri (the production wrapper maps them onto app events) so the +/// transport logic is testable without an `AppHandle`. +enum HttpDownloadEvent<'a> { + Progress(&'a DownloadProgress), + VerificationStarted, + VerificationCompleted, +} + +impl ModelManager { + /// Verifies the SHA256 of `path` against `expected_sha256` (if provided). + /// On mismatch or read error the partial file is deleted and an error is returned, + /// so the next download attempt always starts from a clean state. + /// When `expected_sha256` is `None` (custom user models) verification is skipped. + fn verify_sha256(path: &Path, expected_sha256: Option<&str>, model_id: &str) -> Result<()> { + let Some(expected) = expected_sha256 else { + return Ok(()); + }; + match Self::compute_sha256(path) { + Ok(actual) if actual == expected => { + info!("SHA256 verified for model {}", model_id); + Ok(()) + } + Ok(actual) => { + warn!( + "SHA256 mismatch for model {}: expected {}, got {}", + model_id, expected, actual + ); + let _ = fs::remove_file(path); + Err(anyhow::anyhow!( + "Download verification failed for model {}: file is corrupt. Please retry.", + model_id + )) + } + Err(e) => { + let _ = fs::remove_file(path); + Err(anyhow::anyhow!( + "Failed to verify download for model {}: {}. Please retry.", + model_id, + e + )) + } + } + } + + /// Computes the SHA256 hex digest of a file, reading in 64KB chunks to handle large models. + fn compute_sha256(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 65536]; + loop { + let n = file.read(&mut buffer)?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + Ok(format!("{:x}", hasher.finalize())) + } + + /// Emit verification events around a blocking sha256 check of `path`. + /// On mismatch `verify_sha256` deletes the file, so the next attempt (or + /// next source) starts clean. A `None` hash skips checking (custom models). + async fn verify_file_with_events( + model_id: &str, + path: &Path, + expected_sha256: Option<&str>, + emit: &(dyn Fn(HttpDownloadEvent<'_>) + Send + Sync), + ) -> Result<()> { + emit(HttpDownloadEvent::VerificationStarted); + let path = path.to_path_buf(); + let expected = expected_sha256.map(str::to_string); + let id = model_id.to_string(); + tokio::task::spawn_blocking(move || Self::verify_sha256(&path, expected.as_deref(), &id)) + .await + .map_err(|e| anyhow::anyhow!("SHA256 task panicked: {}", e))??; + emit(HttpDownloadEvent::VerificationCompleted); + Ok(()) + } + + /// [`Self::download_http_resumable_with_events`] wired to the Tauri event + /// bus — the production entry point. + pub(super) async fn download_http_resumable( + &self, + model_id: &str, + url: &str, + partial_path: &Path, + expected_size: Option, + expected_sha256: Option<&str>, + cancel_token: &CancellationToken, + ) -> Result { + let app_handle = self.app_handle.clone(); + let id = model_id.to_string(); + Self::download_http_resumable_with_events( + model_id, + url, + partial_path, + expected_size, + expected_sha256, + cancel_token, + &move |event| { + let _ = match event { + HttpDownloadEvent::Progress(progress) => { + app_handle.emit("model-download-progress", progress) + } + HttpDownloadEvent::VerificationStarted => { + app_handle.emit("model-verification-started", &id) + } + HttpDownloadEvent::VerificationCompleted => { + app_handle.emit("model-verification-completed", &id) + } + }; + }, + ) + .await + } + + /// The one resumable HTTP downloader, shared by the mirror fallback and + /// URL-sourced models: fetch `url` into `partial_path`, resuming what's + /// already there, and leave verified bytes in `partial_path` on success — + /// finalizing (rename / extract) is the caller's job. Takes progress and + /// verification notifications as a callback instead of touching Tauri, so + /// the failure-mode behavior below is exercised by tests against a local + /// socket server. + /// + /// Robustness properties, in the order the failure modes appear: + /// - a partial already at the expected size (crash between completion and + /// finalize) is verified and accepted instead of asking the server for + /// `Range: bytes=-` and looping on 416 forever; an oversized one is + /// deleted; a live 416 finishes the partial only when a hash can prove + /// it, and otherwise clears it + /// - connection setup and every body chunk are bounded by + /// [`HTTP_CONNECT_TIMEOUT`] / [`DOWNLOAD_STALL_TIMEOUT`] and race the + /// cancel token, so a wedged transfer can neither hang the download + /// forever nor ignore a cancel + /// - a 200 to a Range request (server ignored it) restarts from zero + /// rather than appending the whole file to the partial; a 206 must start + /// exactly at our offset or the partial is discarded + /// - a server claiming or sending more than the expected size is cut off + /// at the first excess byte, not trusted until it closes the stream + /// - the final bytes are checked against `expected_size` (catalog, or + /// content-length when unknown) and `expected_sha256` before returning + async fn download_http_resumable_with_events( + model_id: &str, + url: &str, + partial_path: &Path, + expected_size: Option, + expected_sha256: Option<&str>, + cancel_token: &CancellationToken, + emit: &(dyn Fn(HttpDownloadEvent<'_>) + Send + Sync), + ) -> Result { + let mut resume_from = partial_path.metadata().map(|m| m.len()).unwrap_or(0); + + if let Some(expected) = expected_size { + if resume_from > expected { + let _ = fs::remove_file(partial_path); + resume_from = 0; + } else if resume_from == expected && expected > 0 { + info!( + "Partial download of {} is already full-size; verifying", + model_id + ); + Self::verify_file_with_events(model_id, partial_path, expected_sha256, emit) + .await?; + return Ok(HttpDownloadOutcome::Completed); + } + } + + if resume_from > 0 { + info!( + "Resuming download of {} from byte {}", + model_id, resume_from + ); + } else { + info!("Starting fresh download of {} from {}", model_id, url); + } + + let client = reqwest::Client::builder() + .connect_timeout(HTTP_CONNECT_TIMEOUT) + .build()?; + let mut request = client.get(url); + if resume_from > 0 { + request = request.header("Range", format!("bytes={}-", resume_from)); + } + let response = tokio::select! { + r = tokio::time::timeout(DOWNLOAD_STALL_TIMEOUT, request.send()) => r + .map_err(|_| anyhow::anyhow!( + "no response within {}s from {}", + DOWNLOAD_STALL_TIMEOUT.as_secs(), url + ))??, + _ = cancel_token.cancelled() => return Ok(HttpDownloadOutcome::Cancelled), + }; + + // 416 to our Range request means its start is at or past the object's + // end. With a catalog size in hand that can only mean the server's + // object is *smaller* than expected (a full-size partial never issues + // a request — handled above), and with no hash there is no trusted + // signal to bless the partial: both restart clean. Only a hash can + // genuinely finish a partial here. Without a Range in flight a 416 is + // just a broken server, which the generic status check below rejects. + if resume_from > 0 && response.status() == reqwest::StatusCode::RANGE_NOT_SATISFIABLE { + if expected_size.is_some() || expected_sha256.is_none() { + let _ = fs::remove_file(partial_path); + return Err(anyhow::anyhow!( + "server object ends before the expected size (HTTP 416)" + )); + } + Self::verify_file_with_events(model_id, partial_path, expected_sha256, emit).await?; + return Ok(HttpDownloadOutcome::Completed); + } + // A 200 to a Range request means the server ignored it and is sending + // the whole file; appending it to the partial would corrupt the model. + if resume_from > 0 && response.status() == reqwest::StatusCode::OK { + let _ = fs::remove_file(partial_path); + resume_from = 0; + } + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "server returned HTTP {}", + response.status() + )); + } + // On a 206, trust but verify the offset: a reply starting anywhere but + // exactly our partial's end would silently corrupt the file on append. + if resume_from > 0 && response.status() == reqwest::StatusCode::PARTIAL_CONTENT { + let starts_at = response + .headers() + .get(reqwest::header::CONTENT_RANGE) + .and_then(|v| v.to_str().ok()) + .and_then(content_range_start); + if starts_at != Some(resume_from) { + let _ = fs::remove_file(partial_path); + return Err(anyhow::anyhow!( + "server returned Content-Range starting at {:?}, expected {}", + starts_at, + resume_from + )); + } + } + // When the catalog pins the size, a server advertising a different + // total is already misbehaving — reject before writing anything. + if let (Some(expected), Some(len)) = (expected_size, response.content_length()) { + if resume_from + len != expected { + return Err(anyhow::anyhow!( + "server advertises {} bytes, expected {}", + resume_from + len, + expected + )); + } + } + + let known_total = + expected_size.or_else(|| response.content_length().map(|l| resume_from + l)); + let total_size = known_total.unwrap_or(0); + let mut downloaded = resume_from; + let mut file = if resume_from > 0 { + std::fs::OpenOptions::new() + .append(true) + .open(partial_path)? + } else { + std::fs::File::create(partial_path)? + }; + + let emit_progress = |downloaded: u64| { + emit(HttpDownloadEvent::Progress(&DownloadProgress { + model_id: model_id.to_string(), + downloaded, + total: total_size, + percentage: if total_size > 0 { + (downloaded as f64 / total_size as f64) * 100.0 + } else { + 0.0 + }, + })); + }; + emit_progress(downloaded); + + // Throttle progress events to max 10/sec (100ms intervals) + let mut last_emit = Instant::now(); + let throttle = Duration::from_millis(100); + let mut stream = response.bytes_stream(); + loop { + let chunk = tokio::select! { + c = tokio::time::timeout(DOWNLOAD_STALL_TIMEOUT, stream.next()) => match c { + // Stalled mid-body: keep the partial for resume. + Err(_) => return Err(anyhow::anyhow!( + "transfer stalled: no data for {}s", + DOWNLOAD_STALL_TIMEOUT.as_secs() + )), + Ok(None) => break, + Ok(Some(chunk)) => chunk?, + }, + _ = cancel_token.cancelled() => { + // Keep the partial for resume; caller handles state cleanup. + return Ok(HttpDownloadOutcome::Cancelled); + } + }; + // An untrusted server must not be able to fill the disk: cut the + // transfer at the first byte past the known total instead of + // trusting it to eventually close the stream. Everything written + // so far is tainted by a provably-misbehaving server — clear it. + if let Some(cap) = known_total { + if downloaded + chunk.len() as u64 > cap { + drop(file); + let _ = fs::remove_file(partial_path); + return Err(anyhow::anyhow!( + "server sent more than the expected {} bytes", + cap + )); + } + } + file.write_all(&chunk)?; + downloaded += chunk.len() as u64; + if last_emit.elapsed() >= throttle { + emit_progress(downloaded); + last_emit = Instant::now(); + } + } + file.flush()?; + drop(file); + emit_progress(downloaded); + + if let Some(expected) = known_total { + let actual = partial_path.metadata()?.len(); + if actual != expected { + let _ = fs::remove_file(partial_path); + return Err(anyhow::anyhow!( + "download incomplete: expected {} bytes, got {}", + expected, + actual + )); + } + } + + // The catalog hash is the trust anchor: for a mirror (an untrusted + // host) this verification is what makes the fallback safe at all. + Self::verify_file_with_events(model_id, partial_path, expected_sha256, emit).await?; + Ok(HttpDownloadOutcome::Completed) + } +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/managers/model/download/tests.rs b/src-tauri/src/managers/model/download/tests.rs new file mode 100644 index 0000000000..dfe3356d91 --- /dev/null +++ b/src-tauri/src/managers/model/download/tests.rs @@ -0,0 +1,579 @@ +use super::*; +use sha2::{Digest, Sha256}; +use std::fs; +use std::fs::File; +use std::io::Write; +use std::path::Path; +use std::time::{Duration, Instant}; +use tempfile::TempDir; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +// ── SHA256 verification tests ───────────────────────────────────────────── + +/// Helper: write `data` to a temp file and return (TempDir, path). +/// TempDir must be kept alive for the duration of the test. +fn write_temp_file(data: &[u8]) -> (TempDir, std::path::PathBuf) { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("model.partial"); + let mut f = File::create(&path).unwrap(); + f.write_all(data).unwrap(); + (dir, path) +} + +#[test] +fn test_verify_sha256_skipped_when_none() { + // Custom models have no expected hash — verification must be a no-op. + let (_dir, path) = write_temp_file(b"anything"); + assert!(ModelManager::verify_sha256(&path, None, "custom").is_ok()); + assert!( + path.exists(), + "file must be untouched when verification is skipped" + ); +} + +#[test] +fn test_verify_sha256_passes_on_correct_hash() { + // Compute the real hash so the test is self-consistent. + let (_dir, path) = write_temp_file(b"hello world"); + let actual = ModelManager::compute_sha256(&path).unwrap(); + assert!( + ModelManager::verify_sha256(&path, Some(&actual), "test_model").is_ok(), + "should pass when hash matches" + ); + assert!( + path.exists(), + "file must be kept on successful verification" + ); +} + +#[test] +fn test_verify_sha256_fails_and_deletes_partial_on_mismatch() { + let (_dir, path) = write_temp_file(b"this is not the real model"); + let wrong_hash = "0000000000000000000000000000000000000000000000000000000000000000"; + + let result = ModelManager::verify_sha256(&path, Some(wrong_hash), "bad_model"); + + assert!(result.is_err(), "mismatch must return an error"); + assert!( + result.unwrap_err().to_string().contains("corrupt"), + "error message should mention corruption" + ); + assert!( + !path.exists(), + "partial file must be deleted after hash mismatch" + ); +} + +#[test] +fn test_verify_sha256_fails_and_deletes_partial_when_file_missing() { + // Simulate a partial file that was already removed (e.g. disk full mid-download). + let dir = TempDir::new().unwrap(); + let missing_path = dir.path().join("gone.partial"); + // Don't create the file — it should not exist. + + let result = + ModelManager::verify_sha256(&missing_path, Some("anyexpectedhash"), "missing_model"); + + assert!(result.is_err(), "missing file must return an error"); +} + +// ── Resumable HTTP downloader tests ─────────────────────────────────────── +// +// Each test drives `download_http_resumable_with_events` against a scripted +// single-connection server on a local socket — no Tauri, no real network. +// Success means verified bytes are left in the partial (finalizing is the +// caller's job), so "no Completed on a bad hash" is the rename gate too. + +fn sha_hex(data: &[u8]) -> String { + format!("{:x}", Sha256::digest(data)) +} + +fn http_response(status_line: &str, headers: &[String], body: &[u8]) -> Vec { + let mut head = format!("HTTP/1.1 {}\r\nConnection: close\r\n", status_line); + for h in headers { + head.push_str(h); + head.push_str("\r\n"); + } + head.push_str("\r\n"); + let mut bytes = head.into_bytes(); + bytes.extend_from_slice(body); + bytes +} + +async fn read_request_head(sock: &mut TcpStream) -> String { + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + while !buf.ends_with(b"\r\n\r\n") { + if sock.read_exact(&mut byte).await.is_err() { + break; + } + buf.push(byte[0]); + } + String::from_utf8_lossy(&buf).into_owned() +} + +/// Serve exactly one connection: capture the request head, write +/// `response` verbatim, close. Returns the URL to fetch and a handle +/// yielding the captured request head (lowercased for header asserts). +async fn serve_once(response: Vec) -> (String, tokio::task::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let head = read_request_head(&mut sock).await; + // The client may hang up early (error tests); that's not a + // server-side failure. + let _ = sock.write_all(&response).await; + let _ = sock.shutdown().await; + head.to_lowercase() + }); + (format!("http://{}/file", addr), handle) +} + +async fn run_download( + url: &str, + partial: &Path, + expected_size: Option, + expected_sha256: Option<&str>, + cancel: &CancellationToken, +) -> Result { + ModelManager::download_http_resumable_with_events( + "test/model", + url, + partial, + expected_size, + expected_sha256, + cancel, + &|_| {}, + ) + .await +} + +#[tokio::test] +async fn http_fresh_download_completes_and_verifies() { + let body = b"hello world"; + let (url, server) = serve_once(http_response( + "200 OK", + &[format!("Content-Length: {}", body.len())], + body, + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + + let out = run_download( + &url, + &partial, + Some(body.len() as u64), + Some(&sha_hex(body)), + &CancellationToken::new(), + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Completed)); + assert_eq!(fs::read(&partial).unwrap(), body); + let head = server.await.unwrap(); + assert!( + !head.contains("range:"), + "fresh download must not send a Range header" + ); +} + +#[tokio::test] +async fn http_resume_appends_with_valid_content_range() { + let full = b"helloworld"; + let (url, server) = serve_once(http_response( + "206 Partial Content", + &[ + "Content-Range: bytes 5-9/10".to_string(), + "Content-Length: 5".to_string(), + ], + &full[5..], + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, &full[..5]).unwrap(); + + let out = run_download( + &url, + &partial, + Some(10), + Some(&sha_hex(full)), + &CancellationToken::new(), + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Completed)); + assert_eq!(fs::read(&partial).unwrap(), full); + let head = server.await.unwrap(); + assert!(head.contains("range: bytes=5-"), "got request: {head}"); +} + +#[tokio::test] +async fn http_wrong_content_range_discards_partial() { + // Server answers 206 but restarts the range at 0: appending would + // corrupt, so the partial must be dropped and the call must fail. + let full = b"helloworld"; + let (url, _server) = serve_once(http_response( + "206 Partial Content", + &[ + "Content-Range: bytes 0-9/10".to_string(), + "Content-Length: 10".to_string(), + ], + full, + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, &full[..5]).unwrap(); + + let err = run_download( + &url, + &partial, + Some(10), + Some(&sha_hex(full)), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("Content-Range"), "{err}"); + assert!(!partial.exists(), "corrupt-prone partial must be deleted"); +} + +#[tokio::test] +async fn http_range_ignored_200_restarts_from_zero() { + let body = b"helloworld!"; + let (url, _server) = serve_once(http_response( + "200 OK", + &[format!("Content-Length: {}", body.len())], + body, + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, b"XXXXX").unwrap(); // stale bytes that must not survive + + let out = run_download( + &url, + &partial, + Some(body.len() as u64), + Some(&sha_hex(body)), + &CancellationToken::new(), + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Completed)); + assert_eq!(fs::read(&partial).unwrap(), body); +} + +#[tokio::test] +async fn http_416_with_hash_finalizes_complete_partial() { + // URL-model shape: no catalog size, but a hash — the one case where a + // 416 may bless the partial, because verification proves it. + let body = b"the whole file"; + let (url, _server) = serve_once(http_response("416 Range Not Satisfiable", &[], b"")).await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, body).unwrap(); + + let out = run_download( + &url, + &partial, + None, + Some(&sha_hex(body)), + &CancellationToken::new(), + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Completed)); + assert_eq!(fs::read(&partial).unwrap(), body); +} + +#[tokio::test] +async fn http_416_with_wrong_hash_clears_partial() { + let (url, _server) = serve_once(http_response("416 Range Not Satisfiable", &[], b"")).await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, b"corrupted bytes").unwrap(); + + let err = run_download( + &url, + &partial, + None, + Some(&sha_hex(b"the real file")), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("corrupt"), "{err}"); + assert!( + !partial.exists(), + "failed verification must delete the partial" + ); +} + +#[tokio::test] +async fn http_416_short_of_expected_size_clears_partial() { + // Mirror shape: catalog size known, partial shorter, yet the server + // says our offset is past EOF — its object is smaller than the catalog + // expects. Never blessed, even with a hash on hand. + let (url, _server) = serve_once(http_response("416 Range Not Satisfiable", &[], b"")).await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, b"12345").unwrap(); + + let err = run_download( + &url, + &partial, + Some(10), + Some(&sha_hex(b"1234567890")), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("416"), "{err}"); + assert!(!partial.exists()); +} + +#[tokio::test] +async fn http_416_without_trust_signals_clears_partial() { + // No size, no hash: nothing can prove the partial complete, so a 416 + // must restart clean instead of accepting unverified bytes. + let (url, _server) = serve_once(http_response("416 Range Not Satisfiable", &[], b"")).await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, b"whatever").unwrap(); + + let err = run_download(&url, &partial, None, None, &CancellationToken::new()) + .await + .unwrap_err(); + + assert!(err.to_string().contains("416"), "{err}"); + assert!(!partial.exists()); +} + +#[tokio::test] +async fn http_full_size_partial_verified_without_network() { + // Crash-between-completion-and-finalize recovery: the partial already + // has every byte, so no request is issued at all (the URL points at a + // dead port to prove it). + let body = b"complete content"; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, body).unwrap(); + + let out = run_download( + "http://127.0.0.1:9/unreachable", + &partial, + Some(body.len() as u64), + Some(&sha_hex(body)), + &CancellationToken::new(), + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Completed)); + assert_eq!(fs::read(&partial).unwrap(), body); +} + +#[tokio::test] +async fn http_oversized_partial_restarts_clean() { + let body = b"12345"; + let (url, server) = serve_once(http_response( + "200 OK", + &[format!("Content-Length: {}", body.len())], + body, + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, b"garbage-beyond-expected").unwrap(); + + let out = run_download( + &url, + &partial, + Some(body.len() as u64), + Some(&sha_hex(body)), + &CancellationToken::new(), + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Completed)); + assert_eq!(fs::read(&partial).unwrap(), body); + let head = server.await.unwrap(); + assert!( + !head.contains("range:"), + "oversized partial must restart, not resume: {head}" + ); +} + +#[tokio::test] +async fn http_conflicting_content_length_rejected_before_writing() { + let (url, _server) = serve_once(http_response( + "200 OK", + &["Content-Length: 10".to_string()], + b"0123456789", + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + + let err = run_download( + &url, + &partial, + Some(5), + Some(&sha_hex(b"01234")), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("advertises"), "{err}"); + assert!( + !partial.exists(), + "nothing may be written on an up-front size conflict" + ); +} + +#[tokio::test] +async fn http_body_exceeding_expected_size_aborts_and_clears() { + // Chunked response (no Content-Length to reject up-front) that keeps + // sending past the expected size: the disk-fill guard must cut the + // transfer at the first excess byte and clear the tainted partial. + let (url, _server) = serve_once(http_response( + "200 OK", + &["Transfer-Encoding: chunked".to_string()], + b"3\r\nabc\r\n5\r\ndefgh\r\n0\r\n\r\n", + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + + let err = run_download( + &url, + &partial, + Some(5), + Some(&sha_hex(b"abcde")), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("more than"), "{err}"); + assert!(!partial.exists(), "tainted partial must be deleted"); +} + +#[tokio::test] +async fn http_wrong_hash_after_download_clears_partial() { + let body = b"tampered bytes"; + let (url, _server) = serve_once(http_response( + "200 OK", + &[format!("Content-Length: {}", body.len())], + body, + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + + let err = run_download( + &url, + &partial, + Some(body.len() as u64), + Some(&sha_hex(b"the expected bytes")), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("corrupt"), "{err}"); + assert!(!partial.exists()); +} + +#[tokio::test] +async fn http_cancel_while_awaiting_headers() { + // Server accepts and goes silent. Cancellation must win immediately — + // not after the stall timeout, and certainly not never. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let _ = read_request_head(&mut sock).await; + tokio::time::sleep(Duration::from_secs(600)).await; + }); + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + + let cancel = CancellationToken::new(); + let trigger = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + trigger.cancel(); + }); + + let started = Instant::now(); + let out = run_download( + &format!("http://{}/file", addr), + &partial, + Some(10), + Some("00"), + &cancel, + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Cancelled)); + assert!( + started.elapsed() < Duration::from_secs(30), + "cancel must not wait out the stall timeout" + ); +} + +#[tokio::test] +async fn http_cancel_during_stalled_body_keeps_partial() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let _ = read_request_head(&mut sock).await; + let _ = sock + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nabc") + .await; + tokio::time::sleep(Duration::from_secs(600)).await; + }); + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + + let cancel = CancellationToken::new(); + let trigger = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(300)).await; + trigger.cancel(); + }); + + let out = run_download( + &format!("http://{}/file", addr), + &partial, + Some(100), + Some("00"), + &cancel, + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Cancelled)); + assert_eq!( + fs::read(&partial).unwrap(), + b"abc", + "bytes received before the cancel must be kept for resume" + ); +} diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index 2fcc9ee2be..d8170a158d 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -1006,7 +1006,7 @@ impl TranscriptionManager { update.audio_committed_ms, update.buffered_ms, ); - Some(stream.text().display()) + Some(stream.text().full) } Err(e) => { perf.record_compute(finalize_start.elapsed()); @@ -1966,7 +1966,19 @@ fn select_transcribe_backend(setting: TranscribeAcceleratorSetting) -> Backend { { Some(b) => b, None => { - warn!("No GPU backend available for transcribe.cpp; falling back to Auto"); + #[cfg(target_os = "linux")] + warn!( + "GPU acceleration was requested, but no transcribe.cpp GPU backend is \ + registered; falling back to Auto (usually CPU). Run with \ + --list-devices to inspect detected devices; VK_LOADER_DEBUG=error can \ + reveal Vulkan loader or driver failures" + ); + #[cfg(not(target_os = "linux"))] + warn!( + "GPU acceleration was requested, but no transcribe.cpp GPU backend is \ + registered; falling back to Auto (usually CPU). Run with \ + --list-devices to inspect detected devices" + ); Backend::Auto } } diff --git a/src-tauri/src/memory.rs b/src-tauri/src/memory.rs new file mode 100644 index 0000000000..2c4f940c15 --- /dev/null +++ b/src-tauri/src/memory.rs @@ -0,0 +1,55 @@ +//! glibc allocator tuning (Linux only). +//! +//! Each dictation allocates multi-megabyte transient buffers — the captured +//! 16 kHz PCM (~64 KB per second, held twice: once for transcription, once +//! for the history WAV) plus the transcription engine's per-run mel/FFT +//! scratch (~80 KB per second of audio) — all freed within seconds. +//! +//! glibc's malloc serves allocations above its "mmap threshold" with a +//! private mmap that is returned to the OS on free. But the threshold is +//! *dynamic*: freeing an mmapped block raises it to that block's size (up to +//! 32 MB), so after the first dictation every later large buffer is served +//! from malloc arenas instead. Arena memory freed by the app is cached for +//! reuse, and interleaved small live allocations pin those pages, so the OS +//! never gets them back: RSS grows by roughly the transient-buffer volume of +//! each dictation, is never touched again, and slowly migrates to swap +//! (issue #1792 — measured at ~15 MB retained per 2-minute dictation; +//! pinning the threshold reduced that to ~0.5 MB). +//! +//! Both entry points are no-ops on non-glibc targets (Windows, macOS, musl): +//! this failure mode is specific to glibc's dynamic-threshold heuristic. + +/// Pin glibc's mmap threshold so large transient buffers keep taking the +/// mmap path and are returned to the OS as soon as they are freed. +/// +/// Must run before the workload allocates (called at the top of `run()`); +/// the cost is an mmap/munmap round-trip per multi-MB buffer, which is +/// negligible at dictation frequency. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +pub fn init_allocator() { + // SAFETY: FFI call with no memory arguments; mallopt only updates + // malloc's internal parameters. + unsafe { + libc::mallopt(libc::M_MMAP_THRESHOLD, 128 * 1024); + } +} + +#[cfg(not(all(target_os = "linux", target_env = "gnu")))] +pub fn init_allocator() {} + +/// Return freed-but-cached malloc arena memory to the OS. +/// +/// Called once per finished transcription pipeline (see `FinishGuard`); it +/// sweeps whatever smaller-than-threshold churn still accumulates in the +/// arenas. Takes on the order of a millisecond, off the main thread. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +pub fn trim_freed_memory() { + // SAFETY: FFI call with no memory arguments; malloc_trim releases whole + // free pages back to the OS via madvise and is thread-safe. + unsafe { + libc::malloc_trim(0); + } +} + +#[cfg(not(all(target_os = "linux", target_env = "gnu")))] +pub fn trim_freed_memory() {} diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index c4e4be1f8e..3b9ca7fa27 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -171,25 +171,29 @@ fn get_monitor_with_cursor(app_handle: &AppHandle) -> Option { if let Some(mouse_location) = input::get_cursor_position(app_handle) { if let Ok(monitors) = app_handle.available_monitors() { for monitor in monitors { - // Tauri's monitor position/size are physical pixels, but enigo - // may return logical coordinates (confirmed on macOS via - // NSEvent::mouseLocation; on Windows, GetCursorPos behavior - // depends on the process DPI-awareness context). Dividing by - // scale_factor normalizes to logical, which is safe regardless: - // if enigo returns logical it matches directly, and if it returns - // physical on a scale=1 monitor the division is a no-op. - let scale = monitor.scale_factor(); - let pos = PhysicalPosition::new( - (monitor.position().x as f64 / scale) as i32, - (monitor.position().y as f64 / scale) as i32, - ); - let size = PhysicalSize::new( - (monitor.size().width as f64 / scale) as u32, - (monitor.size().height as f64 / scale) as u32, - ); - if is_mouse_within_monitor(mouse_location, &pos, &size) { + // On Windows both the cursor (enigo -> GetCursorPos) and the + // monitor bounds are physical pixels, so compare them directly. + #[cfg(target_os = "windows")] + if is_mouse_within_monitor(mouse_location, monitor.position(), monitor.size()) { return Some(monitor); } + + // macOS/Linux: enigo returns logical coords, so scale the bounds down. + #[cfg(not(target_os = "windows"))] + { + let scale = monitor.scale_factor(); + let pos = PhysicalPosition::new( + (monitor.position().x as f64 / scale) as i32, + (monitor.position().y as f64 / scale) as i32, + ); + let size = PhysicalSize::new( + (monitor.size().width as f64 / scale) as u32, + (monitor.size().height as f64 / scale) as u32, + ); + if is_mouse_within_monitor(mouse_location, &pos, &size) { + return Some(monitor); + } + } } } } @@ -229,7 +233,8 @@ fn is_mouse_within_monitor( /// /// We must use LogicalPosition (not PhysicalPosition) because Tauri/tao /// converts PhysicalPosition using the scale factor of the monitor the window -/// is *currently* on, which is wrong when moving cross-monitor. +/// is *currently* on, which is wrong when moving cross-monitor. Windows uses +/// `place_windows_overlay` instead (no single logical space across mixed DPI). fn calculate_overlay_position( app_handle: &AppHandle, width: f64, @@ -266,12 +271,93 @@ fn calculate_overlay_position( /// Current overlay window size in logical units (points), for repositioning /// without assuming a fixed size (compact vs. streaming). +#[cfg(not(target_os = "windows"))] fn current_overlay_logical_size(window: &tauri::webview::WebviewWindow) -> Option<(f64, f64)> { let size = window.inner_size().ok()?; let scale = window.scale_factor().ok()?; Some((size.width as f64 / scale, size.height as f64 / scale)) } +#[cfg(target_os = "windows")] +static WINDOWS_OVERLAY_IS_STREAMING: AtomicBool = AtomicBool::new(false); + +/// Overlay rectangle in the destination monitor's physical pixels, so nothing +/// is converted through the window's previous-monitor DPI. +#[cfg(target_os = "windows")] +fn windows_overlay_bounds( + monitor_position: PhysicalPosition, + monitor_size: PhysicalSize, + scale: f64, + logical_width: f64, + logical_height: f64, + overlay_position: OverlayPosition, +) -> (i32, i32, i32, i32) { + let width = (logical_width * scale).round().max(1.0) as i32; + let height = (logical_height * scale).round().max(1.0) as i32; + let x = (monitor_position.x as f64 + (monitor_size.width as f64 - width as f64) / 2.0).round() + as i32; + let y = match overlay_position { + OverlayPosition::Top => { + (monitor_position.y as f64 + OVERLAY_TOP_OFFSET * scale).round() as i32 + } + OverlayPosition::Bottom => (monitor_position.y as f64 + monitor_size.height as f64 + - height as f64 + - OVERLAY_BOTTOM_OFFSET * scale) + .round() as i32, + }; + + (x, y, width, height) +} + +/// Moves and sizes the overlay in one native SetWindowPos, bypassing tao's +/// current-DPI logical conversion that mislands cross-monitor moves. +#[cfg(target_os = "windows")] +fn place_windows_overlay( + app_handle: &AppHandle, + overlay_window: &tauri::webview::WebviewWindow, + logical_width: f64, + logical_height: f64, +) -> Result<(), String> { + use windows::Win32::UI::WindowsAndMessaging::{SetWindowPos, SWP_NOACTIVATE, SWP_NOZORDER}; + + let monitor = get_monitor_with_cursor(app_handle) + .ok_or_else(|| "failed to determine the monitor containing the cursor".to_string())?; + let (x, y, width, height) = windows_overlay_bounds( + *monitor.position(), + *monitor.size(), + monitor.scale_factor(), + logical_width, + logical_height, + settings::get_settings(app_handle).overlay_position, + ); + let hwnd = overlay_window + .hwnd() + .map_err(|error| format!("failed to get overlay window handle: {error}"))?; + + unsafe { + SetWindowPos( + hwnd, + None, + x, + y, + width, + height, + SWP_NOACTIVATE | SWP_NOZORDER, + ) + .map_err(|error| format!("failed to set overlay bounds: {error}"))?; + } + + log::debug!( + "windows overlay bounds: x={} y={} width={} height={} scale={}", + x, + y, + width, + height, + monitor.scale_factor() + ); + Ok(()) +} + /// Creates the recording overlay window and keeps it hidden by default #[cfg(not(target_os = "macos"))] pub fn create_recording_overlay(app_handle: &AppHandle) { @@ -374,12 +460,27 @@ pub fn create_recording_overlay(app_handle: &AppHandle) { fn show_overlay_state(app_handle: &AppHandle, state: &str) { // Whether the overlay shows at all is governed by overlay_style; position - // only chooses Top vs Bottom placement. + // only chooses Top vs Bottom placement. Checked here (off the main thread) + // so the common overlay-disabled case never pays for a main-thread hop. let settings = settings::get_settings(app_handle); if settings.overlay_style == OverlayStyle::None { return; } + // The rest queries monitors and the cursor and mutates window geometry. On + // Linux the monitor/cursor lookups hit GDK/Xlib on the process's shared X11 + // connection, which is only safe from the GTK main thread — running them on + // a background thread corrupts the connection and hard-crashes the app + // (issue #227). Hop to the main thread on every platform to keep the + // geometry path uniform (a no-op cost on Windows, and it also keeps macOS's + // NSScreen access main-thread-correct). run_on_main_thread runs the closure + // inline when already on the main thread, so this never deadlocks. + let handle = app_handle.clone(); + let state = state.to_string(); + let _ = app_handle.run_on_main_thread(move || show_overlay_state_on_main(&handle, &state)); +} + +fn show_overlay_state_on_main(app_handle: &AppHandle, state: &str) { // Size the overlay for this state (compact vs. streaming), then position it. let (width, height) = overlay_dimensions(state); if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { @@ -387,17 +488,31 @@ fn show_overlay_state(app_handle: &AppHandle, state: &str) { update_gtk_layer_shell_anchors(&overlay_window); let size_started = std::time::Instant::now(); + #[cfg(not(target_os = "windows"))] let _ = overlay_window.set_size(tauri::Size::Logical(tauri::LogicalSize { width, height })); + #[cfg(target_os = "windows")] + WINDOWS_OVERLAY_IS_STREAMING.store(state == "streaming", Ordering::Relaxed); let size_elapsed = size_started.elapsed(); let pos_started = std::time::Instant::now(); - let mut set_pos_elapsed = std::time::Duration::ZERO; - if let Some((x, y)) = calculate_overlay_position(app_handle, width, height) { + #[cfg(not(target_os = "windows"))] + let set_pos_elapsed = + if let Some((x, y)) = calculate_overlay_position(app_handle, width, height) { + let set_pos_started = std::time::Instant::now(); + let _ = overlay_window + .set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); + set_pos_started.elapsed() + } else { + std::time::Duration::ZERO + }; + #[cfg(target_os = "windows")] + let set_pos_elapsed = { let set_pos_started = std::time::Instant::now(); - let _ = overlay_window - .set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); - set_pos_elapsed = set_pos_started.elapsed(); - } + if let Err(error) = place_windows_overlay(app_handle, &overlay_window, width, height) { + log::error!("Failed to place recording overlay: {error}"); + } + set_pos_started.elapsed() + }; let pos_calc_elapsed = pos_started.elapsed() - set_pos_elapsed; let show_started = std::time::Instant::now(); @@ -408,6 +523,13 @@ fn show_overlay_state(app_handle: &AppHandle, state: &str) { #[cfg(target_os = "windows")] force_overlay_topmost(&overlay_window); + // Re-assert bounds after show(): the pre-show move crosses the DPI + // boundary, and tao's WM_DPICHANGED reflow clobbers the first placement. + #[cfg(target_os = "windows")] + if let Err(error) = place_windows_overlay(app_handle, &overlay_window, width, height) { + log::error!("Failed to re-assert recording overlay position: {error}"); + } + let _ = overlay_window.emit("show-overlay", state); log::debug!( "overlay '{}': set_size={:?} pos_calc={:?} set_pos={:?} show={:?}", @@ -442,19 +564,42 @@ pub fn show_processing_overlay(app_handle: &AppHandle) { /// Updates the overlay window position based on current settings pub fn update_overlay_position(app_handle: &AppHandle) { + // Positioning queries monitors/cursor (GDK/Xlib on Linux) and moves the + // window, so it must run on the main thread — see show_overlay_state. + let handle = app_handle.clone(); + let _ = app_handle.run_on_main_thread(move || update_overlay_position_on_main(&handle)); +} + +fn update_overlay_position_on_main(app_handle: &AppHandle) { if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { #[cfg(target_os = "linux")] { update_gtk_layer_shell_anchors(&overlay_window); } - // Use the window's current size so centering stays correct whether the - // overlay is in compact or streaming layout. - let (width, height) = current_overlay_logical_size(&overlay_window) - .unwrap_or((OVERLAY_WIDTH, OVERLAY_HEIGHT)); - if let Some((x, y)) = calculate_overlay_position(app_handle, width, height) { - let _ = overlay_window - .set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); + #[cfg(target_os = "windows")] + { + let state = if WINDOWS_OVERLAY_IS_STREAMING.load(Ordering::Relaxed) { + "streaming" + } else { + "recording" + }; + let (width, height) = overlay_dimensions(state); + if let Err(error) = place_windows_overlay(app_handle, &overlay_window, width, height) { + log::error!("Failed to update recording overlay position: {error}"); + } + } + + #[cfg(not(target_os = "windows"))] + { + // Use the window's current size so centering stays correct whether the + // overlay is in compact or streaming layout. + let (width, height) = current_overlay_logical_size(&overlay_window) + .unwrap_or((OVERLAY_WIDTH, OVERLAY_HEIGHT)); + if let Some((x, y)) = calculate_overlay_position(app_handle, width, height) { + let _ = overlay_window + .set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); + } } } } @@ -526,3 +671,92 @@ pub fn emit_levels(app_handle: &AppHandle, levels: &[f32]) { // dispatch work in half. let _ = app_handle.emit_to("recording_overlay", "mic-level", levels); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn monitor_hit_test_uses_half_open_physical_bounds() { + let position = PhysicalPosition::new(-2560, -200); + let size = PhysicalSize::new(2560, 1440); + + assert!(is_mouse_within_monitor((-2560, -200), &position, &size)); + assert!(is_mouse_within_monitor((-1, 1239), &position, &size)); + assert!(!is_mouse_within_monitor((0, 0), &position, &size)); + assert!(!is_mouse_within_monitor((-1, 1240), &position, &size)); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_cursor_hit_test_does_not_scale_physical_monitor_bounds() { + let position = PhysicalPosition::new(1920, 0); + let size = PhysicalSize::new(3840, 2160); + let cursor = (5000, 1000); + + assert!(is_mouse_within_monitor(cursor, &position, &size)); + + // This is the old mixed-coordinate comparison. It excludes a cursor + // that is visibly inside a secondary display running at 150%. + let scale = 1.5; + let logical_position = PhysicalPosition::new( + (position.x as f64 / scale) as i32, + (position.y as f64 / scale) as i32, + ); + let logical_size = PhysicalSize::new( + (size.width as f64 / scale) as u32, + (size.height as f64 / scale) as u32, + ); + assert!(!is_mouse_within_monitor( + cursor, + &logical_position, + &logical_size + )); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_overlay_bounds_use_destination_monitor_scale() { + let monitor_position = PhysicalPosition::new(1920, 0); + let monitor_size = PhysicalSize::new(3840, 2160); + + assert_eq!( + windows_overlay_bounds( + monitor_position, + monitor_size, + 1.5, + OVERLAY_WIDTH, + OVERLAY_HEIGHT, + OverlayPosition::Bottom, + ), + (3648, 2031, 384, 69) + ); + assert_eq!( + windows_overlay_bounds( + monitor_position, + monitor_size, + 1.5, + OVERLAY_WIDTH, + OVERLAY_HEIGHT, + OverlayPosition::Top, + ), + (3648, 6, 384, 69) + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_overlay_bounds_support_negative_monitor_origins() { + assert_eq!( + windows_overlay_bounds( + PhysicalPosition::new(-2560, -200), + PhysicalSize::new(2560, 1440), + 1.25, + OVERLAY_STREAM_WIDTH, + OVERLAY_STREAM_HEIGHT, + OverlayPosition::Bottom, + ), + (-1530, 1040, 500, 150) + ); + } +} diff --git a/src-tauri/src/paste_tx/macos.rs b/src-tauri/src/paste_tx/macos.rs new file mode 100644 index 0000000000..0c4113b676 --- /dev/null +++ b/src-tauri/src/paste_tx/macos.rs @@ -0,0 +1,336 @@ +//! macOS reliable paste. +//! +//! Publishes the transcript with `declareTypes:owner:`, which puts a *promise* +//! on the general pasteboard instead of data. When any consumer actually +//! requests the text, AppKit calls `pasteboard:provideDataForType:` on our +//! owner object — that callback is the read receipt. The previous clipboard is +//! restored once receipts go quiet (see `paste_tx::evaluate`), guarded by the +//! pasteboard `changeCount` so we never clobber a newer user copy. +//! +//! Threading: publishing and chord injection happen on the calling (main) +//! thread, because promised pasteboard data is serviced by AppKit on the main +//! run loop. The (potentially seconds-long) wait runs on a worker thread; the +//! guarded restore is dispatched back to the main thread. + +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use log::{error, info}; +use objc2::rc::Retained; +use objc2::{define_class, msg_send, AnyThread, DefinedClass}; +use objc2_app_kit::{NSPasteboard, NSPasteboardTypeString}; +use objc2_foundation::{NSArray, NSInteger, NSObject, NSString}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_clipboard_manager::ClipboardExt; + +use super::{evaluate, send_chord, TxState, WaitDecision}; +use crate::clipboard::send_return_key; +use crate::input::EnigoState; +use crate::settings::{AutoSubmitKey, ClipboardHandling, PasteMethod}; + +/// Concealment marker types declared alongside the text so that well-behaved +/// third-party clipboard managers (Maccy, Paste, ...) skip the transcript. +/// Fewer eager readers means a receipt is more likely to be the actual target. +/// These are conventions; nothing enforces them. +const CONCEALMENT_TYPES: [&str; 3] = [ + "org.nspasteboard.TransientType", + "org.nspasteboard.ConcealedType", + "org.nspasteboard.AutoGeneratedType", +]; + +pub struct ProviderIvars { + state: Arc>, + text: String, +} + +define_class!( + // SAFETY: NSObject has no subclassing requirements and the ivars are + // plain Rust values guarded by a Mutex. + #[unsafe(super(NSObject))] + #[name = "HandyPasteProvider"] + #[ivars = ProviderIvars] + pub struct HandyPasteProvider; + + impl HandyPasteProvider { + // NSPasteboardOwner informal protocol: the pasteboard is asking for the + // promised data — our receipt that a consumer read the clipboard. + #[unsafe(method(pasteboard:provideDataForType:))] + fn pasteboard_provide_data_for_type(&self, pasteboard: &NSPasteboard, data_type: &NSString) { + let ivars = self.ivars(); + // SAFETY: NSPasteboardTypeString is a read-only framework static. + let is_text = unsafe { data_type.isEqualToString(NSPasteboardTypeString) }; + // Only a text read counts as a receipt: a request for one of the + // concealment marker types is a clipboard manager inspecting the + // markers, not the paste target reading the transcript. + if is_text { + if let Ok(mut state) = ivars.state.lock() { + state.record_receipt(Instant::now()); + } + } + let payload = if is_text { + NSString::from_str(&ivars.text) + } else { + // Concealment marker types are never handed out. + NSString::from_str("") + }; + let _ = pasteboard.setString_forType(&payload, data_type); + } + + #[unsafe(method(pasteboardChangedOwner:))] + fn pasteboard_changed_owner(&self, _pasteboard: &NSPasteboard) { + if let Ok(mut state) = self.ivars().state.lock() { + state.ownership_lost = true; + } + } + } +); + +impl HandyPasteProvider { + fn new(state: Arc>, text: String) -> Retained { + let this = Self::alloc().set_ivars(ProviderIvars { state, text }); + unsafe { msg_send![super(this), init] } + } +} + +struct MacPending { + state: Arc>, + saved_text: Option, + saved_image: Option>, + change_count: NSInteger, + provider: Option>, + auto_submit: bool, + auto_submit_key: AutoSubmitKey, + /// ClipboardHandling::CopyToClipboard — instead of restoring, settle by + /// re-writing the transcript as plain text (without the concealment + /// markers), so clipboard managers record it and it outlives the promise. + preserve_transcript: bool, + /// The transcript, for the `preserve_transcript` re-write at settle time. + transcript: String, + settled: bool, +} + +/// The transaction currently holding the clipboard, if any. A new paste +/// settles it before snapshotting (see `flush_pending`). +static PENDING: Mutex>>> = Mutex::new(None); + +/// Settles a transaction exactly once: sends the owed auto-submit Enter and +/// restores the previous clipboard, guarded so we never clobber a newer copy. +/// Must run on the main thread. Uses the already-locked enigo when called +/// from within `paste` (which holds the lock), otherwise locks it itself. +fn settle( + pending: &Arc>, + app_handle: &AppHandle, + enigo: Option<&mut enigo::Enigo>, +) { + let mut p = match pending.lock() { + Ok(p) => p, + Err(_) => return, + }; + if p.settled { + return; + } + p.settled = true; + + let (receipt_seen, ownership_lost) = match p.state.lock() { + Ok(st) => (st.any_receipt_after_injection(), st.ownership_lost), + Err(_) => (false, true), + }; + + // Auto-submit only once the target demonstrably read the transcript; + // pressing Enter after an unconfirmed paste could submit stale content. + if p.auto_submit && receipt_seen { + match enigo { + Some(e) => { + let _ = send_return_key(e, p.auto_submit_key); + } + None => { + if let Some(enigo_state) = app_handle.try_state::() { + if let Ok(mut e) = enigo_state.0.lock() { + let _ = send_return_key(&mut e, p.auto_submit_key); + } + } + } + } + } + + let still_ours = + !ownership_lost && NSPasteboard::generalPasteboard().changeCount() == p.change_count; + if !still_ours { + info!("[reliable-paste] clipboard changed externally; leaving it untouched"); + } else if p.preserve_transcript { + // The user asked for the transcript to stay on the clipboard: replace + // the concealed promise with plain text so clipboard managers record + // it and it survives this app exiting. + let _ = app_handle.clipboard().write_text(&p.transcript); + info!("[reliable-paste] left transcript on clipboard as plain text"); + } else { + let clipboard = app_handle.clipboard(); + if let Some(text) = &p.saved_text { + let _ = clipboard.write_text(text); + } else if let Some(image) = &p.saved_image { + let _ = clipboard.write_image(image); + } else { + let _ = clipboard.clear(); + } + info!("[reliable-paste] restored previous clipboard"); + } + + // Release the owner; any outstanding promise dies with the pasteboard + // contents we just replaced (or with the external change). + p.provider = None; +} + +/// If a previous transaction is still holding the clipboard, settle it now so +/// the caller's snapshot captures the user's original clipboard content. +fn flush_pending(app_handle: &AppHandle, enigo: &mut enigo::Enigo) { + let previous = match PENDING.lock() { + Ok(mut slot) => slot.take(), + Err(_) => None, + }; + if let Some(previous) = previous { + settle(&previous, app_handle, Some(enigo)); + } +} + +fn spawn_waiter(pending: Arc>, app_handle: AppHandle) { + thread::spawn(move || { + let outcome = loop { + thread::sleep(Duration::from_millis(15)); + let (decision, state_snapshot) = { + let p = match pending.lock() { + Ok(p) => p, + Err(_) => return, + }; + if p.settled { + // A newer paste settled this transaction already. + return; + } + let result = match p.state.lock() { + Ok(st) => { + let now = Instant::now(); + let snapshot = ( + st.any_receipt_after_injection(), + st.ownership_lost, + st.injection_failed, + ); + (evaluate(&st, now), snapshot) + } + Err(_) => return, + }; + result + }; + if let WaitDecision::Finish = decision { + break state_snapshot; + } + }; + + let (receipt_seen, ownership_lost, injection_failed) = outcome; + if ownership_lost { + info!("[reliable-paste] settling: clipboard ownership lost"); + } else if receipt_seen { + info!("[reliable-paste] settling: reads went quiet"); + } else if injection_failed { + info!("[reliable-paste] settling: chord injection failed, restoring quickly"); + } else { + info!("[reliable-paste] settling: no read within timeout, restoring anyway"); + } + + let pending_for_finish = pending.clone(); + let app_for_finish = app_handle.clone(); + let _ = app_handle.run_on_main_thread(move || { + settle(&pending_for_finish, &app_for_finish, None); + if let Ok(mut slot) = PENDING.lock() { + let is_us = slot + .as_ref() + .map(|current| Arc::ptr_eq(current, &pending)) + .unwrap_or(false); + if is_us { + *slot = None; + } + } + }); + }); +} + +pub(super) fn run( + text: &str, + app_handle: &AppHandle, + paste_method: &PasteMethod, + enigo: &mut enigo::Enigo, + auto_submit: bool, + auto_submit_key: AutoSubmitKey, + clipboard_handling: ClipboardHandling, +) -> Result<(), String> { + // Settle any previous transaction first so the snapshot below captures the + // user's original clipboard, not the previous transcript. + flush_pending(app_handle, enigo); + + let clipboard = app_handle.clipboard(); + let saved_text = clipboard.read_text().ok().filter(|t| !t.is_empty()); + // Only probe for an image when there is no text; reading an image decodes + // the full bitmap (mirrors the legacy path). + let saved_image = if saved_text.is_none() { + clipboard.read_image().ok().map(|image| image.to_owned()) + } else { + None + }; + + let state = Arc::new(Mutex::new(TxState::new())); + let provider = HandyPasteProvider::new(state.clone(), text.to_string()); + let pasteboard = NSPasteboard::generalPasteboard(); + + let mut types: Vec> = Vec::with_capacity(1 + CONCEALMENT_TYPES.len()); + types.push(NSString::from_str("public.utf8-plain-text")); + for t in CONCEALMENT_TYPES { + types.push(NSString::from_str(t)); + } + let types = NSArray::from_retained_slice(&types); + + // declareTypes:owner: clears the pasteboard and puts our promise on it; + // the return value is the new changeCount. + let change_count: NSInteger = + unsafe { msg_send![&*pasteboard, declareTypes: &*types, owner: &*provider] }; + if change_count <= 0 { + return Err("declareTypes:owner: failed".to_string()); + } + info!("[reliable-paste] published transcript as lazy promise (changeCount {change_count})"); + + // Mark injection *before* sending: enigo holds the chord for ~100ms and a + // fast target may legitimately read while the chord is still held. + if let Ok(mut st) = state.lock() { + st.injected_at = Some(Instant::now()); + } + match send_chord(enigo, paste_method) { + Ok(()) => { + info!("[reliable-paste] paste chord sent ({paste_method:?})"); + } + Err(e) => { + // Keep the transaction alive: the waiter restores the clipboard + // after the short failed-injection timeout. + if let Ok(mut st) = state.lock() { + st.injection_failed = true; + } + error!("[reliable-paste] failed to send paste chord: {e}"); + } + } + + let pending = Arc::new(Mutex::new(MacPending { + state, + saved_text, + saved_image, + change_count, + provider: Some(provider), + auto_submit, + auto_submit_key, + preserve_transcript: clipboard_handling == ClipboardHandling::CopyToClipboard, + transcript: text.to_string(), + settled: false, + })); + if let Ok(mut slot) = PENDING.lock() { + *slot = Some(pending.clone()); + } + spawn_waiter(pending, app_handle.clone()); + + Ok(()) +} diff --git a/src-tauri/src/paste_tx/mod.rs b/src-tauri/src/paste_tx/mod.rs new file mode 100644 index 0000000000..71fdc5524e --- /dev/null +++ b/src-tauri/src/paste_tx/mod.rs @@ -0,0 +1,283 @@ +//! Receipt-sequenced clipboard paste ("reliable paste", debug-gated). +//! +//! The legacy clipboard paste (`clipboard::paste_via_clipboard`) restores the +//! previous clipboard after a fixed delay. The paste keystroke is only +//! *enqueued* at that point — the target application reads the clipboard +//! whenever its event loop gets to it, so any fixed delay can lose the race +//! and the user gets their old clipboard pasted back (#502). +//! +//! This module instead publishes the transcript as a *lazy promise* and waits +//! for the operating system to tell us that a consumer actually read the +//! clipboard — a "receipt" — before restoring: +//! +//! - Windows: delayed rendering (`SetClipboardData(CF_UNICODETEXT, NULL)`), +//! the owner window receives `WM_RENDERFORMAT` on read. +//! - macOS: `declareTypes:owner:` with an owner object, the pasteboard calls +//! `pasteboard:provideDataForType:` on read. +//! +//! Two rules make the receipt trustworthy: +//! +//! 1. Only receipts observed *after* the paste chord was injected count. A +//! read before that is an eager third party (clipboard manager, antivirus) +//! reacting to the clipboard change itself. +//! 2. Restoration only happens while we still own the clipboard +//! (sequence number / changeCount unchanged, no ownership-lost event). If +//! the user copied something else in the meantime, their action wins. +//! +//! The restore is additionally gated on a short quiet period after the *last* +//! receipt, because some applications read the clipboard several times per +//! paste (Chromium probes, then reads). A bounded timeout caps how long the +//! transcript may occupy the clipboard; the failure mode is always "the +//! transcript stays on the clipboard a bit longer", never "stale content gets +//! pasted". + +// The shared transaction state is compiled on all platforms (for the unit +// tests), but only the macOS/Windows platform modules consume all of it. +#![cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] + +use std::time::{Duration, Instant}; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(target_os = "macos")] +use macos as platform; +#[cfg(target_os = "windows")] +use windows as platform; + +/// How long after the *last* observed read the transcript stays on the +/// clipboard before restoring. Covers applications that read the clipboard +/// several times per paste (e.g. Chromium probe-then-read). +pub(crate) const QUIET_PERIOD: Duration = Duration::from_millis(200); + +/// Upper bound on how long the transcript may occupy the clipboard before we +/// restore regardless of receipts. Long enough that a realistically loaded +/// target always gets to read first; short enough that a lost keystroke does +/// not strand the transcript on the clipboard for long. +pub(crate) const RESTORE_TIMEOUT: Duration = Duration::from_secs(8); + +/// When the chord could not be injected at all, no legitimate receipt can +/// arrive, so restore quickly instead of waiting out the full timeout. +pub(crate) const FAILED_INJECTION_TIMEOUT: Duration = Duration::from_millis(500); + +/// Shared, cross-thread record of one paste transaction. +#[derive(Debug)] +pub(crate) struct TxState { + /// When the transcript was published to the clipboard. + pub published_at: Instant, + /// When the paste chord was injected. Only receipts *after* this count as + /// evidence the target read the transcript — earlier reads are eager third + /// parties reacting to the clipboard change itself. + pub injected_at: Option, + /// The chord could not be sent; short-circuit the wait. + pub injection_failed: bool, + /// Times at which a consumer requested the clipboard data. + pub receipts: Vec, + /// Someone else took clipboard ownership (user copied elsewhere, ...). + pub ownership_lost: bool, + /// A newer paste transaction settled this one early (see flush logic in + /// the platform modules). + pub cancelled: bool, + /// The post-paste Enter (auto-submit) has been sent for this transaction. + /// (Read on Windows; the macOS path settles via `MacPending::settled`.) + #[allow(dead_code)] + pub auto_submit_sent: bool, + /// First post-injection receipt has been logged. + pub logged_receipt: bool, +} + +impl TxState { + pub fn new() -> Self { + Self { + published_at: Instant::now(), + injected_at: None, + injection_failed: false, + receipts: Vec::new(), + ownership_lost: false, + cancelled: false, + auto_submit_sent: false, + logged_receipt: false, + } + } + + /// Records a read receipt, logging the first one that counts as evidence. + pub fn record_receipt(&mut self, at: Instant) { + self.receipts.push(at); + if !self.logged_receipt { + if let Some(injected) = self.injected_at { + if at >= injected { + self.logged_receipt = true; + log::info!( + "[reliable-paste] clipboard read {}ms after chord", + at.duration_since(injected).as_millis() + ); + } + } + } + } + + pub fn last_receipt_after_injection(&self) -> Option { + let injected = self.injected_at?; + self.receipts.iter().copied().rev().find(|t| *t >= injected) + } + + pub fn any_receipt_after_injection(&self) -> bool { + self.last_receipt_after_injection().is_some() + } +} + +pub(crate) enum WaitDecision { + KeepWaiting, + /// Stop waiting; settle the transaction (auto-submit + guarded restore). + Finish, +} + +/// Pure decision: given the current transaction state, keep waiting for the +/// target to read, or finish now. Both platform event loops call this. +pub(crate) fn evaluate(state: &TxState, now: Instant) -> WaitDecision { + if state.ownership_lost || state.cancelled { + return WaitDecision::Finish; + } + if let Some(last) = state.last_receipt_after_injection() { + if now.duration_since(last) >= QUIET_PERIOD { + return WaitDecision::Finish; + } + } + let deadline = if state.injection_failed { + FAILED_INJECTION_TIMEOUT + } else { + RESTORE_TIMEOUT + }; + if now.duration_since(state.published_at) >= deadline { + return WaitDecision::Finish; + } + WaitDecision::KeepWaiting +} + +/// Modifier hold for the receipt-sequenced chord, kept at parity with the +/// legacy path (100ms) for the beta: that hold was added in #165 because real +/// users' systems dropped chords released too quickly, and the beta should +/// validate the receipt mechanism without changing a second variable. +/// +/// Once receipts are proven in the field this becomes a safe tuning knob — a +/// chord the target never recognizes produces no receipt and is logged ("no +/// read within timeout") rather than failing silently, so a shorter hold +/// (measured working at 10ms on a fast machine, cutting visible latency from +/// ~110ms to ~20ms) can be tried as its own experiment later. +const CHORD_HOLD_MS: u64 = 100; + +/// Sends the platform paste chord for the configured method. +pub(crate) fn send_chord( + enigo: &mut enigo::Enigo, + paste_method: &crate::settings::PasteMethod, +) -> Result<(), String> { + use crate::settings::PasteMethod; + match paste_method { + PasteMethod::CtrlV => crate::input::send_paste_ctrl_v(enigo, CHORD_HOLD_MS), + PasteMethod::CtrlShiftV => crate::input::send_paste_ctrl_shift_v(enigo, CHORD_HOLD_MS), + PasteMethod::ShiftInsert => crate::input::send_paste_shift_insert(enigo, CHORD_HOLD_MS), + other => Err(format!( + "Invalid paste method for clipboard paste: {:?}", + other + )), + } +} + +/// Attempts the receipt-sequenced paste. Returns `Err` before anything has +/// been published when the platform transaction cannot start, in which case +/// the caller should fall back to the legacy paste path. On `Ok`, publishing +/// and chord injection have completed and the guarded restore (plus +/// auto-submit) finishes asynchronously. +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub(crate) fn try_reliable_paste( + text: &str, + app_handle: &tauri::AppHandle, + paste_method: &crate::settings::PasteMethod, + enigo: &mut enigo::Enigo, + auto_submit: bool, + auto_submit_key: crate::settings::AutoSubmitKey, + clipboard_handling: crate::settings::ClipboardHandling, +) -> Result<(), String> { + platform::run( + text, + app_handle, + paste_method, + enigo, + auto_submit, + auto_submit_key, + clipboard_handling, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state_after_publish(published_ago: Duration) -> TxState { + let mut s = TxState::new(); + s.published_at = Instant::now() - published_ago; + s + } + + #[test] + fn keeps_waiting_without_receipt_within_timeout() { + let s = state_after_publish(Duration::from_millis(100)); + assert!(matches!( + evaluate(&s, Instant::now()), + WaitDecision::KeepWaiting + )); + } + + #[test] + fn finishes_after_quiet_period_once_read() { + let mut s = state_after_publish(Duration::from_millis(300)); + s.injected_at = Some(Instant::now() - Duration::from_millis(250)); + s.receipts.push(Instant::now() - QUIET_PERIOD); + assert!(matches!(evaluate(&s, Instant::now()), WaitDecision::Finish)); + } + + #[test] + fn waits_through_quiet_period_after_recent_read() { + let mut s = state_after_publish(Duration::from_millis(300)); + s.injected_at = Some(Instant::now() - Duration::from_millis(100)); + s.receipts.push(Instant::now() - Duration::from_millis(50)); + assert!(matches!( + evaluate(&s, Instant::now()), + WaitDecision::KeepWaiting + )); + } + + #[test] + fn pre_injection_receipt_does_not_count() { + let mut s = state_after_publish(Duration::from_millis(300)); + s.receipts.push(Instant::now() - Duration::from_millis(200)); + s.injected_at = Some(Instant::now() - Duration::from_millis(100)); + assert!(!s.any_receipt_after_injection()); + assert!(matches!( + evaluate(&s, Instant::now()), + WaitDecision::KeepWaiting + )); + } + + #[test] + fn finishes_on_timeout_without_receipt() { + let s = state_after_publish(RESTORE_TIMEOUT); + assert!(matches!(evaluate(&s, Instant::now()), WaitDecision::Finish)); + } + + #[test] + fn failed_injection_uses_short_timeout() { + let mut s = state_after_publish(FAILED_INJECTION_TIMEOUT); + s.injection_failed = true; + assert!(matches!(evaluate(&s, Instant::now()), WaitDecision::Finish)); + } + + #[test] + fn ownership_loss_finishes_immediately() { + let mut s = state_after_publish(Duration::from_millis(10)); + s.ownership_lost = true; + assert!(matches!(evaluate(&s, Instant::now()), WaitDecision::Finish)); + } +} diff --git a/src-tauri/src/paste_tx/windows.rs b/src-tauri/src/paste_tx/windows.rs new file mode 100644 index 0000000000..55f13bf520 --- /dev/null +++ b/src-tauri/src/paste_tx/windows.rs @@ -0,0 +1,624 @@ +//! Windows reliable paste. +//! +//! Publishes the transcript as a *delayed-render* clipboard format +//! (`SetClipboardData(CF_UNICODETEXT, NULL)`) owned by a hidden message-only +//! window. Windows sends the owner `WM_RENDERFORMAT` when a consumer actually +//! requests the data — that message is the read receipt. The previous +//! clipboard contents (snapshotted with full format fidelity) are restored +//! once receipts go quiet (see `paste_tx::evaluate`), guarded by the clipboard +//! sequence number so we never clobber a newer user copy. +//! +//! Threading: clipboard ownership and delayed rendering are per-thread and +//! need a message pump, so the whole transaction lives on a dedicated worker +//! thread. The calling thread only sends the paste chord once the worker +//! signals the transcript is published, then returns; the wait, guarded +//! restore and auto-submit all finish on the worker. + +use std::sync::{mpsc::Sender, Arc, Mutex, Once}; +use std::thread; +use std::time::Instant; + +use log::{error, info, warn}; +use tauri::Manager; +use windows::core::{w, PCWSTR}; +use windows::Win32::Foundation::{ + SetLastError, ERROR_SUCCESS, HANDLE, HGLOBAL, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM, +}; + +use super::{evaluate, send_chord, TxState, WaitDecision}; +use crate::clipboard::send_return_key; +use crate::input::EnigoState; +use crate::settings::{AutoSubmitKey, ClipboardHandling, PasteMethod}; +use windows::Win32::Foundation::GlobalFree; +use windows::Win32::System::DataExchange::{ + CloseClipboard, EmptyClipboard, EnumClipboardFormats, GetClipboardData, GetClipboardOwner, + GetClipboardSequenceNumber, OpenClipboard, RegisterClipboardFormatW, SetClipboardData, +}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::System::Memory::{ + GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, +}; +use windows::Win32::System::Ole::{ + CF_BITMAP, CF_DSPBITMAP, CF_DSPENHMETAFILE, CF_DSPMETAFILEPICT, CF_DSPTEXT, CF_ENHMETAFILE, + CF_OWNERDISPLAY, CF_PALETTE, CF_UNICODETEXT, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + CopyImage, CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW, + GetWindowLongPtrW, KillTimer, PostQuitMessage, RegisterClassW, SetTimer, SetWindowLongPtrW, + GDI_IMAGE_TYPE, GWLP_USERDATA, HWND_MESSAGE, IMAGE_FLAGS, MSG, WINDOW_EX_STYLE, WINDOW_STYLE, + WM_DESTROYCLIPBOARD, WM_RENDERALLFORMATS, WM_RENDERFORMAT, WM_TIMER, WNDCLASSW, +}; + +const CLASS_NAME: PCWSTR = w!("HandyPasteTxWindow"); +const TIMER_ID: usize = 1; +const TIMER_INTERVAL_MS: u32 = 25; +/// Skip clipboard formats larger than this when snapshotting. +const MAX_FORMAT_BYTES: usize = 64 * 1024 * 1024; + +const IMAGE_BITMAP_TYPE: GDI_IMAGE_TYPE = GDI_IMAGE_TYPE(0); +const LR_CREATEDIBSECTION_FLAG: IMAGE_FLAGS = IMAGE_FLAGS(0x2000); + +struct SavedFormat { + format: u32, + data: Vec, +} + +pub(super) struct WinTxShared { + state: Mutex, + text: String, + snapshot: Mutex>, + /// Copied HBITMAP (as raw usize), restored via SetClipboardData. + saved_bitmap: Mutex>, + sequence: Mutex, + app_handle: tauri::AppHandle, + auto_submit: bool, + auto_submit_key: AutoSubmitKey, + /// ClipboardHandling::CopyToClipboard — settle by leaving the transcript + /// on the clipboard as plain text instead of restoring the snapshot. + preserve_transcript: bool, +} + +/// The transaction currently holding the clipboard, if any. A new +/// transaction settles it before snapshotting (see `flush_pending`). +static PENDING: Mutex>> = Mutex::new(None); + +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +unsafe fn shared_ptr(hwnd: HWND) -> *const WinTxShared { + GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *const WinTxShared +} + +/// Sends the auto-submit Enter. Uses `try_lock` because the paste caller may +/// currently hold the enigo lock while waiting for this worker. +fn send_auto_submit(shared: &WinTxShared) { + { + let mut st = match shared.state.lock() { + Ok(st) => st, + Err(_) => return, + }; + if st.auto_submit_sent { + return; + } + st.auto_submit_sent = true; + } + if let Some(enigo_state) = shared.app_handle.try_state::() { + match enigo_state.0.try_lock() { + Ok(mut enigo) => { + let _ = send_return_key(&mut enigo, shared.auto_submit_key); + } + Err(_) => warn!("[reliable-paste] skipping auto-submit: input state busy"), + } + } +} + +/// Renders the promised transcript into the clipboard, which must already be +/// open: the system opens it on our behalf for WM_RENDERFORMAT; every other +/// caller has to wrap this in OpenClipboard/CloseClipboard itself. +unsafe fn render_text(shared: &WinTxShared) { + let wide_text: Vec = shared + .text + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let Ok(hg) = GlobalAlloc(GMEM_MOVEABLE, wide_text.len() * 2) else { + return; + }; + let ptr = GlobalLock(hg) as *mut u16; + if ptr.is_null() { + let _ = GlobalFree(Some(hg)); + return; + } + std::ptr::copy_nonoverlapping(wide_text.as_ptr(), ptr, wide_text.len()); + let _ = GlobalUnlock(hg); + if SetClipboardData(CF_UNICODETEXT.0 as u32, Some(HANDLE(hg.0))).is_err() { + let _ = GlobalFree(Some(hg)); + } +} + +unsafe extern "system" fn paste_wnd_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + let shared = shared_ptr(hwnd); + match msg { + WM_RENDERFORMAT => { + if !shared.is_null() { + let shared = &*shared; + if let Ok(mut st) = shared.state.lock() { + st.record_receipt(Instant::now()); + } + if wparam.0 as u32 == CF_UNICODETEXT.0 as u32 { + render_text(shared); + } + } + LRESULT(0) + } + WM_RENDERALLFORMATS => { + // Sent when the window is destroyed while an unrendered promise is + // still on the clipboard — not a consumer read, so no receipt. + // Unlike WM_RENDERFORMAT the system does not open the clipboard on + // our behalf here: open it and confirm we still own it first. + if !shared.is_null() { + let shared = &*shared; + if OpenClipboard(Some(hwnd)).is_ok() { + if GetClipboardOwner() + .map(|owner| owner == hwnd) + .unwrap_or(false) + { + render_text(shared); + } + let _ = CloseClipboard(); + } + } + LRESULT(0) + } + WM_DESTROYCLIPBOARD => { + if !shared.is_null() { + if let Ok(mut st) = (&*shared).state.lock() { + st.ownership_lost = true; + } + } + LRESULT(0) + } + WM_TIMER => { + if !shared.is_null() { + on_timer(hwnd, &*shared); + } + LRESULT(0) + } + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } +} + +fn ensure_window_class(hinstance: HINSTANCE) { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let wc = WNDCLASSW { + lpfnWndProc: Some(paste_wnd_proc), + hInstance: hinstance, + lpszClassName: CLASS_NAME, + ..Default::default() + }; + unsafe { + RegisterClassW(&wc); + } + }); +} + +/// If a previous transaction is still holding the clipboard, settle it now so +/// the snapshot below captures the user's original clipboard content. The +/// previous worker observes `cancelled` on its next timer tick and tears down +/// without restoring. +fn flush_pending() { + let previous = match PENDING.lock() { + Ok(mut slot) => slot.take(), + Err(_) => None, + }; + let Some(previous) = previous else { + return; + }; + let receipt = { + let mut st = match previous.state.lock() { + Ok(st) => st, + Err(_) => return, + }; + st.cancelled = true; + st.any_receipt_after_injection() + }; + if previous.auto_submit && receipt { + send_auto_submit(&previous); + } + let sequence = *previous.sequence.lock().unwrap(); + let still_ours = unsafe { GetClipboardSequenceNumber() } == sequence; + if still_ours { + unsafe { settle_clipboard(&previous) }; + } +} + +/// Settle-time clipboard handling once we know we still own the clipboard: +/// restore the snapshot, or — for ClipboardHandling::CopyToClipboard — replace +/// the concealed promise with plain transcript text, so clipboard history and +/// managers record it and it survives this transaction's window going away. +unsafe fn settle_clipboard(shared: &WinTxShared) { + if !shared.preserve_transcript { + restore_snapshot(shared); + return; + } + if OpenClipboard(None).is_err() { + warn!("[reliable-paste] could not open clipboard to leave transcript"); + return; + } + let _ = EmptyClipboard(); + render_text(shared); + let _ = CloseClipboard(); + info!("[reliable-paste] left transcript on clipboard as plain text"); +} + +/// Restores the snapshotted clipboard contents. Safe to call from any thread. +unsafe fn restore_snapshot(shared: &WinTxShared) { + if OpenClipboard(None).is_err() { + warn!("[reliable-paste] could not open clipboard to restore"); + return; + } + let _ = EmptyClipboard(); + if let Ok(formats) = shared.snapshot.lock() { + for saved in formats.iter() { + if saved.data.is_empty() { + continue; + } + let Ok(hg) = GlobalAlloc(GMEM_MOVEABLE, saved.data.len()) else { + continue; + }; + let ptr = GlobalLock(hg) as *mut u8; + if ptr.is_null() { + let _ = GlobalFree(Some(hg)); + continue; + } + std::ptr::copy_nonoverlapping(saved.data.as_ptr(), ptr, saved.data.len()); + let _ = GlobalUnlock(hg); + // SetClipboardData takes ownership of the handle on success. + if SetClipboardData(saved.format, Some(HANDLE(hg.0))).is_err() { + let _ = GlobalFree(Some(hg)); + } + } + } + if let Ok(mut bitmap) = shared.saved_bitmap.lock() { + if let Some(raw) = bitmap.take() { + let _ = SetClipboardData(CF_BITMAP.0 as u32, Some(HANDLE(raw as *mut _))); + } + } + let _ = CloseClipboard(); + info!("[reliable-paste] restored previous clipboard"); +} + +unsafe fn snapshot_clipboard(hwnd: HWND, shared: &WinTxShared) -> Result<(), String> { + OpenClipboard(Some(hwnd)).map_err(|e| format!("OpenClipboard failed: {e}"))?; + let mut formats = Vec::new(); + let mut format = 0u32; + loop { + format = EnumClipboardFormats(format); + if format == 0 { + break; + } + if format == CF_BITMAP.0 as u32 { + // GDI object, not global memory: duplicate the handle instead. + if let Ok(handle) = GetClipboardData(CF_BITMAP.0 as u32) { + if let Ok(copy) = + CopyImage(handle, IMAGE_BITMAP_TYPE, 0, 0, LR_CREATEDIBSECTION_FLAG) + { + if let Ok(mut slot) = shared.saved_bitmap.lock() { + *slot = Some(copy.0 as usize); + } + } + } + continue; + } + // Formats whose handles are not plain global memory cannot be + // byte-copied; skipping them matches what the legacy path restored. + if format == CF_ENHMETAFILE.0 as u32 + || format == CF_DSPENHMETAFILE.0 as u32 + || format == CF_DSPBITMAP.0 as u32 + || format == CF_DSPMETAFILEPICT.0 as u32 + || format == CF_DSPTEXT.0 as u32 + || format == CF_OWNERDISPLAY.0 as u32 + || format == CF_PALETTE.0 as u32 + { + continue; + } + if let Ok(handle) = GetClipboardData(format) { + let hg = HGLOBAL(handle.0); + let size = GlobalSize(hg); + if size == 0 || size > MAX_FORMAT_BYTES { + continue; + } + let ptr = GlobalLock(hg) as *const u8; + if ptr.is_null() { + continue; + } + let data = std::slice::from_raw_parts(ptr, size).to_vec(); + let _ = GlobalUnlock(hg); + formats.push(SavedFormat { format, data }); + } + } + let _ = CloseClipboard(); + if let Ok(mut slot) = shared.snapshot.lock() { + *slot = formats; + } + Ok(()) +} + +/// Publishes the transcript as a delayed-render promise plus clipboard +/// history / cloud / monitoring opt-out markers (the same formats Chrome uses +/// for Incognito copies). Returns the new clipboard sequence number. +unsafe fn publish(hwnd: HWND) -> Result { + OpenClipboard(Some(hwnd)).map_err(|e| format!("OpenClipboard failed: {e}"))?; + let published = publish_formats(); + let closed = CloseClipboard(); + published?; + closed.map_err(|e| format!("CloseClipboard failed: {e}"))?; + Ok(GetClipboardSequenceNumber()) +} + +/// Everything `publish` does while the clipboard is open, split out so +/// `publish` closes the clipboard on every path — bailing out while holding it +/// open (and possibly already emptied) would strand the clipboard and leave +/// the legacy fallback snapshotting nothing. +unsafe fn publish_formats() -> Result<(), String> { + EmptyClipboard().map_err(|e| format!("EmptyClipboard failed: {e}"))?; + + for (name, value) in [ + ("ExcludeClipboardContentFromMonitorProcessing", 1u32), + ("CanIncludeInClipboardHistory", 0u32), + ("CanUploadToCloudClipboard", 0u32), + ] { + let name_wide = wide(name); + let format = RegisterClipboardFormatW(PCWSTR(name_wide.as_ptr())); + if format == 0 { + continue; + } + if let Ok(hg) = GlobalAlloc(GMEM_MOVEABLE, std::mem::size_of::()) { + let ptr = GlobalLock(hg) as *mut u32; + if !ptr.is_null() { + *ptr = value; + let _ = GlobalUnlock(hg); + if SetClipboardData(format, Some(HANDLE(hg.0))).is_err() { + let _ = GlobalFree(Some(hg)); + } + } else { + let _ = GlobalFree(Some(hg)); + } + } + } + + // NULL handle = delayed rendering: we are only asked for the data (via + // WM_RENDERFORMAT) when a consumer actually reads it. SetClipboardData + // returns the handle it was given, so for delayed rendering success is + // also NULL and the windows crate reports it as an Err carrying + // GetLastError(). Only a nonzero thread error is a real failure, and the + // thread error must be cleared first so a stale value from an earlier + // call can't masquerade as one. + SetLastError(ERROR_SUCCESS); + if let Err(e) = SetClipboardData(CF_UNICODETEXT.0 as u32, None) { + if e.code().is_err() { + return Err(format!("SetClipboardData failed: {e}")); + } + } + Ok(()) +} + +fn on_timer(_hwnd: HWND, shared: &WinTxShared) { + let now = Instant::now(); + let finish = { + let mut st = match shared.state.lock() { + Ok(st) => st, + Err(_) => return, + }; + if st.cancelled { + true + } else { + match evaluate(&st, now) { + WaitDecision::KeepWaiting => false, + WaitDecision::Finish => { + st.cancelled = true; + true + } + } + } + }; + if !finish { + return; + } + + let (receipt, ownership_lost, injection_failed) = { + let st = match shared.state.lock() { + Ok(st) => st, + Err(_) => return, + }; + ( + st.any_receipt_after_injection(), + st.ownership_lost, + st.injection_failed, + ) + }; + if ownership_lost { + info!("[reliable-paste] settling: clipboard ownership lost"); + } else if receipt { + info!("[reliable-paste] settling: reads went quiet"); + } else if injection_failed { + info!("[reliable-paste] settling: chord injection failed, restoring quickly"); + } else { + info!("[reliable-paste] settling: no read within timeout, restoring anyway"); + } + + // Auto-submit only once the target demonstrably read the transcript; + // pressing Enter after an unconfirmed paste could submit stale content. + if shared.auto_submit && receipt { + send_auto_submit(shared); + } + + let sequence = *shared.sequence.lock().unwrap(); + let still_ours = !ownership_lost && unsafe { GetClipboardSequenceNumber() } == sequence; + if still_ours { + unsafe { settle_clipboard(shared) }; + } else { + info!("[reliable-paste] clipboard changed externally; leaving it untouched"); + } + + if let Ok(mut slot) = PENDING.lock() { + let is_us = slot + .as_ref() + .map(|pending| Arc::as_ptr(pending) as *const WinTxShared == shared as *const _) + .unwrap_or(false); + if is_us { + *slot = None; + } + } + + unsafe { + PostQuitMessage(0); + } +} + +unsafe fn destroy_window_and_shared(hwnd: HWND) { + let ptr = shared_ptr(hwnd); + let _ = DestroyWindow(hwnd); + if !ptr.is_null() { + drop(Arc::from_raw(ptr)); + } +} + +fn pump_thread(shared: Arc, ready: Sender>) { + unsafe { + // Settle any previous transaction first so the snapshot captures the + // user's original clipboard, not the previous transcript. + flush_pending(); + + let hinstance = match GetModuleHandleW(PCWSTR::null()) { + Ok(hmodule) => HINSTANCE(hmodule.0), + Err(e) => { + let _ = ready.send(Err(format!("GetModuleHandle failed: {e}"))); + return; + } + }; + ensure_window_class(hinstance); + + let hwnd = match CreateWindowExW( + WINDOW_EX_STYLE::default(), + CLASS_NAME, + w!("HandyPasteTx"), + WINDOW_STYLE::default(), + 0, + 0, + 0, + 0, + Some(HWND_MESSAGE), + None, + Some(hinstance), + None, + ) { + Ok(hwnd) => hwnd, + Err(e) => { + let _ = ready.send(Err(format!("CreateWindowEx failed: {e}"))); + return; + } + }; + SetWindowLongPtrW( + hwnd, + GWLP_USERDATA, + Arc::into_raw(shared.clone()) as *const _ as isize, + ); + + let published = match snapshot_clipboard(hwnd, &shared) { + Ok(()) => match publish(hwnd) { + Ok(sequence) => Ok(sequence), + Err(e) => { + // publish may have emptied the clipboard before failing; + // put the snapshot back so the legacy fallback's own + // snapshot captures the user's clipboard, not an empty one. + restore_snapshot(&shared); + Err(e) + } + }, + Err(e) => Err(e), + }; + let sequence = match published { + Ok(sequence) => sequence, + Err(e) => { + destroy_window_and_shared(hwnd); + let _ = ready.send(Err(e)); + return; + } + }; + *shared.sequence.lock().unwrap() = sequence; + shared.state.lock().unwrap().published_at = Instant::now(); + if let Ok(mut slot) = PENDING.lock() { + *slot = Some(shared.clone()); + } + let _ = SetTimer(Some(hwnd), TIMER_ID, TIMER_INTERVAL_MS, None); + let _ = ready.send(Ok(())); + + let mut msg = MSG::default(); + while GetMessageW(&mut msg, None, 0, 0).as_bool() { + let _ = DispatchMessageW(&msg); + } + + let _ = KillTimer(Some(hwnd), TIMER_ID); + destroy_window_and_shared(hwnd); + } +} + +pub(super) fn run( + text: &str, + app_handle: &tauri::AppHandle, + paste_method: &PasteMethod, + enigo: &mut enigo::Enigo, + auto_submit: bool, + auto_submit_key: AutoSubmitKey, + clipboard_handling: ClipboardHandling, +) -> Result<(), String> { + let shared = Arc::new(WinTxShared { + state: Mutex::new(TxState::new()), + text: text.to_string(), + snapshot: Mutex::new(Vec::new()), + saved_bitmap: Mutex::new(None), + sequence: Mutex::new(0), + app_handle: app_handle.clone(), + auto_submit, + auto_submit_key, + preserve_transcript: clipboard_handling == ClipboardHandling::CopyToClipboard, + }); + + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let shared_for_pump = shared.clone(); + thread::spawn(move || pump_thread(shared_for_pump, ready_tx)); + + // Wait until the transcript is actually published (or the worker reports + // why it could not) before injecting the chord. + match ready_rx.recv() { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(e), + Err(_) => return Err("reliable paste worker died before publishing".to_string()), + } + info!("[reliable-paste] published transcript (delayed render)"); + + // Mark injection *before* sending: enigo holds the chord for ~100ms and a + // fast target may legitimately read while the chord is still held. + shared.state.lock().unwrap().injected_at = Some(Instant::now()); + match send_chord(enigo, paste_method) { + Ok(()) => { + info!("[reliable-paste] paste chord sent ({paste_method:?})"); + } + Err(e) => { + // Keep the transaction alive: the worker restores the clipboard + // after the short failed-injection timeout. + shared.state.lock().unwrap().injection_failed = true; + error!("[reliable-paste] failed to send paste chord: {e}"); + } + } + + Ok(()) +} diff --git a/src-tauri/src/secure_input.rs b/src-tauri/src/secure_input.rs new file mode 100644 index 0000000000..9512c5cd45 --- /dev/null +++ b/src-tauri/src/secure_input.rs @@ -0,0 +1,660 @@ +//! macOS Secure Event Input detection, monitoring, and fallback. +//! +//! When any process enables secure event input (password fields, Terminal's +//! "Secure Keyboard Entry", a stuck `loginwindow`), CGEventTaps stop receiving +//! KeyDown/KeyUp events while FlagsChanged still flows. The handy-keys +//! implementation is tap-based, so keyed shortcuts (e.g. Option+Space) die +//! silently while modifier-only shortcuts keep working. See issue #1578. +//! +//! This module: +//! - polls `IsSecureEventInputEnabled()` and tracks state transitions +//! - looks up the holding process (best effort — Apple documents no reliable +//! API; the IORegistry PID is frequently wrong or absent) +//! - while secure input is sustained, shadow-registers vulnerable *keyed* +//! bindings through the Carbon-backed Tauri global-shortcut path, which is +//! not affected by secure input (modifier-only bindings need no fallback) +//! - dynamically shadows the Cancel binding while recording, so Escape and +//! other keyed cancellation shortcuts remain available under secure input +//! - exposes a count-only keyboard diagnostic for the debug window. Only +//! event *kinds* are counted — key identity is never logged or returned. + +use serde::Serialize; +use specta::Type; +use tauri::{AppHandle, Emitter, Manager}; + +#[derive(Debug, Clone, Serialize, Type)] +pub struct SecureInputStatus { + /// Secure input is currently enabled (live check) + pub enabled: bool, + /// Enabled continuously long enough to be considered stuck (not just a + /// password field gaining momentary focus) + pub sustained: bool, + pub culprit_pid: Option, + pub culprit_name: Option, + /// Carbon fallback registrations are currently active + pub fallback_active: bool, + /// Binding ids shadow-registered with identical semantics + pub covered_bindings: Vec, + /// Side-specific binding ids widened to match either side while shadowed + pub degraded_bindings: Vec, + /// Binding ids that cannot fire at all (e.g. fn+key, registration failure) + pub uncovered_bindings: Vec, + /// The user tried to record a shortcut while secure input was active. + /// Treated as user impact even when every binding is covered, so the + /// warning banner appears and explains why recording refused. + pub recorder_blocked: bool, +} + +#[derive(Debug, Clone, Serialize, Type)] +pub struct KeyboardDiagnosticReport { + pub secure_input_enabled: bool, + pub culprit_pid: Option, + pub culprit_name: Option, + /// Counts only — key identity is deliberately never captured. + pub key_down: u32, + pub key_up: u32, + pub flags_changed: u32, + pub mouse: u32, + pub duration_ms: u32, +} + +#[tauri::command] +#[specta::specta] +pub fn get_secure_input_status(app: AppHandle) -> SecureInputStatus { + imp::status(&app) +} + +#[tauri::command] +#[specta::specta] +pub async fn run_keyboard_diagnostic( + duration_secs: Option, +) -> Result { + imp::run_diagnostic(duration_secs.unwrap_or(10).clamp(3, 30)).await +} + +/// True if secure input is enabled right now (live check, macOS only). +pub fn is_enabled_now() -> bool { + imp::is_enabled() +} + +/// Record that a shortcut-recording attempt was refused because secure input +/// is active. Flips the warning state so the banner/tray explain the refusal +/// even when every registered binding is covered by the fallback. +pub fn note_recorder_blocked(app: &AppHandle) { + imp::note_recorder_blocked(app) +} + +/// Register/unregister the dynamic Cancel binding through the Carbon fallback +/// while a recording and sustained Secure Input overlap. +pub fn register_cancel_fallback(app: &AppHandle) { + imp::register_cancel_fallback(app) +} + +pub fn unregister_cancel_fallback(app: &AppHandle) { + imp::unregister_cancel_fallback(app) +} + +/// Rebuild Carbon fallback registrations from the current settings and +/// lifecycle state. This is called after shortcut-related settings change. +pub fn reconcile_fallback(app: &AppHandle) { + imp::reconcile_fallback(app) +} + +/// Managed state + monitor startup. On non-macOS platforms the state exists +/// but the monitor never runs and everything reports disabled. +pub fn init(app: &AppHandle) { + app.manage(imp::SecureInputState::new()); + imp::start_monitor(app); +} + +/// Whether the tray should show the warning badge / menu entry. +/// +/// Only when the user is actually impacted: a binding is degraded or dead. +/// When every affected binding is covered transparently by the fallback (or +/// none are affected), the experience is seamless and nothing is shown. +pub fn tray_warning_active(app: &AppHandle) -> bool { + app.try_state::() + .map(|s| s.warning_active()) + .unwrap_or(false) +} + +#[cfg(target_os = "macos")] +mod imp { + use super::*; + use crate::settings::{self, KeyboardImplementation, ShortcutBinding}; + use log::{debug, error, info, warn}; + use std::process::Command; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Mutex; + use std::time::{Duration, Instant}; + + /// How often the monitor thread polls. + const POLL_INTERVAL: Duration = Duration::from_secs(1); + /// Secure input must be held this long before we treat it as stuck. + /// Momentary activation (a password field gaining focus) is normal. + const SUSTAIN_THRESHOLD: Duration = Duration::from_secs(3); + + #[link(name = "Carbon", kind = "framework")] + extern "C" { + // Carbon HIToolbox; Boolean is an unsigned char + fn IsSecureEventInputEnabled() -> u8; + } + + pub fn is_enabled() -> bool { + unsafe { IsSecureEventInputEnabled() != 0 } + } + + #[derive(Debug, Clone)] + struct Culprit { + pid: i32, + name: String, + } + + #[derive(Default)] + struct FallbackState { + /// Bindings shadow-registered through the Tauri/Carbon path (possibly + /// with widened modifiers), kept so deactivation unregisters the + /// exact strings we registered. + registered: Vec, + /// Shadowed with identical semantics + covered: Vec, + /// Shadowed, but side-specific modifiers widened to either side + degraded: Vec, + /// Cannot fire at all while secure input is held + uncovered: Vec, + } + + pub struct SecureInputState { + enabled: AtomicBool, + sustained: AtomicBool, + enabled_since: Mutex>, + culprit: Mutex>, + fallback: Mutex, + /// Serializes fallback registration changes without requiring the + /// fallback state lock to be held across global-shortcut plugin calls. + fallback_operation: Mutex<()>, + recorder_blocked: AtomicBool, + cancel_requested: AtomicBool, + monitor_started: AtomicBool, + } + + impl SecureInputState { + pub fn new() -> Self { + Self { + enabled: AtomicBool::new(false), + sustained: AtomicBool::new(false), + enabled_since: Mutex::new(None), + culprit: Mutex::new(None), + fallback: Mutex::new(FallbackState::default()), + fallback_operation: Mutex::new(()), + recorder_blocked: AtomicBool::new(false), + cancel_requested: AtomicBool::new(false), + monitor_started: AtomicBool::new(false), + } + } + + pub fn is_sustained(&self) -> bool { + self.sustained.load(Ordering::SeqCst) + } + + /// User-visible impact exists: some binding is degraded or dead, or + /// the user ran into the blocked shortcut recorder. + pub fn warning_active(&self) -> bool { + if self.recorder_blocked.load(Ordering::SeqCst) { + return true; + } + if !self.is_sustained() { + return false; + } + let fallback = self.fallback.lock().unwrap(); + !fallback.degraded.is_empty() || !fallback.uncovered.is_empty() + } + } + + /// Best-effort culprit lookup via the IORegistry session property. + /// Apple documents no reliable API for this; the PID may be missing + /// (an app quit while holding secure input) or point at the wrong + /// process (often the responsible parent, or `loginwindow`). + fn lookup_culprit() -> Option { + let out = Command::new("ioreg") + .args(["-l", "-w", "0"]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + let pid: i32 = text + .lines() + .find_map(|l| l.split("\"kCGSSessionSecureInputPID\"=").nth(1))? + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse() + .ok()?; + + // `ps -o comm=` returns the full executable path; show just the + // binary name ("Terminal", not ".../Terminal.app/Contents/MacOS/Terminal") + let name = Command::new("ps") + .args(["-o", "comm=", "-p", &pid.to_string()]) + .output() + .ok() + .and_then(|o| { + let raw = String::from_utf8_lossy(&o.stdout); + let trimmed = raw.trim(); + (!trimmed.is_empty()) + .then(|| trimmed.rsplit('/').next().unwrap_or(trimmed).to_string()) + }) + .unwrap_or_else(|| "(process no longer running)".to_string()); + + Some(Culprit { pid, name }) + } + + pub fn status(app: &AppHandle) -> SecureInputStatus { + let enabled = is_enabled(); + let state = app.state::(); + + // Culprit discovery shells out to ioreg and is intentionally performed + // only by the monitor (or the blocking diagnostic), never by this + // synchronous Tauri command. + let culprit = state.culprit.lock().unwrap().clone(); + let fallback = state.fallback.lock().unwrap(); + SecureInputStatus { + enabled, + sustained: state.sustained.load(Ordering::SeqCst), + culprit_pid: culprit.as_ref().map(|c| c.pid), + culprit_name: culprit.map(|c| c.name), + fallback_active: !fallback.registered.is_empty(), + covered_bindings: fallback.covered.clone(), + degraded_bindings: fallback.degraded.clone(), + uncovered_bindings: fallback.uncovered.clone(), + recorder_blocked: state.recorder_blocked.load(Ordering::SeqCst), + } + } + + pub fn note_recorder_blocked(app: &AppHandle) { + let state = app.state::(); + if !state.recorder_blocked.swap(true, Ordering::SeqCst) { + warn!("SecureInput: shortcut recording attempt blocked — surfacing warning"); + refresh_tray(app); + emit_status(app); + } + } + + fn emit_status(app: &AppHandle) { + let payload = status(app); + if let Err(e) = app.emit("secure-input-changed", &payload) { + error!("Failed to emit secure-input-changed: {e}"); + } + } + + fn refresh_tray(app: &AppHandle) { + // Tray may be absent (--no-tray) + if app.try_state::().is_some() { + crate::tray::refresh_tray_icon(app); + } + } + + pub fn start_monitor(app: &AppHandle) { + let state = app.state::(); + if state.monitor_started.swap(true, Ordering::SeqCst) { + return; + } + + let app = app.clone(); + std::thread::spawn(move || { + info!("secure-input monitor started"); + loop { + std::thread::sleep(POLL_INTERVAL); + let state = app.state::(); + let now_enabled = is_enabled(); + let was_enabled = state.enabled.swap(now_enabled, Ordering::SeqCst); + + if now_enabled && !was_enabled { + let culprit = lookup_culprit(); + match &culprit { + Some(c) => { + info!("SecureInput ENABLED (held by pid {} '{}')", c.pid, c.name) + } + None => info!("SecureInput ENABLED (no visible holder)"), + } + *state.enabled_since.lock().unwrap() = Some(Instant::now()); + *state.culprit.lock().unwrap() = culprit; + } + + if !now_enabled { + // Clear recorder impact on every disabled sample. A short + // Secure Input episode can otherwise occur entirely + // between polls and leave this flag latched indefinitely. + let was_blocked = state.recorder_blocked.swap(false, Ordering::SeqCst); + if was_enabled { + info!("SecureInput DISABLED"); + *state.enabled_since.lock().unwrap() = None; + *state.culprit.lock().unwrap() = None; + } + + if state.sustained.swap(false, Ordering::SeqCst) { + reconcile_fallback(&app); + } else if was_enabled || was_blocked { + refresh_tray(&app); + emit_status(&app); + } + continue; + } + + // Promote to "sustained" after the threshold. + if !state.sustained.load(Ordering::SeqCst) { + let held_long_enough = state + .enabled_since + .lock() + .unwrap() + .map(|t| t.elapsed() >= SUSTAIN_THRESHOLD) + .unwrap_or(false); + if held_long_enough { + warn!( + "SecureInput held for {}s — keyed shortcuts are blocked; activating fallback", + SUSTAIN_THRESHOLD.as_secs() + ); + state.sustained.store(true, Ordering::SeqCst); + reconcile_fallback(&app); + } + } + } + }); + } + + fn is_mouse_key(key: &handy_keys::Key) -> bool { + key.to_string().to_lowercase().starts_with("mouse") + } + + /// Build the Carbon-registrable equivalent of a keyed hotkey. + /// + /// Carbon has no concept of left/right modifiers, so side-specific + /// modifiers widen to the whole group — returned as `degraded: true` so + /// the UI can call out the changed matching. The fn key cannot be + /// expressed at all (`None`). + fn carbon_equivalent(hotkey: &handy_keys::Hotkey) -> Option<(String, bool)> { + use handy_keys::Modifiers as M; + + if hotkey.modifiers.contains(M::FN) { + return None; + } + + let mut widened = M::empty(); + let mut degraded = false; + for group in [M::CTRL, M::OPT, M::SHIFT, M::CMD] { + if hotkey.modifiers.intersects(group) { + widened |= group; + if !hotkey.modifiers.contains(group) { + // Only one side was specified — matching gets wider + degraded = true; + } + } + } + + let carbon_hotkey = handy_keys::Hotkey::new(widened, hotkey.key).ok()?; + Some((carbon_hotkey.to_handy_string(), degraded)) + } + + /// Register one vulnerable binding through Carbon. `fallback` is local + /// reconciliation state, never the mutex-protected shared state. + fn register_fallback_binding( + app: &AppHandle, + id: &str, + binding: &ShortcutBinding, + fallback: &mut FallbackState, + ) -> bool { + let Ok(hotkey) = binding.current_binding.parse::() else { + warn!( + "SecureInput fallback: '{}' has unparseable binding '{}', skipping", + id, binding.current_binding + ); + fallback.uncovered.push(id.to_string()); + return false; + }; + + match &hotkey.key { + None => { + debug!( + "SecureInput fallback: '{}' ('{}') is modifier-only — immune, no shadow needed", + id, binding.current_binding + ); + return true; + } + Some(k) if is_mouse_key(k) => { + debug!( + "SecureInput fallback: '{}' ('{}') is mouse-based — immune, no shadow needed", + id, binding.current_binding + ); + return true; + } + Some(_) => {} + } + + let Some((carbon_binding, degraded)) = carbon_equivalent(&hotkey) else { + warn!( + "SecureInput fallback: '{}' ('{}') cannot be expressed via Carbon", + id, binding.current_binding + ); + fallback.uncovered.push(id.to_string()); + return false; + }; + + let mut shadow = binding.clone(); + shadow.current_binding = carbon_binding; + + match crate::shortcut::tauri_impl::register_shortcut(app, shadow.clone()) { + Ok(()) => { + info!( + "SecureInput fallback: '{}' registered via Carbon as '{}'{}", + id, + shadow.current_binding, + if degraded { + " (widened to either side)" + } else { + "" + } + ); + fallback.registered.push(shadow); + if degraded { + fallback.degraded.push(id.to_string()); + } else { + fallback.covered.push(id.to_string()); + } + } + Err(e) => { + warn!( + "SecureInput fallback: could not cover '{}' ('{}'): {}", + id, shadow.current_binding, e + ); + fallback.uncovered.push(id.to_string()); + } + } + + false + } + + /// Rebuild the fallback from current state. The operation mutex serializes + /// reconciliations, while the fallback mutex is released before every + /// global-shortcut plugin call to avoid lock-order inversion with callbacks. + pub fn reconcile_fallback(app: &AppHandle) { + let state = app.state::(); + let _operation = state.fallback_operation.lock().unwrap(); + + let previous = { + let mut fallback = state.fallback.lock().unwrap(); + std::mem::take(&mut *fallback) + }; + + if !previous.registered.is_empty() { + info!( + "SecureInput fallback reconciling: removing {} Carbon shadow(s)", + previous.registered.len() + ); + } + for binding in previous.registered { + if let Err(e) = crate::shortcut::tauri_impl::unregister_shortcut(app, binding.clone()) { + warn!( + "SecureInput fallback: failed to unregister '{}': {}", + binding.current_binding, e + ); + } + } + + let settings = settings::get_settings(app); + let eligible = state.is_sustained() + && app + .try_state::() + .is_some() + && settings.keyboard_implementation == KeyboardImplementation::HandyKeys; + + let mut next = FallbackState::default(); + let mut immune = 0usize; + if eligible { + for (id, binding) in &settings.bindings { + if id == "cancel" && !state.cancel_requested.load(Ordering::SeqCst) { + continue; + } + if id == "transcribe_with_post_process" && !settings.post_process_enabled { + continue; + } + + if register_fallback_binding(app, id, binding, &mut next) { + immune += 1; + } + } + + info!( + "SecureInput fallback active: {} covered, {} degraded, {} uncovered, {} immune (user impact: {})", + next.covered.len(), + next.degraded.len(), + next.uncovered.len(), + immune, + !next.degraded.is_empty() || !next.uncovered.is_empty() + ); + } else if state.is_sustained() + && app + .try_state::() + .is_none() + { + debug!("SecureInput fallback deferred until shortcuts are initialized"); + } + + *state.fallback.lock().unwrap() = next; + drop(_operation); + refresh_tray(app); + emit_status(app); + } + + fn schedule_reconcile(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + reconcile_fallback(&app); + }); + } + + pub fn register_cancel_fallback(app: &AppHandle) { + let state = app.state::(); + state.cancel_requested.store(true, Ordering::SeqCst); + schedule_reconcile(app); + } + + pub fn unregister_cancel_fallback(app: &AppHandle) { + let state = app.state::(); + state.cancel_requested.store(false, Ordering::SeqCst); + schedule_reconcile(app); + } + + /// Count-only capture test for the debug window. Opens a short-lived + /// keyboard listener and tallies event kinds; key identity is never + /// inspected beyond the mouse/keyboard distinction, and nothing about + /// individual events is logged or returned. + pub async fn run_diagnostic(duration_secs: u32) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let listener = handy_keys::KeyboardListener::new() + .map_err(|e| format!("Failed to create keyboard listener: {e}"))?; + + let enabled_at_start = is_enabled(); + let start = Instant::now(); + let deadline = start + Duration::from_secs(duration_secs as u64); + let (mut key_down, mut key_up, mut flags_changed, mut mouse) = (0u32, 0u32, 0u32, 0u32); + + while Instant::now() < deadline { + match listener.try_recv() { + Some(event) => match &event.key { + Some(k) if is_mouse_key(k) => mouse += 1, + Some(_) if event.is_key_down => key_down += 1, + Some(_) => key_up += 1, + None => flags_changed += 1, + }, + None => std::thread::sleep(Duration::from_millis(10)), + } + } + + let enabled = enabled_at_start || is_enabled(); + let culprit = if enabled { lookup_culprit() } else { None }; + info!( + "keyboard diagnostic: secure_input={} key_down={} key_up={} flags_changed={} mouse={}", + enabled, key_down, key_up, flags_changed, mouse + ); + + Ok(KeyboardDiagnosticReport { + secure_input_enabled: enabled, + culprit_pid: culprit.as_ref().map(|c| c.pid), + culprit_name: culprit.map(|c| c.name), + key_down, + key_up, + flags_changed, + mouse, + duration_ms: start.elapsed().as_millis() as u32, + }) + }) + .await + .map_err(|e| format!("Diagnostic task failed: {e}"))? + } +} + +#[cfg(not(target_os = "macos"))] +mod imp { + use super::*; + + pub struct SecureInputState; + + impl SecureInputState { + pub fn new() -> Self { + Self + } + pub fn warning_active(&self) -> bool { + false + } + } + + pub fn is_enabled() -> bool { + false + } + + pub fn start_monitor(_app: &AppHandle) {} + + pub fn status(_app: &AppHandle) -> SecureInputStatus { + SecureInputStatus { + enabled: false, + sustained: false, + culprit_pid: None, + culprit_name: None, + fallback_active: false, + covered_bindings: Vec::new(), + degraded_bindings: Vec::new(), + uncovered_bindings: Vec::new(), + recorder_blocked: false, + } + } + + pub fn note_recorder_blocked(_app: &AppHandle) {} + + pub fn register_cancel_fallback(_app: &AppHandle) {} + + pub fn unregister_cancel_fallback(_app: &AppHandle) {} + + pub fn reconcile_fallback(_app: &AppHandle) {} + + pub async fn run_diagnostic(_duration_secs: u32) -> Result { + Err("The keyboard diagnostic is only supported on macOS".to_string()) + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index c3d203c5e0..f9e3f9a48b 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -377,6 +377,10 @@ pub struct AppSettings { pub always_on_microphone: bool, #[serde(default)] pub selected_microphone: Option, + /// Which input channel to use on the selected microphone device. + /// None means "average all channels" (original behavior). + #[serde(default)] + pub selected_channel: Option, #[serde(default)] pub clamshell_microphone: Option, #[serde(default)] @@ -443,6 +447,11 @@ pub struct AppSettings { pub paste_delay_ms: u64, #[serde(default = "default_paste_delay_after_ms")] pub paste_delay_after_ms: u64, + /// Debug-gated ("beta") receipt-sequenced paste: restore the clipboard only + /// after the target app actually reads the transcript, instead of after a + /// fixed delay. See `paste_tx`. macOS and Windows only. + #[serde(default)] + pub reliable_paste: bool, #[serde(default = "default_typing_tool")] pub typing_tool: TypingTool, #[serde(default)] @@ -856,6 +865,7 @@ pub fn get_default_settings() -> AppSettings { onboarding_completed: false, always_on_microphone: false, selected_microphone: None, + selected_channel: None, clamshell_microphone: None, selected_output_device: None, translate_to_english: false, @@ -889,6 +899,7 @@ pub fn get_default_settings() -> AppSettings { show_tray_icon: default_show_tray_icon(), paste_delay_ms: default_paste_delay_ms(), paste_delay_after_ms: default_paste_delay_after_ms(), + reliable_paste: false, typing_tool: default_typing_tool(), external_script_path: None, custom_filler_words: None, diff --git a/src-tauri/src/shortcut/handy_keys.rs b/src-tauri/src/shortcut/handy_keys.rs index 3153d5e994..bd8e562e50 100644 --- a/src-tauri/src/shortcut/handy_keys.rs +++ b/src-tauri/src/shortcut/handy_keys.rs @@ -527,10 +527,29 @@ pub fn start_handy_keys_recording(app: AppHandle, binding_id: String) -> Result< return Err("handy-keys is not the active keyboard implementation".into()); } + // While Secure Input is active the tap receives no KeyDown/KeyUp, so the + // recorder would silently capture just the modifier and overwrite the + // binding with it (issue #1578). Refuse instead; the frontend maps this + // marker to a localized explanation, and the noted impact makes the + // warning banner appear with the full story. + if crate::secure_input::is_enabled_now() { + crate::secure_input::note_recorder_blocked(&app); + return Err("secure-input-active".into()); + } + let state = app .try_state::() .ok_or("HandyKeysState not initialized")?; - state.start_recording(&app, binding_id) + + // Suspend every registered shortcut so a combo that overlaps an existing + // binding can't fire it (or have its keys swallowed) mid-capture. + super::suspend_all_shortcuts(&app); + + let result = state.start_recording(&app, binding_id); + if result.is_err() { + super::resume_all_shortcuts(&app); + } + result } /// Stop key recording mode @@ -545,5 +564,11 @@ pub fn stop_handy_keys_recording(app: AppHandle) -> Result<(), String> { let state = app .try_state::() .ok_or("HandyKeysState not initialized")?; - state.stop_recording() + + // Restore shortcuts from settings regardless of how recording ended. + // A commit has already registered the new binding via change_binding; + // re-registering it here fails cleanly and is ignored. + let result = state.stop_recording(); + super::resume_all_shortcuts(&app); + result } diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index f130cc151c..bc14c6feda 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -11,13 +11,12 @@ mod handler; pub mod handy_keys; -mod tauri_impl; +pub mod tauri_impl; -use log::{error, info, warn}; +use log::{debug, error, info, warn}; use serde::Serialize; use specta::Type; use tauri::{AppHandle, Emitter, Manager}; -use tauri_plugin_autostart::ManagerExt; #[cfg(all(target_os = "macos", target_arch = "aarch64"))] use crate::settings::APPLE_INTELLIGENCE_DEFAULT_MODEL_ID; @@ -58,6 +57,10 @@ pub fn init_shortcuts(app: &AppHandle) { /// Register the cancel shortcut (called when recording starts) pub fn register_cancel_shortcut(app: &AppHandle) { + // Track recording lifecycle independently of the current implementation so + // switching implementations mid-recording cannot leave stale fallback state. + crate::secure_input::register_cancel_fallback(app); + let settings = get_settings(app); match settings.keyboard_implementation { KeyboardImplementation::Tauri => tauri_impl::register_cancel_shortcut(app), @@ -67,6 +70,8 @@ pub fn register_cancel_shortcut(app: &AppHandle) { /// Unregister the cancel shortcut (called when recording stops) pub fn unregister_cancel_shortcut(app: &AppHandle) { + crate::secure_input::unregister_cancel_fallback(app); + let settings = get_settings(app); match settings.keyboard_implementation { KeyboardImplementation::Tauri => tauri_impl::unregister_cancel_shortcut(app), @@ -151,6 +156,7 @@ pub fn change_binding( b.current_binding = binding; settings.bindings.insert(id.clone(), b.clone()); settings::write_settings(&app, settings); + crate::secure_input::reconcile_fallback(&app); return Ok(BindingResponse { success: true, binding: Some(b.clone()), @@ -169,17 +175,19 @@ pub fn change_binding( if let Err(e) = validate_shortcut_for_implementation(&binding, settings.keyboard_implementation) { warn!("change_binding validation error: {}", e); + restore_registration(&app, &binding_to_modify); return Err(e); } // Create an updated binding - let mut updated_binding = binding_to_modify; + let mut updated_binding = binding_to_modify.clone(); updated_binding.current_binding = binding; // Register the new binding if let Err(e) = register_shortcut(&app, updated_binding.clone()) { let error_msg = format!("Failed to register shortcut: {}", e); error!("change_binding error: {}", error_msg); + restore_registration(&app, &binding_to_modify); return Ok(BindingResponse { success: false, binding: None, @@ -190,8 +198,9 @@ pub fn change_binding( // Update the binding in the settings settings.bindings.insert(id, updated_binding.clone()); - // Save the settings + // Save the settings and synchronize any active Secure Input shadows. settings::write_settings(&app, settings); + crate::secure_input::reconcile_fallback(&app); // Return the updated binding Ok(BindingResponse { @@ -201,6 +210,17 @@ pub fn change_binding( }) } +/// Best-effort re-register of the previous binding after a failed change, +/// so a failure leaves the user's shortcut working exactly as before. +fn restore_registration(app: &AppHandle, binding: &ShortcutBinding) { + if let Err(e) = register_shortcut(app, binding.clone()) { + error!( + "Failed to restore previous binding '{}' ({}): {}", + binding.id, binding.current_binding, e + ); + } +} + #[tauri::command] #[specta::specta] pub fn reset_binding(app: AppHandle, id: String) -> Result { @@ -208,30 +228,56 @@ pub fn reset_binding(app: AppHandle, id: String) -> Result Result<(), String> { - if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { - if let Err(e) = unregister_shortcut(&app, b) { - error!("suspend_binding error for id '{}': {}", id, e); - return Err(e); +/// Unregister every binding while the user is recording a new shortcut in +/// the UI, so no existing shortcut can fire — or swallow the keystrokes — +/// mid-capture. The "cancel" binding is untouched: it is managed dynamically +/// by the recording lifecycle. +pub fn suspend_all_shortcuts(app: &AppHandle) { + for (id, binding) in settings::get_bindings(app) { + if id == "cancel" { + continue; + } + if let Err(e) = unregister_shortcut(app, binding) { + debug!( + "suspend_all_shortcuts: could not unregister '{}': {}", + id, e + ); } } +} + +/// Re-register every binding from settings after shortcut recording ends. +/// Registering an already-registered shortcut fails cleanly in both +/// implementations, so this is idempotent and safe on every exit path. +pub fn resume_all_shortcuts(app: &AppHandle) { + let settings = get_settings(app); + for (id, binding) in &settings.bindings { + if id == "cancel" { + continue; + } + if id == "transcribe_with_post_process" && !settings.post_process_enabled { + continue; + } + if let Err(e) = register_shortcut(app, binding.clone()) { + debug!("resume_all_shortcuts: could not register '{}': {}", id, e); + } + } +} + +/// Temporarily unregister all bindings while the user is recording a +/// shortcut in the UI. This avoids firing actions while keys are recorded. +#[tauri::command] +#[specta::specta] +pub fn suspend_all_bindings(app: AppHandle) -> Result<(), String> { + suspend_all_shortcuts(&app); Ok(()) } -/// Re-register the binding after the user has finished editing. +/// Re-register all bindings after the user has finished recording. #[tauri::command] #[specta::specta] -pub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> { - if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { - if let Err(e) = register_shortcut(&app, b) { - error!("resume_binding error for id '{}': {}", id, e); - return Err(e); - } - } +pub fn resume_all_bindings(app: AppHandle) -> Result<(), String> { + resume_all_shortcuts(&app); Ok(()) } @@ -282,9 +328,16 @@ pub fn change_keyboard_implementation_setting( settings.keyboard_implementation = new_impl; settings::write_settings(&app, settings); + // Carbon fallback registrations use the Tauri plugin. Remove them before + // registering the full Tauri implementation to avoid duplicate conflicts. + if new_impl == KeyboardImplementation::Tauri { + crate::secure_input::reconcile_fallback(&app); + } + // Initialize new implementation if needed (HandyKeys needs state) if new_impl == KeyboardImplementation::HandyKeys && initialize_handy_keys_with_rollback(&app)? { - // Shortcuts already registered during init + // Shortcuts already registered during init. + crate::secure_input::reconcile_fallback(&app); return Ok(ImplementationChangeResult { success: true, reset_bindings: vec![], @@ -293,6 +346,7 @@ pub fn change_keyboard_implementation_setting( // Register all shortcuts with new implementation, resetting invalid ones let reset_bindings = register_all_shortcuts_for_implementation(&app, new_impl); + crate::secure_input::reconcile_fallback(&app); // Emit event to notify frontend of the change let _ = app.emit( @@ -454,6 +508,7 @@ fn initialize_handy_keys_with_rollback(app: &AppHandle) -> Result let mut settings = settings::get_settings(app); settings.keyboard_implementation = KeyboardImplementation::Tauri; settings::write_settings(app, settings); + crate::secure_input::reconcile_fallback(app); tauri_impl::init_shortcuts(app); return Err(format!( "Failed to initialize HandyKeys: {}. Reverted to Tauri.", @@ -671,12 +726,7 @@ pub fn change_autostart_setting(app: AppHandle, enabled: bool) -> Result<(), Str settings::write_settings(&app, settings); // Apply the autostart setting immediately - let autostart_manager = app.autolaunch(); - if enabled { - let _ = autostart_manager.enable(); - } else { - let _ = autostart_manager.disable(); - } + crate::autostart::apply_autostart(&app, enabled); // Notify frontend let _ = app.emit( @@ -799,6 +849,15 @@ pub fn change_paste_delay_after_ms_setting(app: AppHandle, ms: u64) -> Result<() Ok(()) } +#[tauri::command] +#[specta::specta] +pub fn change_reliable_paste_setting(app: AppHandle, enabled: bool) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.reliable_paste = enabled; + settings::write_settings(&app, settings); + Ok(()) +} + #[tauri::command] #[specta::specta] pub fn change_paste_method_setting(app: AppHandle, method: String) -> Result<(), String> { @@ -933,6 +992,7 @@ pub fn change_post_process_enabled_setting(app: AppHandle, enabled: bool) -> Res } } + crate::secure_input::reconcile_fallback(&app); Ok(()) } diff --git a/src-tauri/src/shortcut/tauri_impl.rs b/src-tauri/src/shortcut/tauri_impl.rs index c12da7221d..6e19c03c1e 100644 --- a/src-tauri/src/shortcut/tauri_impl.rs +++ b/src-tauri/src/shortcut/tauri_impl.rs @@ -3,7 +3,7 @@ //! This module provides shortcut functionality using Tauri's built-in //! global-shortcut plugin. -use log::{error, warn}; +use log::{debug, error, warn}; use tauri::AppHandle; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; @@ -108,6 +108,13 @@ pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<() if scut == &shortcut { let shortcut_string = scut.into_string(); let is_pressed = event.state == ShortcutState::Pressed; + // Mirrors the handy-keys event log line; the distinct prefix + // makes it possible to tell which backend fired a shortcut + // (e.g. when diagnosing the Secure Input fallback) + debug!( + "tauri global-shortcut event: binding={}, shortcut={}, state={:?}", + binding_id_for_closure, shortcut_string, event.state + ); handle_shortcut_event( app_handle, &binding_id_for_closure, diff --git a/src-tauri/src/signal_handle.rs b/src-tauri/src/signal_handle.rs index 1f54c0d96e..efe01e8a55 100644 --- a/src-tauri/src/signal_handle.rs +++ b/src-tauri/src/signal_handle.rs @@ -4,8 +4,10 @@ use log::debug; use log::warn; use tauri::{AppHandle, Manager}; +#[cfg(target_os = "macos")] +use signal_hook::consts::SIGUSR1; #[cfg(unix)] -use signal_hook::consts::{SIGUSR1, SIGUSR2}; +use signal_hook::consts::SIGUSR2; #[cfg(unix)] use signal_hook::iterator::Signals; #[cfg(unix)] @@ -21,12 +23,30 @@ pub fn send_transcription_input(app: &AppHandle, binding_id: &str, source: &str) } } +/// Listen for Unix signals that remotely toggle transcription. +/// +/// SIGUSR2 toggles plain transcription on all Unix platforms. SIGUSR1 +/// (transcription with post-processing) is only handled on macOS: on Linux, +/// WebKitGTK's JavaScriptCore garbage collector sends SIGUSR1 to its own +/// threads to suspend them, so handling it caused phantom recordings on every +/// GC cycle (#1660). Linux users should use `handy --toggle-post-process` +/// instead. #[cfg(unix)] -pub fn setup_signal_handler(app_handle: AppHandle, mut signals: Signals) { +pub fn setup_signal_handler(app_handle: AppHandle) { + #[cfg(target_os = "macos")] + let mut signals = + Signals::new([SIGUSR1, SIGUSR2]).expect("failed to register transcription signal handlers"); + #[cfg(not(target_os = "macos"))] + let mut signals = + Signals::new([SIGUSR2]).expect("failed to register transcription signal handlers"); + #[cfg(target_os = "macos")] debug!("Signal handlers registered (SIGUSR1, SIGUSR2)"); + #[cfg(not(target_os = "macos"))] + debug!("Signal handler registered (SIGUSR2; SIGUSR1 is left to WebKitGTK)"); thread::spawn(move || { for sig in signals.forever() { let (binding_id, signal_name) = match sig { + #[cfg(target_os = "macos")] SIGUSR1 => ("transcribe_with_post_process", "SIGUSR1"), SIGUSR2 => ("transcribe", "SIGUSR2"), _ => continue, diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 4751c35c06..e4e2c5642e 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -93,8 +93,21 @@ fn windows_taskbar_theme() -> Option { }) } -/// Gets the appropriate icon path for the given theme and state -pub fn get_icon_path(theme: AppTheme, state: TrayIconState) -> &'static str { +/// Gets the appropriate icon path for the given theme and state. +/// +/// `warning` overlays a badge on the idle icon while keyboard shortcuts are +/// blocked (macOS Secure Input); recording/transcribing states keep their +/// normal icons so in-flight activity stays recognizable. +pub fn get_icon_path(theme: AppTheme, state: TrayIconState, warning: bool) -> &'static str { + if warning && state == TrayIconState::Idle { + return match theme { + AppTheme::Dark => "resources/tray_idle_warning.png", + AppTheme::Light => "resources/tray_idle_warning_dark.png", + // Linux never sets the warning flag (Secure Input is macOS-only), + // but fall back to the normal icon just in case. + AppTheme::Colored => "resources/handy.png", + }; + } match (theme, state) { // Dark theme uses light icons (AppTheme::Dark, TrayIconState::Idle) => "resources/tray_idle.png", @@ -118,7 +131,8 @@ pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) { // Store current state app.state::().set(icon); - let icon_path = get_icon_path(theme, icon); + let warning = crate::secure_input::tray_warning_active(app); + let icon_path = get_icon_path(theme, icon, warning); let icon_started = std::time::Instant::now(); if let Err(err) = load_tray_icon( @@ -174,6 +188,20 @@ pub fn update_tray_menu(app: &AppHandle, locale: Option<&str>) { let locale = locale.unwrap_or(&settings.app_language); let strings = get_tray_translations(Some(locale.to_string())); + // Secure Input warning entry (macOS): clicking opens the settings window + // where the full warning banner explains the situation. Locales that + // haven't translated the key yet get the English string rather than a + // blank menu item (build.rs emits "" for missing keys). + let secure_input_warning = crate::secure_input::tray_warning_active(app).then(|| { + let label = if strings.secure_input_warning.is_empty() { + get_tray_translations(Some("en".to_string())).secure_input_warning + } else { + strings.secure_input_warning.clone() + }; + MenuItem::with_id(app, "secure_input_warning", &label, true, None::<&str>) + .expect("failed to create secure input warning item") + }); + // Platform-specific accelerators #[cfg(target_os = "macos")] let (settings_accelerator, quit_accelerator) = (Some("Cmd+,"), Some("Cmd+Q")); @@ -292,10 +320,19 @@ pub fn update_tray_menu(app: &AppHandle, locale: Option<&str>) { .expect("failed to create menu"), }; + // Both layouts start with [version, separator, ...]; slot the warning in + // right below the version line so it's the first actionable thing seen. + let mut tooltip = version_label; + if let Some(warning_item) = secure_input_warning { + let _ = menu.insert(&warning_item, 2); + let _ = menu.insert(&separator(), 3); + tooltip = format!("{} — {}", tooltip, warning_item.text().unwrap_or_default()); + } + let tray = app.state::(); let _ = tray.set_menu(Some(menu)); let _ = tray.set_icon_as_template(true); - let _ = tray.set_tooltip(Some(version_label)); + let _ = tray.set_tooltip(Some(tooltip)); } fn last_transcript_text(entry: &HistoryEntry) -> &str { diff --git a/src-tauri/src/tray_i18n.rs b/src-tauri/src/tray_i18n.rs index 1f896dbe34..2bdc84ca82 100644 --- a/src-tauri/src/tray_i18n.rs +++ b/src-tauri/src/tray_i18n.rs @@ -20,15 +20,63 @@ include!(concat!(env!("OUT_DIR"), "/tray_translations.rs")); /// Get localized tray menu strings based on the system locale. /// -/// Lookup order: full locale (e.g. "zh-TW") → language code ("zh") → English. +/// Lookup order: exact locale → Chinese script/region fallback → language code → English. pub fn get_tray_translations(locale: Option) -> TrayStrings { - let locale_str = locale.as_deref().unwrap_or("en"); - let lang_code = locale_str.split(['-', '_']).next().unwrap_or("en"); + let normalized = locale + .as_deref() + .unwrap_or("en") + .to_lowercase() + .replace('_', "-"); + let subtags: Vec<_> = normalized.split('-').collect(); + let language = subtags.first().copied().unwrap_or("en"); + let is_hant = subtags.contains(&"hant"); + let is_hans = subtags.contains(&"hans"); + let is_traditional_region = ["tw", "hk", "mo"] + .iter() + .any(|region| subtags.contains(region)); - TRANSLATIONS - .get(locale_str) - .or_else(|| TRANSLATIONS.get(lang_code)) + let exact_match = TRANSLATIONS + .iter() + .find_map(|(code, strings)| code.eq_ignore_ascii_case(&normalized).then_some(strings)); + let fallback = match language { + "zh" if is_hant || (!is_hans && is_traditional_region) => "zh-TW", + // Cantonese uses Traditional Chinese unless explicitly tagged as Hans. + "yue" if is_hans => "zh", + "yue" => "zh-TW", + _ => language, + }; + + exact_match + .or_else(|| TRANSLATIONS.get(fallback)) .or_else(|| TRANSLATIONS.get("en")) .cloned() .expect("English translations must exist") } + +#[cfg(test)] +mod tests { + use super::{get_tray_translations, TRANSLATIONS}; + + #[test] + fn resolves_locale_fallbacks() { + for (locale, expected) in [ + ("zh-Hant-TW", "zh-TW"), + ("zh-Hant-HK", "zh-TW"), + ("zh-HK", "zh-TW"), + ("zh-MO", "zh-TW"), + ("ZH-TW", "zh-TW"), + ("zh_Hant_TW", "zh-TW"), + ("zh-Hans-CN", "zh"), + ("yue-Hant-HK", "zh-TW"), + ("yue-Hans-CN", "zh"), + ("fr-FR", "fr"), + ("xx-YY", "en"), + ] { + assert_eq!( + format!("{:?}", get_tray_translations(Some(locale.into()))), + format!("{:?}", TRANSLATIONS[expected]), + "{locale} should resolve to {expected}" + ); + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8d74f17318..ffc481c10c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Handy", - "version": "0.9.4", + "version": "0.9.5", "identifier": "com.pais.handy", "build": { "beforeDevCommand": "bun run dev", diff --git a/src/App.css b/src/App.css index 650b809bd9..de315044f5 100644 --- a/src/App.css +++ b/src/App.css @@ -11,6 +11,8 @@ --color-logo-stroke: var(--color-logo-stroke); --color-text-stroke: var(--color-text-stroke); --color-mid-gray: var(--color-mid-gray); + --color-warning: var(--color-warning); + --color-error: var(--color-error); } :root { @@ -71,6 +73,8 @@ --color-background: var(--light-color-background); --color-logo-primary: var(--light-color-logo-primary); --color-logo-stroke: var(--light-color-logo-stroke); + --color-warning: var(--light-color-warning); + --color-error: var(--light-color-error); } :root[data-theme="dark"] { @@ -78,6 +82,8 @@ --color-background: var(--dark-color-background); --color-logo-primary: var(--dark-color-logo-primary); --color-logo-stroke: var(--dark-color-logo-stroke); + --color-warning: var(--dark-color-warning); + --color-error: var(--dark-color-error); } /* macOS - tint native overlay scrollbar thumb */ diff --git a/src/App.tsx b/src/App.tsx index d3b05771cd..fde2fbe96d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,8 +10,10 @@ import { import { ModelStateEvent, RecordingErrorEvent } from "./lib/types/events"; import "./App.css"; import AccessibilityPermissions from "./components/AccessibilityPermissions"; +import SecureInputWarning from "./components/SecureInputWarning"; import Footer from "./components/footer"; import Onboarding, { AccessibilityOnboarding } from "./components/onboarding"; +import { ErrorBoundary } from "./components/ErrorBoundary"; import { Sidebar, SidebarSection, SECTIONS_CONFIG } from "./components/Sidebar"; import { WhatsNewGate } from "./components/whats-new"; import { useSettings } from "./hooks/useSettings"; @@ -264,6 +266,8 @@ function App() { "bg-background border border-mid-gray/20 rounded-lg shadow-lg px-4 py-3 flex items-center gap-3 text-sm", title: "font-medium", description: "text-mid-gray", + actionButton: + "px-2 py-1 text-xs font-medium rounded-lg border bg-mid-gray/10 border-mid-gray/20 hover:bg-background-ui/30 hover:border-logo-primary cursor-pointer whitespace-nowrap", }, }} /> @@ -290,7 +294,9 @@ function App() { dir={direction} className="h-screen flex flex-col select-none cursor-default" > - + + + {/* Main content area that takes remaining space */}
+ {renderSettingsContent(currentSection)}
diff --git a/src/bindings.ts b/src/bindings.ts index a394ebd85a..a3c1c7fd93 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -149,6 +149,14 @@ async changePasteDelayAfterMsSetting(ms: number) : Promise> else return { status: "error", error: e as any }; } }, +async changeReliablePasteSetting(enabled: boolean) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("change_reliable_paste_setting", { enabled }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async changePasteMethodSetting(method: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_paste_method_setting", { method }) }; @@ -297,23 +305,23 @@ async updateCustomWords(words: string[]) : Promise> { } }, /** - * Temporarily unregister a binding while the user is editing it in the UI. - * This avoids firing the action while keys are being recorded. + * Temporarily unregister all bindings while the user is recording a + * shortcut in the UI. This avoids firing actions while keys are recorded. */ -async suspendBinding(id: string) : Promise> { +async suspendAllBindings() : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("suspend_binding", { id }) }; + return { status: "ok", data: await TAURI_INVOKE("suspend_all_bindings") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, /** - * Re-register the binding after the user has finished editing. + * Re-register all bindings after the user has finished recording. */ -async resumeBinding(id: string) : Promise> { +async resumeAllBindings() : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("resume_binding", { id }) }; + return { status: "ok", data: await TAURI_INVOKE("resume_all_bindings") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -484,6 +492,17 @@ async stopHandyKeysRecording() : Promise> { else return { status: "error", error: e as any }; } }, +async getSecureInputStatus() : Promise { + return await TAURI_INVOKE("get_secure_input_status"); +}, +async runKeyboardDiagnostic(durationSecs: number | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("run_keyboard_diagnostic", { durationSecs }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async triggerUpdateCheck() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("trigger_update_check") }; @@ -786,6 +805,22 @@ async getClamshellMicrophone() : Promise> { async isRecording() : Promise { return await TAURI_INVOKE("is_recording"); }, +async getMicrophoneChannels(deviceName: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("get_microphone_channels", { deviceName }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async setSelectedChannel(channel: number | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("set_selected_channel", { channel }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async setModelUnloadTimeout(timeout: ModelUnloadTimeout) : Promise { await TAURI_INVOKE("set_model_unload_timeout", { timeout }); }, @@ -921,7 +956,22 @@ bindings?: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk?: boolean * upgrading from before this key existed are blanked by the migration so they * see the current release's notes — see `apply_settings_migrations`. */ +<<<<<<< HEAD whats_new_last_seen_version?: string; selected_model?: string; onboarding_completed?: boolean; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; theme?: Theme; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; paste_delay_after_ms?: number; typing_tool?: TypingTool; external_script_path?: string | null; custom_filler_words?: string[] | null; transcribe_accelerator?: TranscribeAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; transcribe_gpu_device?: number; extra_recording_buffer_ms?: number; duck_volume_percent?: number; duck_fade_ms?: number; vad_enabled?: boolean; +======= +whats_new_last_seen_version?: string; selected_model?: string; onboarding_completed?: boolean; always_on_microphone?: boolean; selected_microphone?: string | null; +/** + * Which input channel to use on the selected microphone device. + * None means "average all channels" (original behavior). + */ +selected_channel?: number | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; theme?: Theme; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; paste_delay_after_ms?: number; +/** + * Debug-gated ("beta") receipt-sequenced paste: restore the clipboard only + * after the target app actually reads the transcript, instead of after a + * fixed delay. See `paste_tx`. macOS and Windows only. + */ +reliable_paste?: boolean; typing_tool?: TypingTool; external_script_path?: string | null; custom_filler_words?: string[] | null; transcribe_accelerator?: TranscribeAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; transcribe_gpu_device?: number; extra_recording_buffer_ms?: number; vad_enabled?: boolean; +>>>>>>> tags/v0.9.5 /** * Which recording overlay to show: None / Minimal / Live. Streaming mode is * not gated on this — that follows model capability. Migrated from the old @@ -952,6 +1002,11 @@ export type ImplementationChangeResult = { success: boolean; * List of binding IDs that were reset to defaults due to incompatibility */ reset_bindings: string[] } +export type KeyboardDiagnosticReport = { secure_input_enabled: boolean; culprit_pid: number | null; culprit_name: string | null; +/** + * Counts only — key identity is deliberately never captured. + */ +key_down: number; key_up: number; flags_changed: number; mouse: number; duration_ms: number } export type KeyboardImplementation = "tauri" | "handy_keys" export type LLMPrompt = { id: string; name: string; prompt: string } export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" @@ -997,6 +1052,38 @@ export type PermissionAccess = "allowed" | "denied" | "unknown" export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean } export type RecordingRetentionPeriod = "never" | "preserve_limit" | "days_3" | "weeks_2" | "months_3" export type SecretMap = Partial<{ [key in string]: string }> +export type SecureInputStatus = { +/** + * Secure input is currently enabled (live check) + */ +enabled: boolean; +/** + * Enabled continuously long enough to be considered stuck (not just a + * password field gaining momentary focus) + */ +sustained: boolean; culprit_pid: number | null; culprit_name: string | null; +/** + * Carbon fallback registrations are currently active + */ +fallback_active: boolean; +/** + * Binding ids shadow-registered with identical semantics + */ +covered_bindings: string[]; +/** + * Side-specific binding ids widened to match either side while shadowed + */ +degraded_bindings: string[]; +/** + * Binding ids that cannot fire at all (e.g. fn+key, registration failure) + */ +uncovered_bindings: string[]; +/** + * The user tried to record a shortcut while secure input was active. + * Treated as user impact even when every binding is covered, so the + * warning banner appears and explains why recording refused. + */ +recorder_blocked: boolean } export type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string } export type SoundTheme = "marimba" | "pop" | "custom" /** diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000000..f4c7e28585 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,35 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +interface ErrorBoundaryProps { + children: ReactNode; + context: string; +} + +interface ErrorBoundaryState { + failed: boolean; +} + +/** Prevents a non-critical UI subtree from blanking the entire app. */ +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { failed: false }; + + static getDerivedStateFromError(): ErrorBoundaryState { + return { failed: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error( + `Error rendering ${this.props.context}:`, + error, + info.componentStack, + ); + } + + render(): ReactNode { + if (this.state.failed) return null; + return this.props.children; + } +} diff --git a/src/components/SecureInputWarning.tsx b/src/components/SecureInputWarning.tsx new file mode 100644 index 0000000000..e064f7c196 --- /dev/null +++ b/src/components/SecureInputWarning.tsx @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { listen } from "@tauri-apps/api/event"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { ExternalLink, TriangleAlert, X } from "lucide-react"; +import { commands, type SecureInputStatus } from "@/bindings"; + +// Detailed remediation steps live in the docs rather than in the banner +export const SECURE_INPUT_HELP_URL = + "https://handy.computer/docs/troubleshooting#shortcuts-stopped-working-on-macos-secure-input"; + +/** + * Compact warning banner shown while macOS Secure Input is stuck on. + * + * Secure Input (password fields, Terminal's "Secure Keyboard Entry", a stuck + * loginwindow) blocks key events from reaching Handy's keyboard listener, so + * keyed shortcuts silently stop firing (issue #1578). The backend monitor + * emits `secure-input-changed` on state transitions; `sustained` filters out + * the normal momentary activation from focusing a password field. + */ +const SecureInputWarning: React.FC = () => { + const { t } = useTranslation(); + const [status, setStatus] = useState(null); + const [dismissed, setDismissed] = useState(false); + + const refresh = useCallback(async () => { + try { + setStatus(await commands.getSecureInputStatus()); + } catch (e) { + console.warn("Failed to fetch secure input status:", e); + } + }, []); + + useEffect(() => { + refresh(); + const unlisten = listen( + "secure-input-changed", + (event) => setStatus(event.payload), + ); + return () => { + unlisten.then((fn) => fn()); + }; + }, [refresh]); + + // Only warn when the user is actually impacted: a binding is degraded + // (side-specific matching widened) or dead (e.g. fn+key), or they ran into + // the blocked shortcut recorder. When the fallback covers everything + // transparently — and nothing else surfaced — stay silent; the backend + // still logs. + const impacted = + status !== null && + ((status.sustained && + (status.degraded_bindings.length > 0 || + status.uncovered_bindings.length > 0)) || + status.recorder_blocked); + + // A dismissal lasts for the current episode only: once the condition + // clears, the next occurrence warns again. The tray badge is the + // persistent indicator and is not dismissible. + useEffect(() => { + if (!impacted) { + setDismissed(false); + } + }, [impacted]); + + if (!impacted || dismissed) { + return null; + } + + const affectedCount = new Set([ + ...status.uncovered_bindings, + ...status.degraded_bindings, + ]).size; + const countSuffix = affectedCount === 1 ? "one" : "other"; + const message = + affectedCount > 0 + ? status.culprit_name !== null + ? t(`secureInput.blockedWithCulprit_${countSuffix}`, { + name: status.culprit_name, + count: affectedCount, + }) + : t(`secureInput.blockedNoCulprit_${countSuffix}`, { + count: affectedCount, + }) + : status.culprit_name !== null + ? t("secureInput.recorderBlockedWithCulprit", { + name: status.culprit_name, + }) + : t("secureInput.recorderBlockedNoCulprit"); + + return ( +
+
+ +

+ {message} +

+
+ + +
+
+
+ ); +}; + +export default SecureInputWarning; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index d70c8dafd1..95ec0c6190 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -38,6 +38,12 @@ export const SECTIONS_CONFIG = { component: GeneralSettings, enabled: () => true, }, + history: { + labelKey: "sidebar.history", + icon: History, + component: HistorySettings, + enabled: () => true, + }, models: { labelKey: "sidebar.models", icon: Cpu, @@ -50,12 +56,6 @@ export const SECTIONS_CONFIG = { component: AdvancedSettings, enabled: () => true, }, - history: { - labelKey: "sidebar.history", - icon: History, - component: HistorySettings, - enabled: () => true, - }, postprocessing: { labelKey: "sidebar.postProcessing", icon: Sparkles, diff --git a/src/components/settings/AppLanguageSelector.tsx b/src/components/settings/AppLanguageSelector.tsx index 3c41aa1a08..69be863159 100644 --- a/src/components/settings/AppLanguageSelector.tsx +++ b/src/components/settings/AppLanguageSelector.tsx @@ -2,7 +2,11 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; -import { SUPPORTED_LANGUAGES, type SupportedLanguageCode } from "../../i18n"; +import { + SUPPORTED_LANGUAGES, + getSupportedLanguage, + type SupportedLanguageCode, +} from "../../i18n"; import { useSettings } from "@/hooks/useSettings"; interface AppLanguageSelectorProps { @@ -15,7 +19,7 @@ export const AppLanguageSelector: React.FC = const { t, i18n } = useTranslation(); const { settings, updateSetting } = useSettings(); - const currentLanguage = (settings?.app_language || + const currentLanguage = (getSupportedLanguage(settings?.app_language) || i18n.language) as SupportedLanguageCode; const languageOptions = SUPPORTED_LANGUAGES.map((lang) => ({ diff --git a/src/components/settings/ChannelSelector.tsx b/src/components/settings/ChannelSelector.tsx new file mode 100644 index 0000000000..2f64afedd2 --- /dev/null +++ b/src/components/settings/ChannelSelector.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Dropdown } from "../ui/Dropdown"; +import { SettingContainer } from "../ui/SettingContainer"; +import { commands } from "@/bindings"; +import { useSettings } from "../../hooks/useSettings"; + +interface ChannelSelectorProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const ChannelSelector: React.FC = React.memo( + ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); + const { getSetting, updateSetting, isUpdating, isLoading } = useSettings(); + const [channelCount, setChannelCount] = useState(1); + + const selectedMicrophone = getSetting("selected_microphone") || "default"; + const selectedChannel = getSetting("selected_channel"); + + useEffect(() => { + let cancelled = false; + setChannelCount(1); + + const fetchChannels = async () => { + try { + const deviceName = + selectedMicrophone === "Default" ? "default" : selectedMicrophone; + const result = await commands.getMicrophoneChannels(deviceName); + if (!cancelled && result.status === "ok") { + setChannelCount(result.data); + } + } catch (error) { + console.error("Failed to get microphone channel count:", error); + } + }; + + void fetchChannels(); + return () => { + cancelled = true; + }; + }, [selectedMicrophone]); + + // Don't render if the device only has one channel. + if (channelCount <= 1) { + return null; + } + + const handleChannelSelect = async (value: string) => { + const channel = value === "average" ? null : parseInt(value, 10); + await updateSetting("selected_channel", channel); + }; + + const options = [ + { value: "average", label: t("settings.sound.channel.average") }, + ...Array.from({ length: channelCount }, (_, index) => ({ + value: index.toString(), + label: t("settings.sound.channel.channel", { n: index + 1 }), + })), + ]; + + // An old selection may not exist on a newly selected device. The recorder + // also falls back to averaging in that case, so reflect that effective value. + const currentValue = + selectedChannel == null || selectedChannel >= channelCount + ? "average" + : selectedChannel.toString(); + + return ( + + + + ); + }, +); + +ChannelSelector.displayName = "ChannelSelector"; diff --git a/src/components/settings/GlobalShortcutInput.tsx b/src/components/settings/GlobalShortcutInput.tsx index 537a782a55..71997fc219 100644 --- a/src/components/settings/GlobalShortcutInput.tsx +++ b/src/components/settings/GlobalShortcutInput.tsx @@ -124,6 +124,10 @@ export const GlobalShortcutInput: React.FC = ({ } } + // Re-register all bindings (the one just committed is already + // registered; re-registering it fails cleanly and is ignored) + await commands.resumeAllBindings().catch(console.error); + // Exit editing mode and reset states setEditingShortcutId(null); setKeyPressed([]); @@ -146,9 +150,8 @@ export const GlobalShortcutInput: React.FC = ({ console.error("Failed to restore original binding:", error); toast.error(t("settings.general.shortcut.errors.restore")); } - } else if (editingShortcutId) { - commands.resumeBinding(editingShortcutId).catch(console.error); } + await commands.resumeAllBindings().catch(console.error); setEditingShortcutId(null); setKeyPressed([]); setRecordedKeys([]); @@ -180,8 +183,9 @@ export const GlobalShortcutInput: React.FC = ({ const startRecording = async (id: string) => { if (editingShortcutId === id) return; // Already editing this shortcut - // Suspend current binding to avoid firing while recording - await commands.suspendBinding(id).catch(console.error); + // Suspend all bindings so no shortcut fires (or swallows the + // keystrokes) while keys are being recorded + await commands.suspendAllBindings().catch(console.error); // Store the original binding to restore if canceled setOriginalBinding(bindings[id]?.current_binding || ""); diff --git a/src/components/settings/HandyKeysShortcutInput.tsx b/src/components/settings/HandyKeysShortcutInput.tsx index d5f5ed0350..5a1b4b58a1 100644 --- a/src/components/settings/HandyKeysShortcutInput.tsx +++ b/src/components/settings/HandyKeysShortcutInput.tsx @@ -8,6 +8,8 @@ import { useSettings } from "../../hooks/useSettings"; import { useOsType } from "../../hooks/useOsType"; import { commands } from "@/bindings"; import { toast } from "sonner"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { SECURE_INPUT_HELP_URL } from "../SecureInputWarning"; interface HandyKeysShortcutInputProps { descriptionMode?: "inline" | "tooltip"; @@ -39,6 +41,13 @@ export const HandyKeysShortcutInput: React.FC = ({ const unlistenRef = useRef<(() => void) | null>(null); // Use a ref to track currentKeys for the event handler (avoids stale closure) const currentKeysRef = useRef(""); + // Track keyed vs modifier-only captures separately so a combo commits only + // on its key's release and a modifier-only shortcut only once every + // modifier is released. Committing on the *first* release (the old + // behavior) silently saved just the modifier whenever the key event never + // arrived — e.g. while macOS Secure Input is active (issue #1578). + const keyedShortcutRef = useRef(""); + const modifierOnlyShortcutRef = useRef(""); const osType = useOsType(); const bindings = getSetting("bindings") || {}; @@ -69,6 +78,8 @@ export const HandyKeysShortcutInput: React.FC = ({ setIsRecording(false); setCurrentKeys(""); currentKeysRef.current = ""; + keyedShortcutRef.current = ""; + modifierOnlyShortcutRef.current = ""; setOriginalBinding(""); }, [isRecording, originalBinding, shortcutId, updateBinding, t]); @@ -80,51 +91,78 @@ export const HandyKeysShortcutInput: React.FC = ({ const setupListener = async () => { // Listen for key events from backend + const commitAndStop = async (keysToCommit: string) => { + try { + await updateBinding(shortcutId, keysToCommit); + } catch (error) { + console.error("Failed to change binding:", error); + toast.error( + t("settings.general.shortcut.errors.set", { + error: String(error), + }), + ); + + // Reset to original binding on error + if (originalBinding) { + try { + await updateBinding(shortcutId, originalBinding); + } catch (resetError) { + console.error("Failed to reset binding:", resetError); + toast.error(t("settings.general.shortcut.errors.reset")); + } + } + } + + // Stop recording + if (unlistenRef.current) { + unlistenRef.current(); + unlistenRef.current = null; + } + await commands.stopHandyKeysRecording().catch(console.error); + setIsRecording(false); + setCurrentKeys(""); + currentKeysRef.current = ""; + keyedShortcutRef.current = ""; + modifierOnlyShortcutRef.current = ""; + setOriginalBinding(""); + }; + const unlisten = await listen( "handy-keys-event", async (event) => { if (cleanup) return; - const { hotkey_string, is_key_down } = event.payload; + const { hotkey_string, is_key_down, key, modifiers } = event.payload; if (is_key_down && hotkey_string) { - // Update both state (for display) and ref (for release handler) + // Update both state (for display) and refs (for release handler) + if (key) { + keyedShortcutRef.current = hotkey_string; + } else { + modifierOnlyShortcutRef.current = hotkey_string; + } currentKeysRef.current = hotkey_string; setCurrentKeys(hotkey_string); - } else if (!is_key_down && currentKeysRef.current) { - // Key released - commit the shortcut using the ref value - const keysToCommit = currentKeysRef.current; - try { - await updateBinding(shortcutId, keysToCommit); - } catch (error) { - console.error("Failed to change binding:", error); - toast.error( - t("settings.general.shortcut.errors.set", { - error: String(error), - }), - ); - - // Reset to original binding on error - if (originalBinding) { - try { - await updateBinding(shortcutId, originalBinding); - } catch (resetError) { - console.error("Failed to reset binding:", resetError); - toast.error(t("settings.general.shortcut.errors.reset")); - } - } + } else if (!is_key_down && key) { + // The main key was released — commit the keyed combo. The release + // event's hotkey_string still contains the key, so it works even + // if the key-down was somehow missed. Never fall back to a + // modifier-only capture here: that's how bindings used to get + // silently overwritten with just the modifier (issue #1578). + const keysToCommit = keyedShortcutRef.current || hotkey_string; + if (keysToCommit) { + await commitAndStop(keysToCommit); } - - // Stop recording - if (unlistenRef.current) { - unlistenRef.current(); - unlistenRef.current = null; - } - await commands.stopHandyKeysRecording().catch(console.error); - setIsRecording(false); - setCurrentKeys(""); - currentKeysRef.current = ""; - setOriginalBinding(""); + } else if ( + !is_key_down && + !key && + modifiers.length === 0 && + !keyedShortcutRef.current && + modifierOnlyShortcutRef.current + ) { + // Every modifier released without a main key ever going down — + // commit as a modifier-only shortcut + await commitAndStop(modifierOnlyShortcutRef.current); } }, ); @@ -176,12 +214,34 @@ export const HandyKeysShortcutInput: React.FC = ({ // Store the original binding to restore if canceled setOriginalBinding(bindings[shortcutId]?.current_binding || ""); - // Start backend recording + // Start backend recording. The backend refuses while macOS Secure Input + // is active (the recorder's listener would receive no key events and + // capture just the modifier) — it also flips the warning banner on, so + // the toast points at a visible explanation. try { - await commands.startHandyKeysRecording(shortcutId); + const result = await commands.startHandyKeysRecording(shortcutId); + if (result.status === "error") { + if (String(result.error).includes("secure-input-active")) { + toast.error(t("secureInput.recorderBlocked"), { + action: { + label: t("secureInput.learnMore"), + onClick: () => openUrl(SECURE_INPUT_HELP_URL), + }, + }); + } else { + toast.error( + t("settings.general.shortcut.errors.set", { + error: String(result.error), + }), + ); + } + return; + } setIsRecording(true); setCurrentKeys(""); currentKeysRef.current = ""; + keyedShortcutRef.current = ""; + modifierOnlyShortcutRef.current = ""; } catch (error) { console.error("Failed to start recording:", error); toast.error( diff --git a/src/components/settings/debug/DebugSettings.tsx b/src/components/settings/debug/DebugSettings.tsx index de6d6297e0..dd65ae5740 100644 --- a/src/components/settings/debug/DebugSettings.tsx +++ b/src/components/settings/debug/DebugSettings.tsx @@ -4,6 +4,7 @@ import { WordCorrectionThreshold } from "./WordCorrectionThreshold"; import { LogLevelSelector } from "./LogLevelSelector"; import { LiveLogViewer } from "./LiveLogViewer"; import { PasteDelay } from "./PasteDelay"; +import { ReliablePasteToggle } from "./ReliablePaste"; import { RecordingBuffer } from "./RecordingBuffer"; import { SettingsGroup } from "../../ui/SettingsGroup"; import { AlwaysOnMicrophone } from "../AlwaysOnMicrophone"; @@ -11,6 +12,7 @@ import { SoundPicker } from "../SoundPicker"; import { ClamshellMicrophoneSelector } from "../ClamshellMicrophoneSelector"; import { UpdateChecksToggle } from "../UpdateChecksToggle"; import { WhatsNewPreview } from "./WhatsNewPreview"; +import { KeyboardDiagnostic } from "./KeyboardDiagnostic"; export const DebugSettings: React.FC = () => { const { t } = useTranslation(); @@ -34,9 +36,11 @@ export const DebugSettings: React.FC = () => { labelKey="settings.debug.pasteDelayAfter.title" descriptionKey="settings.debug.pasteDelayAfter.description" /> + + diff --git a/src/components/settings/debug/KeyboardDiagnostic.tsx b/src/components/settings/debug/KeyboardDiagnostic.tsx new file mode 100644 index 0000000000..a333359a17 --- /dev/null +++ b/src/components/settings/debug/KeyboardDiagnostic.tsx @@ -0,0 +1,120 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { commands, type KeyboardDiagnosticReport } from "@/bindings"; +import { useOsType } from "../../../hooks/useOsType"; + +/** + * Count-only keyboard capture test (macOS). + * + * Opens a short-lived listener and tallies how many key-down / key-up / + * modifier / mouse events reach Handy — never *which* keys were pressed. + * The signature of stuck Secure Input (issue #1578) is modifier events + * flowing while key-down stays at zero. + */ +export const KeyboardDiagnostic: React.FC = () => { + const { t } = useTranslation(); + const osType = useOsType(); + const [running, setRunning] = useState(false); + const [report, setReport] = useState(null); + const [error, setError] = useState(null); + + if (osType !== "macos") { + return null; + } + + const runDiagnostic = async () => { + setRunning(true); + setReport(null); + setError(null); + try { + const result = await commands.runKeyboardDiagnostic(10); + if (result.status === "ok") { + setReport(result.data); + } else { + setError(result.error); + } + } catch (e) { + setError(String(e)); + } finally { + setRunning(false); + } + }; + + const verdict = (r: KeyboardDiagnosticReport): string => { + if (r.secure_input_enabled && r.key_down === 0) { + return t("settings.debug.keyboardDiagnostic.verdictBlocked"); + } + if (!r.secure_input_enabled && r.key_down === 0 && r.flags_changed > 0) { + return t("settings.debug.keyboardDiagnostic.verdictSuspicious"); + } + if (r.key_down === 0 && r.flags_changed === 0 && r.mouse === 0) { + return t("settings.debug.keyboardDiagnostic.verdictNoEvents"); + } + return t("settings.debug.keyboardDiagnostic.verdictOk"); + }; + + const secureInputLine = (r: KeyboardDiagnosticReport): string => { + const state = r.secure_input_enabled + ? t("settings.debug.keyboardDiagnostic.enabled") + : t("settings.debug.keyboardDiagnostic.disabled"); + if (!r.secure_input_enabled) { + return state; + } + const holder = + r.culprit_name !== null + ? t("settings.debug.keyboardDiagnostic.holder", { + name: r.culprit_name, + pid: r.culprit_pid, + }) + : t("settings.debug.keyboardDiagnostic.holderUnknown"); + return `${state} — ${holder}`; + }; + + return ( +
+
+
+

+ {t("settings.debug.keyboardDiagnostic.title")} +

+

+ {t("settings.debug.keyboardDiagnostic.description")} +

+
+ +
+ {running && ( +

+ {t("settings.debug.keyboardDiagnostic.running")} +

+ )} + {error !== null && ( +

+ {t("settings.debug.keyboardDiagnostic.failed", { error })} +

+ )} + {report !== null && ( +
+

+ {t("settings.debug.keyboardDiagnostic.secureInputLabel")}:{" "} + {secureInputLine(report)} +

+

+ {t("settings.debug.keyboardDiagnostic.keyDown")}: {report.key_down}{" "} + · {t("settings.debug.keyboardDiagnostic.keyUp")}: {report.key_up} ·{" "} + {t("settings.debug.keyboardDiagnostic.flagsChanged")}:{" "} + {report.flags_changed} ·{" "} + {t("settings.debug.keyboardDiagnostic.mouse")}: {report.mouse} +

+

{verdict(report)}

+
+ )} +
+ ); +}; diff --git a/src/components/settings/debug/PasteDelay.tsx b/src/components/settings/debug/PasteDelay.tsx index 8bc33a5728..28e24479f7 100644 --- a/src/components/settings/debug/PasteDelay.tsx +++ b/src/components/settings/debug/PasteDelay.tsx @@ -21,7 +21,7 @@ export const PasteDelay: React.FC = ({ descriptionKey = "settings.debug.pasteDelay.description", }) => { const { t } = useTranslation(); - const { settings, updateSetting } = useSettings(); + const { settings, updateSetting, resetSetting, isUpdating } = useSettings(); const handleDelayChange = (value: number) => { updateSetting(settingKey, value); @@ -31,6 +31,8 @@ export const PasteDelay: React.FC = ({ resetSetting(settingKey)} + isResetting={isUpdating(settingKey)} min={10} max={500} step={10} diff --git a/src/components/settings/debug/RecordingBuffer.tsx b/src/components/settings/debug/RecordingBuffer.tsx index 4bfc0f0d6b..c5cc1389ad 100644 --- a/src/components/settings/debug/RecordingBuffer.tsx +++ b/src/components/settings/debug/RecordingBuffer.tsx @@ -13,7 +13,7 @@ export const RecordingBuffer: React.FC = ({ grouped = false, }) => { const { t } = useTranslation(); - const { settings, updateSetting } = useSettings(); + const { settings, updateSetting, resetSetting, isUpdating } = useSettings(); const handleBufferChange = (value: number) => { updateSetting("extra_recording_buffer_ms", value); @@ -23,6 +23,8 @@ export const RecordingBuffer: React.FC = ({ resetSetting("extra_recording_buffer_ms")} + isResetting={isUpdating("extra_recording_buffer_ms")} min={0} max={1500} step={50} diff --git a/src/components/settings/debug/ReliablePaste.tsx b/src/components/settings/debug/ReliablePaste.tsx new file mode 100644 index 0000000000..7dc9b0d8ee --- /dev/null +++ b/src/components/settings/debug/ReliablePaste.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ToggleSwitch } from "../../ui/ToggleSwitch"; +import { useSettings } from "../../../hooks/useSettings"; +import { useOsType } from "../../../hooks/useOsType"; + +interface ReliablePasteToggleProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const ReliablePasteToggle: React.FC = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const { t } = useTranslation(); + const { getSetting, updateSetting, isUpdating } = useSettings(); + const osType = useOsType(); + + // The receipt-sequenced paste path is implemented for macOS and Windows. + if (osType !== "macos" && osType !== "windows") { + return null; + } + + return ( + updateSetting("reliable_paste", enabled)} + isUpdating={isUpdating("reliable_paste")} + label={t("settings.debug.reliablePaste.title")} + description={t("settings.debug.reliablePaste.description")} + descriptionMode={descriptionMode} + grouped={grouped} + /> + ); +}; diff --git a/src/components/settings/debug/WordCorrectionThreshold.tsx b/src/components/settings/debug/WordCorrectionThreshold.tsx index 5945d93727..5de9fdab99 100644 --- a/src/components/settings/debug/WordCorrectionThreshold.tsx +++ b/src/components/settings/debug/WordCorrectionThreshold.tsx @@ -12,7 +12,7 @@ export const WordCorrectionThreshold: React.FC< WordCorrectionThresholdProps > = ({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); - const { settings, updateSetting } = useSettings(); + const { settings, updateSetting, resetSetting, isUpdating } = useSettings(); const handleThresholdChange = (value: number) => { updateSetting("word_correction_threshold", value); @@ -22,6 +22,8 @@ export const WordCorrectionThreshold: React.FC< resetSetting("word_correction_threshold")} + isResetting={isUpdating("word_correction_threshold")} min={0.0} max={1.0} label={t("settings.debug.wordCorrectionThreshold.title")} diff --git a/src/components/settings/general/GeneralSettings.tsx b/src/components/settings/general/GeneralSettings.tsx index e010c21b0c..34bfb655e6 100644 --- a/src/components/settings/general/GeneralSettings.tsx +++ b/src/components/settings/general/GeneralSettings.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { type } from "@tauri-apps/plugin-os"; import { MicrophoneSelector } from "../MicrophoneSelector"; +import { ChannelSelector } from "../ChannelSelector"; import { ShortcutInput } from "../ShortcutInput"; import { SettingsGroup } from "../../ui/SettingsGroup"; import { OutputDeviceSelector } from "../OutputDeviceSelector"; @@ -34,6 +35,7 @@ export const GeneralSettings: React.FC = () => { + {isMacOS && ( <> diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index c50132ae10..5397d1082a 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -9,6 +9,7 @@ export { ModelsSettings } from "./models/ModelsSettings"; // Individual setting components export { MicrophoneSelector } from "./MicrophoneSelector"; +export { ChannelSelector } from "./ChannelSelector"; export { ClamshellMicrophoneSelector } from "./ClamshellMicrophoneSelector"; export { OutputDeviceSelector } from "./OutputDeviceSelector"; export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone"; diff --git a/src/components/settings/models/ModelsSettings.tsx b/src/components/settings/models/ModelsSettings.tsx index 41d7a0e8bf..9554594400 100644 --- a/src/components/settings/models/ModelsSettings.tsx +++ b/src/components/settings/models/ModelsSettings.tsx @@ -1,7 +1,14 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ask } from "@tauri-apps/plugin-dialog"; -import { ChevronDown, Globe, RefreshCw, Search } from "lucide-react"; +import { + AudioLines, + ChevronDown, + Globe, + Languages, + RefreshCw, + Search, +} from "lucide-react"; import type { ModelCardStatus } from "@/components/onboarding"; import { ModelCard } from "@/components/onboarding"; import { useModelStore } from "@/stores/modelStore"; @@ -27,6 +34,8 @@ export const ModelsSettings: React.FC = () => { const { t } = useTranslation(); const [switchingModelId, setSwitchingModelId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); + const [filterStreaming, setFilterStreaming] = useState(false); + const [filterTranslation, setFilterTranslation] = useState(false); const [languageFilter, setLanguageFilter] = useState("all"); const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false); const [languageSearch, setLanguageSearch] = useState(""); @@ -164,7 +173,7 @@ export const ModelsSettings: React.FC = () => { } }; - // Filter models by search query (name + description) and language filter + // Filter models by search query (name + description), language filter, and toggles const filteredModels = useMemo(() => { const q = searchQuery.trim().toLowerCase(); return models.filter((model: ModelInfo) => { @@ -173,13 +182,16 @@ export const ModelsSettings: React.FC = () => { if (languageFilter !== "all") { if (!modelSupportsLanguage(model, languageFilter)) return false; } + if (filterStreaming && !model.supports_streaming) return false; + if (filterTranslation && !model.supports_translation) return false; + if (q) { const haystack = `${model.name} ${model.description}`.toLowerCase(); if (!haystack.includes(q)) return false; } return true; }); - }, [models, languageFilter, searchQuery]); + }, [models, languageFilter, filterStreaming, filterTranslation, searchQuery]); // Split filtered models into downloaded (including custom) and available sections const { downloadedModels, availableModels } = useMemo(() => { @@ -246,125 +258,175 @@ export const ModelsSettings: React.FC = () => { /> - {filteredModels.length > 0 ? ( -
- {/* Downloaded Models Section — header always visible so filter stays accessible */} -
-
-

- {t("settings.models.yourModels")} -

-
- {/* Rescan local sources for models added outside Handy */} +
+ {/* Downloaded Models Section — header always visible so filter stays accessible */} +
+
+

+ {t("settings.models.yourModels")} +

+
+ {/* Rescan local sources for models added outside Handy */} + + + {/* Vertical divider separating action from filters */} +
+ + + {/* Language filter dropdown */} +
- {/* Language filter dropdown */} -
- - {languageDropdownOpen && ( -
-
- setLanguageSearch(e.target.value)} - onKeyDown={(e) => { - if ( - e.key === "Enter" && - filteredLanguages.length > 0 - ) { - setLanguageFilter(filteredLanguages[0].value); - setLanguageDropdownOpen(false); - setLanguageSearch(""); - } else if (e.key === "Escape") { - setLanguageDropdownOpen(false); - setLanguageSearch(""); - } - }} - placeholder={t( - "settings.general.language.searchPlaceholder", - )} - className="w-full px-2 py-1 text-sm bg-mid-gray/10 border border-mid-gray/40 rounded-md focus:outline-none focus:ring-1 focus:ring-logo-primary" - /> -
-
+ {languageDropdownOpen && ( +
+
+ setLanguageSearch(e.target.value)} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + filteredLanguages.length > 0 + ) { + setLanguageFilter(filteredLanguages[0].value); + setLanguageDropdownOpen(false); + setLanguageSearch(""); + } else if (e.key === "Escape") { + setLanguageDropdownOpen(false); + setLanguageSearch(""); + } + }} + placeholder={t( + "settings.general.language.searchPlaceholder", + )} + className="w-full px-2 py-1 text-sm bg-mid-gray/10 border border-mid-gray/40 rounded-md focus:outline-none focus:ring-1 focus:ring-logo-primary" + /> +
+
+ + {filteredLanguages.map((lang) => ( - {filteredLanguages.map((lang) => ( - - ))} - {filteredLanguages.length === 0 && ( -
- {t("settings.general.language.noResults")} -
- )} -
+ ))} + {filteredLanguages.length === 0 && ( +
+ {t("settings.general.language.noResults")} +
+ )}
- )} -
+
+ )}
- {downloadedModels.map((model: ModelInfo) => ( +
+ {downloadedModels.map((model: ModelInfo) => ( + + ))} +
+ + {/* Available Models Section */} + {availableModels.length > 0 && ( +
+

+ {t("settings.models.availableModels")} +

+ {availableModels.map((model: ModelInfo) => ( { onCancel={handleModelCancel} downloadProgress={getDownloadProgress(model.id)} downloadSpeed={getDownloadSpeed(model.id)} - showRecommended={false} + showRecommended={true} /> ))}
- - {/* Available Models Section */} - {availableModels.length > 0 && ( -
-

- {t("settings.models.availableModels")} -

- {availableModels.map((model: ModelInfo) => ( - - ))} -
- )} -
- ) : ( -
- {t("settings.models.noModelsMatch")} -
- )} + )} + {filteredModels.length === 0 && ( +
+ {t("settings.models.noModelsMatch")} +
+ )} +
); }; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index df92fdc907..056cbb4962 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -5,6 +5,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes { | "primary" | "primary-soft" | "secondary" + | "warning" | "danger" | "danger-ghost" | "ghost"; @@ -28,6 +29,11 @@ export const Button: React.FC = ({ "text-text bg-logo-primary/20 border-transparent hover:bg-logo-primary/30 focus:ring-1 focus:ring-logo-primary", secondary: "bg-mid-gray/10 border-mid-gray/20 hover:bg-background-ui/30 hover:border-logo-primary focus:outline-none", + // Secondary's neutral resting look, but hover/focus use the semantic + // --color-warning token (theme.css) instead of the pink accent — for + // buttons sitting on warning surfaces like SecureInputWarning + warning: + "text-text bg-mid-gray/10 border-mid-gray/20 hover:bg-warning/15 hover:border-warning focus:ring-1 focus:ring-warning", danger: "text-white bg-red-600 border-mid-gray/20 hover:bg-red-700 hover:border-red-700 focus:ring-1 focus:ring-red-500", "danger-ghost": diff --git a/src/components/ui/Slider.tsx b/src/components/ui/Slider.tsx index aaa50bc476..b3fdc8eba3 100644 --- a/src/components/ui/Slider.tsx +++ b/src/components/ui/Slider.tsx @@ -1,5 +1,6 @@ import React from "react"; import { SettingContainer } from "./SettingContainer"; +import { ResetButton } from "./ResetButton"; interface SliderProps { value: number; @@ -14,6 +15,8 @@ interface SliderProps { grouped?: boolean; showValue?: boolean; formatValue?: (value: number) => string; + onReset?: () => void; + isResetting?: boolean; } export const Slider: React.FC = ({ @@ -29,6 +32,8 @@ export const Slider: React.FC = ({ grouped = false, showValue = true, formatValue = (v) => v.toFixed(2), + onReset, + isResetting = false, }) => { const handleChange = (e: React.ChangeEvent) => { onChange(parseFloat(e.target.value)); @@ -67,6 +72,9 @@ export const Slider: React.FC = ({ {formatValue(value)} )} + {onReset && ( + + )}
diff --git a/src/components/update-checker/UpdateChecker.tsx b/src/components/update-checker/UpdateChecker.tsx index a104ebf4d5..aea87bf6d9 100644 --- a/src/components/update-checker/UpdateChecker.tsx +++ b/src/components/update-checker/UpdateChecker.tsx @@ -4,9 +4,14 @@ import { check } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; import { listen } from "@tauri-apps/api/event"; import { openUrl } from "@tauri-apps/plugin-opener"; +import { arch, platform } from "@tauri-apps/plugin-os"; import { ProgressBar } from "../shared"; import { useSettings } from "../../hooks/useSettings"; import { commands } from "../../bindings"; +import { + resolvePortableInstallerUrl, + PORTABLE_RELEASES_URL, +} from "./portableInstaller"; interface UpdateCheckerProps { className?: string; @@ -22,6 +27,9 @@ const UpdateChecker: React.FC = ({ className = "" }) => { const [showUpToDate, setShowUpToDate] = useState(false); const [showPortableUpdateDialog, setShowPortableUpdateDialog] = useState(false); + const [portableInstallerUrl, setPortableInstallerUrl] = useState( + PORTABLE_RELEASES_URL, + ); const { settings, isLoading } = useSettings(); const settingsLoaded = !isLoading && settings !== null; @@ -72,6 +80,11 @@ const UpdateChecker: React.FC = ({ className = "" }) => { if (update) { setUpdateAvailable(true); setShowUpToDate(false); + // Portable installs can't self-update in place — the manual dialog links + // straight at the matching installer from this manifest instead. + setPortableInstallerUrl( + resolvePortableInstallerUrl(update.rawJson, platform(), arch()), + ); } else { setUpdateAvailable(false); @@ -182,6 +195,10 @@ const UpdateChecker: React.FC = ({ className = "" }) => { const isUpdateClickable = !isUpdateDisabled && (updateAvailable || (!isChecking && !showUpToDate)); + // When no installer could be resolved for this target the button falls back to + // the releases index, so the dialog has to say "browse" rather than "download". + const hasDirectInstaller = portableInstallerUrl !== PORTABLE_RELEASES_URL; + return ( <> {showPortableUpdateDialog && ( @@ -191,7 +208,9 @@ const UpdateChecker: React.FC = ({ className = "" }) => { {t("footer.portableUpdateTitle")}

- {t("footer.portableUpdateMessage")} + {hasDirectInstaller + ? t("footer.portableUpdateMessage") + : t("footer.portableUpdateBrowseMessage")}

diff --git a/src/components/update-checker/portableInstaller.test.ts b/src/components/update-checker/portableInstaller.test.ts new file mode 100644 index 0000000000..18b15603f9 --- /dev/null +++ b/src/components/update-checker/portableInstaller.test.ts @@ -0,0 +1,65 @@ +// Standalone assert check (no JS unit-test runner in this repo). Run with: +// bun src/components/update-checker/portableInstaller.test.ts +import assert from "node:assert"; +import { + resolvePortableInstallerUrl, + PORTABLE_RELEASES_URL, +} from "./portableInstaller"; + +const X64_SETUP = + "https://github.com/cjpais/Handy/releases/download/v0.9.5/Handy_0.9.5_x64-setup.exe"; +const ARM64_SETUP = + "https://github.com/cjpais/Handy/releases/download/v0.9.5/Handy_0.9.5_arm64-setup.exe"; + +// Trimmed copy of the real latest.json served from the updater endpoint. +const manifest = { + version: "0.9.5", + platforms: { + "windows-x86_64-nsis": { url: X64_SETUP, signature: "…" }, + "windows-x86_64-msi": { + url: "https://github.com/cjpais/Handy/releases/download/v0.9.5/Handy_0.9.5_x64_en-US.msi", + }, + "windows-aarch64-nsis": { url: ARM64_SETUP, signature: "…" }, + "darwin-aarch64": { + url: "https://github.com/cjpais/Handy/releases/download/v0.9.5/Handy_aarch64.app.tar.gz", + }, + }, +}; + +// x64 Windows -> the x64 NSIS asset, pinned to the release tag +assert.equal( + resolvePortableInstallerUrl(manifest, "windows", "x86_64"), + X64_SETUP, +); + +// arm64 Windows -> the arm64 NSIS asset +assert.equal( + resolvePortableInstallerUrl(manifest, "windows", "aarch64"), + ARM64_SETUP, +); + +// no manifest (check() failed or returned no update) -> releases page fallback +assert.equal( + resolvePortableInstallerUrl(undefined, "windows", "x86_64"), + PORTABLE_RELEASES_URL, +); + +// no NSIS bundle for this arch -> releases page fallback +assert.equal( + resolvePortableInstallerUrl(manifest, "windows", "x86"), + PORTABLE_RELEASES_URL, +); + +// non-Windows portable install -> releases page, never a Windows .exe +assert.equal( + resolvePortableInstallerUrl(manifest, "macos", "aarch64"), + PORTABLE_RELEASES_URL, +); + +// malformed manifest -> releases page fallback +assert.equal( + resolvePortableInstallerUrl({ platforms: "nope" }, "windows", "x86_64"), + PORTABLE_RELEASES_URL, +); + +console.log("portableInstaller: all assertions passed"); diff --git a/src/components/update-checker/portableInstaller.ts b/src/components/update-checker/portableInstaller.ts new file mode 100644 index 0000000000..0cce528a48 --- /dev/null +++ b/src/components/update-checker/portableInstaller.ts @@ -0,0 +1,41 @@ +// Portable installs can't self-update in place (no installer, and Windows won't +// let a running exe replace itself). Instead of dumping the user on the releases +// page to hand-pick one of ~27 assets, deep-link the NSIS setup.exe for their +// platform and architecture. +// +// The URL comes straight out of the updater manifest (`Update.rawJson`) rather +// than being rebuilt from the bundler's file-naming convention: it stays correct +// if asset names or the repo slug change, and it points at the immutable +// `releases/download/v/…` tag URL instead of a moving `latest` link. + +export const PORTABLE_RELEASES_URL = + "https://github.com/cjpais/Handy/releases/latest"; + +/** + * Pick the NSIS installer URL for the running target out of the update manifest. + * Falls back to the generic releases page whenever there is no matching entry — + * e.g. a portable install on a platform Handy ships no NSIS bundle for. + * + * @param rawJson `Update.rawJson`, the deserialized `latest.json` manifest + * @param platformName value from `@tauri-apps/plugin-os` `platform()` + * @param archName value from `@tauri-apps/plugin-os` `arch()` ("x86_64", "aarch64") + */ +export function resolvePortableInstallerUrl( + rawJson: Record | undefined, + platformName: string, + archName: string, +): string { + // NSIS is a Windows-only bundle; nothing else has an installer to link to. + if (platformName !== "windows") return PORTABLE_RELEASES_URL; + + const platforms = rawJson?.platforms; + if (!platforms || typeof platforms !== "object") return PORTABLE_RELEASES_URL; + + const entry = (platforms as Record)[ + `windows-${archName}-nsis` + ]; + if (!entry || typeof entry !== "object") return PORTABLE_RELEASES_URL; + + const url = (entry as Record).url; + return typeof url === "string" ? url : PORTABLE_RELEASES_URL; +} diff --git a/src/components/whats-new/MarkdownContent.tsx b/src/components/whats-new/MarkdownContent.tsx index bb1f055e3c..7ece8c3ee3 100644 --- a/src/components/whats-new/MarkdownContent.tsx +++ b/src/components/whats-new/MarkdownContent.tsx @@ -1,6 +1,5 @@ import React from "react"; import ReactMarkdown, { type Components } from "react-markdown"; -import remarkGfm from "remark-gfm"; import { openUrl } from "@tauri-apps/plugin-opener"; interface MarkdownContentProps { @@ -12,25 +11,17 @@ const allowedElements = [ "blockquote", "br", "code", - "del", "em", "h1", "h2", "h3", "hr", "img", - "input", "li", "ol", "p", "pre", "strong", - "table", - "tbody", - "td", - "th", - "thead", - "tr", "ul", ]; @@ -77,51 +68,19 @@ const components: Components = { p: ({ children }) => (

{children}

), - ul: ({ children, className }) => { - const isTaskList = className?.includes("contains-task-list"); - - return ( -
    - {children} -
- ); - }, - li: ({ children, className }) => { - const isTaskListItem = className?.includes("task-list-item"); - - return ( -
  • - {children} -
  • - ); - }, - input: ({ checked, type }) => { - if (type !== "checkbox") return null; - - return ( - - ); - }, + ul: ({ children }) => ( +
      + {children} +
    + ), + li: ({ children }) => ( +
  • {children}
  • + ), ol: ({ children }) => (
      {children}
    ), - del: ({ children }) => ( - {children} - ), br: () =>
    , hr: () =>
    , img: ({ alt, src }) => { @@ -137,24 +96,6 @@ const components: Components = { /> ); }, - table: ({ children }) => ( -
    - - {children} -
    -
    - ), - thead: ({ children }) => ( - {children} - ), - tbody: ({ children }) => ( - {children} - ), - tr: ({ children }) => {children}, - th: ({ children }) => ( - {children} - ), - td: ({ children }) => {children}, blockquote: ({ children }) => (
    {children} @@ -211,7 +152,6 @@ export const MarkdownContent: React.FC = ({ {markdown} diff --git a/src/content/release-notes/README.md b/src/content/release-notes/README.md index 4abd0f9547..3aeb543868 100644 --- a/src/content/release-notes/README.md +++ b/src/content/release-notes/README.md @@ -11,9 +11,9 @@ persisted `whats_new_last_seen_version` and not newer than the running app version. Keep these files focused on headline user-facing changes. Release notes support -paragraphs, headings, lists, links, code, quotes, strikethrough, tables, task -lists, separators, hard line breaks, and local images under -`/release-notes/...`. Raw HTML is ignored before rendering. +paragraphs, headings, lists, links, code, quotes, separators, hard line breaks, +and local images under `/release-notes/...`. Raw HTML is ignored before +rendering. Place image assets in `public/release-notes/{version}/` and reference them from Markdown with absolute paths: diff --git a/src/i18n/index.ts b/src/i18n/index.ts index cf5fd3f2bc..107e7ce001 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -52,20 +52,34 @@ export const SUPPORTED_LANGUAGES = Object.keys(resources) export type SupportedLanguageCode = string; // Check if a language code is supported -const getSupportedLanguage = ( +export const getSupportedLanguage = ( langCode: string | null | undefined, ): SupportedLanguageCode | null => { if (!langCode) return null; - const normalized = langCode.toLowerCase(); + + const normalized = langCode.toLowerCase().replace(/_/g, "-"); + const subtags = normalized.split("-"); + const language = subtags[0]; + const isHant = subtags.includes("hant"); + const isHans = subtags.includes("hans"); + const isTraditionalRegion = ["tw", "hk", "mo"].some((region) => + subtags.includes(region), + ); + // Try exact match first let supported = SUPPORTED_LANGUAGES.find( (lang) => lang.code.toLowerCase() === normalized, ); if (!supported) { - // Fall back to prefix match (language only, without region) - const prefix = normalized.split("-")[0]; + let fallback = language; + if (language === "zh" && (isHant || (!isHans && isTraditionalRegion))) { + fallback = "zh-tw"; + } else if (language === "yue") { + // Cantonese uses Traditional Chinese unless explicitly tagged as Hans. + fallback = isHans ? "zh" : "zh-tw"; + } supported = SUPPORTED_LANGUAGES.find( - (lang) => lang.code.toLowerCase() === prefix, + (lang) => lang.code.toLowerCase() === fallback, ); } return supported ? supported.code : null; diff --git a/src/i18n/languages.ts b/src/i18n/languages.ts index 2b6a197351..d16bf1612b 100644 --- a/src/i18n/languages.ts +++ b/src/i18n/languages.ts @@ -39,4 +39,5 @@ export const LANGUAGE_METADATA: Record< nl: { name: "Dutch", nativeName: "Nederlands", priority: 21 }, ne: { name: "Nepali", nativeName: "नेपाली", priority: 22 }, hi: { name: "Hindi", nativeName: "हिन्दी", priority: 23 }, + da: { name: "Danish", nativeName: "Dansk", priority: 24 }, }; diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 36777991de..b85246ba61 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -6,7 +6,8 @@ "unloadModel": "تفريغ النموذج", "model": "النموذج", "quit": "إنهاء", - "cancel": "إلغاء" + "cancel": "إلغاء", + "secureInputWarning": "⚠ الاختصارات محجوبة بواسطة Secure Input" }, "sidebar": { "general": "عام", @@ -19,10 +20,10 @@ }, "onboarding": { "subtitle": "للبدء، اختر نموذج التفريغ الصوتي", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "النماذج المتوافقة", + "downloadModelsTitle": "متاح للتنزيل", + "showAllModels": "عرض كل النماذج ({{total}})", + "showFewerModels": "عرض عدد أقل من النماذج", "recommended": "موصى به", "customModelDescription": "غير مدعوم رسميًا", "modelCard": { @@ -96,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "فشل تحديد النموذج" }, "permissions": { "title": "الأذونات المطلوبة", @@ -145,7 +146,7 @@ "capabilities": { "languageSelection": "يدعم اختيار اللغة", "singleLanguage": "يدعم هذه اللغة فقط", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} لغة", "languageOnly": "{{language}} فقط", "translation": "يدعم الترجمة", "translate": "ترجمة", @@ -203,6 +204,12 @@ "placeholder": "...اختر الميكروفون", "loading": "...جاري التحميل" }, + "channel": { + "title": "قناة الإدخال", + "description": "اختر قناة الإدخال التي تريد التسجيل منها. استخدم هذا الخيار إذا كانت واجهة الصوت لديك تحتوي على عدة مداخل.", + "average": "متوسط جميع القنوات", + "channel": "القناة {{n}}" + }, "audioFeedback": { "label": "تنبيهات صوتية", "description": "تشغيل صوت عند بدء التسجيل وتوقفه" @@ -270,12 +277,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "التراكب", + "description": "اختر تراكب التسجيل: «بلا» يخفيه، و«مبسط» يعرض شارة صغيرة، و«مباشر» يعرض النص المكتوب فوريًا أثناء التحدث (النماذج التي تدعم البث فقط — ابحث عن شارة «البث» في منتقي النماذج). على Linux يُنصح باختيار «بلا».", "options": { "none": "بلا", - "minimal": "Minimal", - "live": "Live" + "minimal": "مبسط", + "live": "مباشر" } }, "position": { @@ -353,8 +360,8 @@ "duplicate": "\"{{word}}\" موجود بالفعل" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "كشف النشاط الصوتي", + "description": "تصفية الصمت من التسجيلات. النماذج التي تدعم البث تستخدم فترة VAD أطول؛ وتعطيل VAD يسجّل الصوت الخام." } }, "postProcessing": { @@ -512,10 +519,34 @@ "title": "تأخير اللصق (بعد)", "description": "التأخير (بالمللي ثانية) بعد ضغطة مفتاح اللصق وقبل استعادة محتوى الحافظة السابق. قم بزيادته إذا تم لصق محتوى الحافظة القديم بدلاً من النص المُفرَّغ." }, + "reliablePaste": { + "title": "لصق موثوق (Beta)", + "description": "استعادة الحافظة فقط بعد أن يقرأ التطبيق المستهدف النص المُفرَّغ فعلياً، بدلاً من الاعتماد على تأخير ثابت. يهدف إلى إصلاح لصق محتوى الحافظة القديم عند وجود حمل على النظام. يتطلب طريقة لصق تعتمد على الحافظة؛ على macOS وWindows فقط." + }, "recordingBuffer": { "title": "مخزن التسجيل الإضافي", "description": "وقت إضافي (بالمللي ثانية) للاستمرار في التسجيل بعد تحرير المفتاح، لالتقاط الصوت المتبقي. 0 = لا مخزن إضافي." }, + "keyboardDiagnostic": { + "title": "تشخيص لوحة المفاتيح", + "description": "يتحقق مما إذا كانت أحداث لوحة المفاتيح تصل إلى Handy. يُسجَّل عدد الأحداث فقط — ولا تُسجَّل أبدًا المفاتيح التي تضغط عليها.", + "run": "تشغيل تشخيص لمدة 10 ثوانٍ", + "running": "جارٍ الاستماع… اضغط اختصارك عدة مرات (مثل Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "مُفعَّل", + "disabled": "مُعطَّل", + "holder": "يحتجزه {{name}} (pid {{pid}})", + "holderUnknown": "لا يوجد محتجِز ظاهر", + "keyDown": "ضغط مفتاح", + "keyUp": "تحرير مفتاح", + "flagsChanged": "مفاتيح التعديل", + "mouse": "الماوس", + "verdictBlocked": "يحجب Secure Input أحداث المفاتيح — لا يمكن للاختصارات التي تتضمن مفتاحًا عاديًا أن تعمل حتى تُحلّ المشكلة.", + "verdictSuspicious": "وصلت أحداث مفاتيح التعديل لكن لم يصل أي حدث مفتاح — هناك شيء يمنع المفاتيح رغم أن Secure Input يظهر معطلاً. يُرجى الإبلاغ عن ذلك على GitHub.", + "verdictOk": "أحداث المفاتيح تصل إلى Handy بشكل طبيعي.", + "verdictNoEvents": "لم يُسجَّل أي حدث — هل ضغطت أي مفاتيح أثناء الاختبار؟", + "failed": "فشل التشخيص: {{error}}" + }, "whatsNewPreview": { "title": "معاينة الجديد", "description": "افتح أحدث ملاحظات إصدار مضمنة دون وضع علامة عليها كمقروءة", @@ -558,7 +589,7 @@ } }, "modelSettings": { - "title": "إعدادات النموذج" + "title": "إعدادات {{model}}" }, "models": { "title": "نماذج النسخ", @@ -569,7 +600,9 @@ "deleteActiveConfirm": "{{modelName}} هو النموذج النشط حاليًا. حذفه سيوقف النسخ حتى تختار نموذجًا جديدًا. هل أنت متأكد؟", "deleteTitle": "حذف النموذج", "filters": { - "allLanguages": "جميع اللغات" + "allLanguages": "جميع اللغات", + "streaming": "تصفية النماذج التي تدعم النسخ المباشر", + "translation": "تصفية النماذج التي تدعم الترجمة إلى الإنجليزية" }, "noModelsMatch": "لا توجد نماذج مطابقة لهذا الفلتر.", "searchPlaceholder": "ابحث عن النماذج بالاسم…", @@ -589,8 +622,10 @@ "preparing": "...جاري التحضير", "checkForUpdates": "التحقق من وجود تحديثات", "portableUpdateTitle": "التحديث اليدوي مطلوب", - "portableUpdateMessage": "لا يمكن تحديث التثبيتات المحمولة تلقائيًا. للتحديث: نزّل أحدث مثبّت NSIS من GitHub Releases، وثبّته في المجلد نفسه، ثم انسخ مجلد Data/ (الإعدادات والنماذج والتسجيلات) من الإصدار القديم إلى الجديد.", - "portableUpdateButton": "فتح GitHub Releases" + "portableUpdateMessage": "لا يمكن تحديث التثبيتات المحمولة تلقائيًا. نزّل المثبّت المناسب لنظامك من الزر أدناه، شغّله، وثبّته في المجلد نفسه — يبقى مجلد Data/ (الإعدادات والنماذج والتسجيلات) في مكانه.", + "portableUpdateButton": "تنزيل المثبّت", + "portableUpdateBrowseMessage": "لا يمكن تحديث التثبيتات المحمولة تلقائيًا. نزّل أحدث إصدار مناسب لنظامك من GitHub Releases وثبّته في المجلد نفسه — يبقى مجلد Data/ (الإعدادات والنماذج والتسجيلات) في مكانه.", + "portableUpdateBrowseButton": "فتح GitHub Releases" }, "common": { "loading": "...جاري التحميل", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "الجديد في Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "قد يحظر {{name}} اختصارًا واحدًا", + "blockedWithCulprit_other": "قد يحظر {{name}} {{count}} اختصارات", + "blockedNoCulprit_one": "يحظر macOS مؤقتًا اختصارًا واحدًا", + "blockedNoCulprit_other": "يحظر macOS مؤقتًا {{count}} اختصارات", + "recorderBlockedWithCulprit": "قد يمنع {{name}} تغيير الاختصارات", + "recorderBlockedNoCulprit": "يمنع macOS مؤقتًا تغيير الاختصارات", + "learnMore": "كيفية الإصلاح", + "recorderBlocked": "لا يمكن تسجيل الاختصارات الآن — يحجب Secure Input في macOS أحداث المفاتيح. عالج تحذير Secure Input أولاً.", + "dismiss": "تجاهل" } } diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index 3889eca210..8168368a08 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Разтоварване на модела", "model": "Модел", "quit": "Изход", - "cancel": "Отказ" + "cancel": "Отказ", + "secureInputWarning": "⚠ Бързите клавиши са блокирани от Secure Input" }, "sidebar": { "general": "Общи", @@ -19,10 +20,10 @@ }, "onboarding": { "subtitle": "За да започнете, изберете модел за транскрипция", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Съвместими модели", + "downloadModelsTitle": "Достъпни за изтегляне", + "showAllModels": "Показване на всички {{total}} модела", + "showFewerModels": "Показване на по-малко модели", "recommended": "Препоръчан", "customModelDescription": "Не се поддържа официално", "modelCard": { @@ -96,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Неуспешно избиране на модел" }, "permissions": { "title": "Необходими разрешения", @@ -145,7 +146,7 @@ "capabilities": { "languageSelection": "Поддържа няколко входни езика", "singleLanguage": "Поддържа само този език", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} езика", "languageOnly": "Само {{language}}", "translation": "Може да превежда на английски", "translate": "Превод на английски", @@ -207,7 +208,9 @@ "deleteActiveConfirm": "{{modelName}} е вашият активен модел. Изтриването му ще спре транскрипциите, докато не изберете нов модел. Сигурни ли сте?", "deleteTitle": "Изтриване на модел", "filters": { - "allLanguages": "Всички езици" + "allLanguages": "Всички езици", + "streaming": "Филтриране на моделите, които поддържат транскрипция на живо", + "translation": "Филтриране на моделите, които поддържат превод на английски" }, "noModelsMatch": "Няма модели, отговарящи на този филтър.", "searchPlaceholder": "Търсене на модели по име…", @@ -224,6 +227,12 @@ "placeholder": "Изберете микрофон...", "loading": "Зареждане..." }, + "channel": { + "title": "Входен канал", + "description": "Изберете входния канал, от който да записвате. Използвайте тази опция, ако аудио интерфейсът ви има няколко входа.", + "average": "Осредняване на всички канали", + "channel": "Канал {{n}}" + }, "audioFeedback": { "label": "Звукова обратна връзка", "description": "Възпроизвеждане на звук при старт и край на записа" @@ -291,12 +300,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Наслагване", + "description": "Изберете наслагването при запис: «Няма» го скрива, «Минимално» показва компактен индикатор, «На живо» показва транскрипцията в реално време, докато говорите (само модели с поддръжка на стрийминг — потърсете етикета «Стрийминг» в избора на модел). На Linux се препоръчва «Няма».", "options": { "none": "Няма", - "minimal": "Minimal", - "live": "Live" + "minimal": "Минимално", + "live": "На живо" } }, "position": { @@ -374,8 +383,8 @@ "duplicate": "„{{word}}“ вече съществува" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Откриване на гласова активност", + "description": "Филтрира тишината от записите. Моделите с поддръжка на стрийминг използват по-дълга опашка на VAD; изключването на VAD записва необработен звук." } }, "postProcessing": { @@ -533,10 +542,34 @@ "title": "Забавяне при поставяне (след)", "description": "Забавяне (в милисекунди) след клавиша за поставяне, преди възстановяване на предишния клипборд. Увеличете, ако вместо транскрипцията се поставя старото съдържание на клипборда." }, + "reliablePaste": { + "title": "Надеждно поставяне (Beta)", + "description": "Възстановява клипборда само след като целевото приложение действително прочете транскрипцията, вместо след фиксирано забавяне. Предназначено да отстрани поставянето на старото съдържание на клипборда при натоварване на системата. Изисква начин на поставяне през клипборда; само за macOS и Windows." + }, "recordingBuffer": { "title": "Допълнителен буфер на записа", "description": "Допълнително време (в милисекунди) за запис след пускане на клавиша, за да се улови краят на аудиото. 0 = без допълнителен буфер." }, + "keyboardDiagnostic": { + "title": "Диагностика на клавиатурата", + "description": "Проверява дали клавиатурните събития достигат до Handy. Записва се само броят на събитията — никога кои клавиши натискате.", + "run": "Стартиране на 10-секундна диагностика", + "running": "Слушане… натиснете бързия си клавиш няколко пъти (напр. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "включен", + "disabled": "изключен", + "holder": "задържан от {{name}} (pid {{pid}})", + "holderUnknown": "няма видим държател", + "keyDown": "Натиснат клавиш", + "keyUp": "Отпуснат клавиш", + "flagsChanged": "Модификатори", + "mouse": "Мишка", + "verdictBlocked": "Secure Input блокира клавишните събития — бързите клавиши с обикновен клавиш не могат да работят, докато това не бъде решено.", + "verdictSuspicious": "Пристигнаха събития от модификатори, но нито едно клавишно събитие — нещо потиска клавишите, въпреки че Secure Input се отчита като изключен. Моля, съобщете за това в GitHub.", + "verdictOk": "Клавишните събития достигат до Handy нормално.", + "verdictNoEvents": "Не са уловени събития — натиснахте ли някакви клавиши по време на теста?", + "failed": "Диагностиката е неуспешна: {{error}}" + }, "whatsNewPreview": { "title": "Преглед на новостите", "description": "Отваря последните включени бележки към изданието, без да ги маркира като видени", @@ -589,8 +622,10 @@ "preparing": "Подготовка...", "checkForUpdates": "Проверка за актуализации", "portableUpdateTitle": "Необходима е ръчна актуализация", - "portableUpdateMessage": "Преносимите инсталации не могат да бъдат актуализирани автоматично. За актуализация: изтеглете най-новия NSIS инсталатор от GitHub Releases, инсталирайте го в същата папка, след което копирайте папката Data/ (настройки, модели, записи) от старата версия в новата.", - "portableUpdateButton": "Отвори GitHub Releases" + "portableUpdateMessage": "Преносимите инсталации не могат да бъдат актуализирани автоматично. Изтеглете инсталатора за вашата система от бутона по-долу, стартирайте го и инсталирайте в същата папка — папката Data/ (настройки, модели, записи) остава на мястото си.", + "portableUpdateButton": "Изтегляне на инсталатора", + "portableUpdateBrowseMessage": "Преносимите инсталации не могат да бъдат актуализирани автоматично. Изтеглете най-новата версия за вашата система от GitHub Releases и я инсталирайте в същата папка — папката Data/ (настройки, модели, записи) остава на мястото си.", + "portableUpdateBrowseButton": "Отвори GitHub Releases" }, "common": { "loading": "Зареждане...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Ново в Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} може да блокира 1 бърз клавиш", + "blockedWithCulprit_other": "{{name}} може да блокира {{count}} бързи клавиша", + "blockedNoCulprit_one": "macOS временно блокира 1 бърз клавиш", + "blockedNoCulprit_other": "macOS временно блокира {{count}} бързи клавиша", + "recorderBlockedWithCulprit": "{{name}} може да блокира промените на бързите клавиши", + "recorderBlockedNoCulprit": "macOS временно блокира промените на бързите клавиши", + "learnMore": "Как да поправите това", + "recorderBlocked": "В момента не могат да се записват бързи клавиши — Secure Input на macOS блокира клавишните събития. Първо отстранете предупреждението за Secure Input.", + "dismiss": "Отхвърляне" } } diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index a2618b7488..b2e472ebef 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Uvolnit model", "model": "Model", "quit": "Ukončit", - "cancel": "Zrušit" + "cancel": "Zrušit", + "secureInputWarning": "⚠ Zkratky blokuje Secure Input" }, "sidebar": { "general": "Obecné", @@ -19,10 +20,10 @@ }, "onboarding": { "subtitle": "Pro začátek vyberte model pro přepis", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Kompatibilní modely", + "downloadModelsTitle": "Dostupné ke stažení", + "showAllModels": "Zobrazit všech {{total}} modelů", + "showFewerModels": "Zobrazit méně modelů", "recommended": "Doporučeno", "customModelDescription": "Oficiálně nepodporováno", "modelCard": { @@ -96,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Nepodařilo se vybrat model" }, "permissions": { "title": "Vyžadována oprávnění", @@ -142,7 +143,7 @@ "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "Podporuje více vstupních jazyků", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} jazyků", "translation": "Umí překládat do angličtiny", "translate": "Přeložit do angličtiny", "singleLanguage": "Podporuje pouze tento jazyk", @@ -164,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} je váš aktivní model. Jeho smazáním se zastaví přepisy, dokud nevyberete nový model. Opravdu chcete pokračovat?", "deleteTitle": "Smazat model", "filters": { - "allLanguages": "Všechny jazyky" + "allLanguages": "Všechny jazyky", + "streaming": "Filtrovat modely podporující živý přepis", + "translation": "Filtrovat modely podporující překlad do angličtiny" }, "noModelsMatch": "Tomuto filtru neodpovídají žádné modely.", "yourModels": "Stažené modely", @@ -209,7 +212,7 @@ "description": "Vyberte jazyk rozpoznávání řeči. Auto jazyk určí automaticky, zatímco výběr konkrétního jazyka může zlepšit přesnost.", "searchPlaceholder": "Hledat jazyky...", "noResults": "Žádné jazyky nenalezeny", - "auto": "Auto" + "auto": "Automaticky" }, "pushToTalk": { "label": "Stisk a mluv", @@ -224,6 +227,12 @@ "placeholder": "Vyberte mikrofon...", "loading": "Načítání..." }, + "channel": { + "title": "Vstupní kanál", + "description": "Vyberte vstupní kanál, ze kterého se má nahrávat. Tuto možnost použijte, pokud má vaše zvukové rozhraní více vstupů.", + "average": "Průměr všech kanálů", + "channel": "Kanál {{n}}" + }, "audioFeedback": { "label": "Zvuková odezva", "description": "Přehrát zvuk při zahájení a ukončení nahrávání" @@ -291,12 +300,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Překryv", + "description": "Vyberte překryv při nahrávání: „Žádné“ jej skryje, „Minimální“ zobrazí kompaktní indikátor, „Živě“ zobrazuje přepis v reálném čase, když mluvíte (pouze modely podporující streaming — hledejte odznak „Streaming“ ve výběru modelů). Na Linuxu se doporučuje „Žádné“.", "options": { "none": "Žádné", - "minimal": "Minimal", - "live": "Live" + "minimal": "Minimální", + "live": "Živě" } }, "position": { @@ -374,8 +383,8 @@ "duplicate": "\"{{word}}\" již existuje" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Detekce hlasové aktivity", + "description": "Odfiltruje ticho z nahrávek. Modely podporující streaming používají delší doběh VAD; vypnutí VAD nahrává surový zvuk." } }, "postProcessing": { @@ -533,10 +542,34 @@ "title": "Zpoždění vložení (po)", "description": "Zpoždění (v milisekundách) po klávese pro vložení, před obnovením předchozí schránky. Zvyšte, pokud se místo přepisu vkládá starý obsah schránky." }, + "reliablePaste": { + "title": "Spolehlivé vložení (Beta)", + "description": "Obnoví schránku teprve poté, co cílová aplikace skutečně přečte přepis, místo po pevně daném zpoždění. Má odstranit vkládání starého obsahu schránky při zatížení systému. Vyžaduje způsob vložení přes schránku; pouze macOS a Windows." + }, "recordingBuffer": { "title": "Extra vyrovnávací paměť nahrávání", "description": "Extra čas (v milisekundách) pro pokračování nahrávání po uvolnění klávesy, pro zachycení zbývajícího zvuku. 0 = žádná extra vyrovnávací paměť." }, + "keyboardDiagnostic": { + "title": "Diagnostika klávesnice", + "description": "Ověří, zda události klávesnice dorazí do Handy. Zaznamenává se pouze počet událostí — nikdy to, které klávesy stisknete.", + "run": "Spustit 10s diagnostiku", + "running": "Naslouchání… několikrát stiskněte svou zkratku (např. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "zapnuto", + "disabled": "vypnuto", + "holder": "drží ji {{name}} (pid {{pid}})", + "holderUnknown": "žádný viditelný držitel", + "keyDown": "Stisk klávesy", + "keyUp": "Uvolnění klávesy", + "flagsChanged": "Modifikátory", + "mouse": "Myš", + "verdictBlocked": "Secure Input blokuje události kláves — zkratky obsahující běžnou klávesu nemohou fungovat, dokud se to nevyřeší.", + "verdictSuspicious": "Dorazily události modifikátorů, ale žádné události kláves — něco klávesy potlačuje, přestože Secure Input hlásí, že je vypnutý. Nahlaste to prosím na GitHubu.", + "verdictOk": "Události kláves dorazí do Handy normálně.", + "verdictNoEvents": "Nebyly zachyceny žádné události — stiskli jste během testu nějaké klávesy?", + "failed": "Diagnostika selhala: {{error}}" + }, "whatsNewPreview": { "title": "Náhled novinek", "description": "Otevřít nejnovější přibalené poznámky k vydání bez označení jako zobrazené", @@ -589,8 +622,10 @@ "preparing": "Příprava...", "checkForUpdates": "Zkontrolovat aktualizace", "portableUpdateTitle": "Vyžadována ruční aktualizace", - "portableUpdateMessage": "Přenosné instalace nelze aktualizovat automaticky. Postup aktualizace: stáhněte nejnovější instalátor NSIS z GitHub Releases, nainstalujte jej do stejné složky a poté zkopírujte svou složku Data/ (nastavení, modely, nahrávky) ze staré verze do nové.", - "portableUpdateButton": "Otevřít GitHub Releases" + "portableUpdateMessage": "Přenosné instalace nelze aktualizovat automaticky. Níže stáhněte instalátor pro svůj systém, spusťte jej a nainstalujte do stejné složky — vaše složka Data/ (nastavení, modely, nahrávky) zůstane na místě.", + "portableUpdateButton": "Stáhnout instalátor", + "portableUpdateBrowseMessage": "Přenosné instalace nelze aktualizovat automaticky. Stáhněte nejnovější verzi pro svůj systém z GitHub Releases a nainstalujte ji do stejné složky — vaše složka Data/ (nastavení, modely, nahrávky) zůstane na místě.", + "portableUpdateBrowseButton": "Otevřít GitHub Releases" }, "common": { "loading": "Načítání...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Novinky v Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} může blokovat 1 zkratku", + "blockedWithCulprit_other": "{{name}} může blokovat {{count}} zkratek", + "blockedNoCulprit_one": "macOS dočasně blokuje 1 zkratku", + "blockedNoCulprit_other": "macOS dočasně blokuje {{count}} zkratek", + "recorderBlockedWithCulprit": "{{name}} může blokovat změny zkratek", + "recorderBlockedNoCulprit": "macOS dočasně blokuje změny zkratek", + "learnMore": "Jak to vyřešit", + "recorderBlocked": "Zkratky teď nelze nahrát — Secure Input v macOS blokuje události kláves. Nejprve vyřešte upozornění na Secure Input.", + "dismiss": "Zavřít" } } diff --git a/src/i18n/locales/da/translation.json b/src/i18n/locales/da/translation.json new file mode 100644 index 0000000000..16b2375823 --- /dev/null +++ b/src/i18n/locales/da/translation.json @@ -0,0 +1,683 @@ +{ + "tray": { + "settings": "Indstillinger...", + "checkUpdates": "Tjek for opdateringer...", + "copyLastTranscript": "Kopiér seneste transskription", + "unloadModel": "Frigør model", + "model": "Model", + "quit": "Afslut", + "cancel": "Annuller", + "secureInputWarning": "⚠ Genveje blokeret af Secure Input" + }, + "sidebar": { + "general": "Generelt", + "models": "Modeller", + "advanced": "Avanceret", + "postProcessing": "Efterbehandling", + "history": "Historik", + "debug": "Fejlfinding", + "about": "Om" + }, + "onboarding": { + "subtitle": "For at komme i gang skal du vælge en transskriptionsmodel", + "existingModelsTitle": "Kompatible modeller", + "downloadModelsTitle": "Tilgængelig til download", + "showAllModels": "Vis alle {{total}} modeller", + "showFewerModels": "Vis færre modeller", + "recommended": "Anbefalet", + "customModelDescription": "Ikke officielt understøttet", + "modelCard": { + "accuracy": "nøjagtighed", + "speed": "hastighed" + }, + "models": { + "small": { + "name": "Whisper Small", + "description": "Hurtig og ret nøjagtig." + }, + "medium": { + "name": "Whisper Medium", + "description": "God nøjagtighed, middel hastighed" + }, + "turbo": { + "name": "Whisper Turbo", + "description": "Afbalanceret nøjagtighed og hastighed." + }, + "large": { + "name": "Whisper Large", + "description": "God nøjagtighed, men langsom." + }, + "parakeet-tdt-0.6b-v2": { + "name": "Parakeet V2", + "description": "Kun engelsk. Den bedste model for engelsktalende." + }, + "parakeet-tdt-0.6b-v3": { + "name": "Parakeet V3", + "description": "Hurtig og nøjagtig" + }, + "moonshine-base": { + "name": "Moonshine Base", + "description": "Meget hurtig, kun engelsk. Håndterer accenter godt." + }, + "moonshine-tiny-streaming-en": { + "name": "Moonshine V2 Tiny", + "description": "Ultrahurtig, kun engelsk" + }, + "moonshine-small-streaming-en": { + "name": "Moonshine V2 Small", + "description": "Hurtig, kun engelsk. God balance mellem hastighed og nøjagtighed." + }, + "moonshine-medium-streaming-en": { + "name": "Moonshine V2 Medium", + "description": "Kun engelsk. Høj kvalitet." + }, + "breeze-asr": { + "name": "Breeze ASR", + "description": "Optimeret til taiwansk mandarin. Understøtter kodeskift." + }, + "sense-voice-int8": { + "name": "SenseVoice", + "description": "Meget hurtig. Kinesisk, engelsk, japansk, koreansk, kantonesisk." + }, + "gigaam-v3-e2e-ctc": { + "name": "GigaAM v3", + "description": "Russisk talegenkendelse. Hurtig og nøjagtig." + }, + "canary-180m-flash": { + "name": "Canary 180M Flash", + "description": "Meget hurtig. Engelsk, tysk, spansk, fransk. Understøtter oversættelse." + }, + "canary-1b-v2": { + "name": "Canary 1B v2", + "description": "Nøjagtig, flersproget. 25 europæiske sprog. Understøtter oversættelse." + }, + "cohere-int8": { + "name": "Cohere", + "description": "En stor, langsommere, men meget nøjagtig flersproget model." + } + }, + "errors": { + "selectModel": "Kunne ikke vælge model" + }, + "permissions": { + "title": "Tilladelser påkrævet", + "description": "Handy har brug for et par tilladelser for at fungere korrekt.", + "microphone": { + "title": "Mikrofonadgang", + "description": "Påkrævet for at høre din stemme til transskription." + }, + "accessibility": { + "title": "Tilgængelighedsadgang", + "description": "Påkrævet for at skrive den transskriberede tekst ind i dine programmer." + }, + "grant": "Giv tilladelse", + "granted": "Givet", + "waiting": "Venter...", + "allGranted": "Alt er klar!", + "errors": { + "checkFailed": "Kunne ikke tjekke tilladelser. Prøv igen.", + "requestFailed": "Kunne ikke anmode om tilladelse. Prøv igen." + } + } + }, + "modelSelector": { + "custom": "Brugerdefineret", + "legacy": "Forældet", + "streaming": "Streaming", + "active": "Aktiv", + "switching": "Skifter...", + "noModelsAvailable": "Ingen modeller tilgængelige", + "verifying": "Verificerer {{modelName}}...", + "verifyingGeneric": "Verificerer...", + "extracting": "Udpakker {{modelName}}...", + "extractingMultiple": "Udpakker {{count}} modeller...", + "extractingGeneric": "Udpakker...", + "downloading": "Downloader {{percentage}}%", + "downloadingMultiple": "Downloader {{count}} modeller...", + "modelReady": "Model klar", + "loading": "Indlæser {{modelName}}...", + "loadingGeneric": "Indlæser...", + "modelError": "Modelfejl", + "modelUnloaded": "Model frigjort", + "noModelDownloadRequired": "Ingen model - download påkrævet", + "deleteModel": "Slet {{modelName}}", + "downloadSpeed": "{{speed}} MB/s", + "cancel": "Annuller", + "cancelDownload": "Annuller download", + "capabilities": { + "languageSelection": "Understøtter flere inputsprog", + "singleLanguage": "Understøtter kun dette sprog", + "languageCount": "{{total}} sprog", + "languageOnly": "Kun {{language}}", + "translation": "Kan oversætte til engelsk", + "translate": "Oversæt", + "streaming": "Viser live transskription, mens du taler" + } + }, + "settings": { + "modelSettings": { + "title": "{{model}}-indstillinger" + }, + "general": { + "title": "Generelt", + "shortcut": { + "title": "Handy-genveje", + "description": "Konfigurer tastaturgenveje til at udløse tale-til-tekst-optagelse", + "loading": "Indlæser genveje...", + "none": "Ingen genveje konfigureret", + "notFound": "Genvej ikke fundet", + "pressKeys": "Tryk på ønskede taster...", + "bindings": { + "transcribe": { + "name": "Transskriptionsgenvej", + "description": "Tastaturgenvejen til at optage og transskribere din stemme." + }, + "cancel": { + "name": "Annulleringsgenvej", + "description": "Tastaturgenvejen til at annullere den aktuelle optagelse." + }, + "transcribe_with_post_process": { + "name": "Genvej til efterbehandling", + "description": "Valgfrit: En dedikeret genvej, der altid anvender AI-efterbehandling på din transskription." + } + }, + "errors": { + "restore": "Kunne ikke gendanne den oprindelige genvej", + "set": "Kunne ikke indstille genvej: {{error}}", + "reset": "Kunne ikke nulstille genvej til oprindelig indstilling" + } + }, + "language": { + "title": "Sprog", + "description": "Vælg sproget til talegenkendelse. Auto tyder automatisk sproget, mens valg af et specifikt sprog kan forbedre nøjagtigheden for det pågældende sprog.", + "searchPlaceholder": "Søg efter sprog...", + "noResults": "Ingen sprog fundet", + "auto": "Automatisk" + }, + "pushToTalk": { + "label": "Tryk-for-at-tale", + "description": "Hold nede for at optage, slip for at stoppe" + } + }, + "models": { + "title": "Transskriptionsmodeller", + "description": "Vælg en transskriptionsmodel, eller download flere modeller. Forskellige modeller tilbyder varierende niveauer af nøjagtighed og hastighed.", + "searchPlaceholder": "Søg efter modeller ved navn…", + "yourModels": "Downloadede modeller", + "availableModels": "Tilgængelig til download", + "deleteConfirm": "Er du sikker på, at du vil slette {{modelName}}? Du skal downloade den igen for at bruge den.", + "deleteActiveConfirm": "{{modelName}} er din aktive model. Sletning af den vil stoppe transskriptioner, indtil du vælger en ny model. Er du sikker?", + "deleteTitle": "Slet model", + "filters": { + "allLanguages": "Alle sprog", + "streaming": "Filtrer modeller, der understøtter live transskription", + "translation": "Filtrer modeller, der understøtter oversættelse til engelsk" + }, + "rescan": { + "label": "Søg igen", + "tooltip": "Søg igen efter modeller, der er tilføjet til modelmappen eller Hugging Face-cachen uden for Handy" + }, + "noModelsMatch": "Ingen modeller matcher dette filter." + }, + "sound": { + "title": "Lyd", + "microphone": { + "title": "Mikrofon", + "description": "Vælg din foretrukne mikrofonenhed", + "placeholder": "Vælg mikrofon...", + "loading": "Indlæser..." + }, + "channel": { + "title": "Inputkanal", + "description": "Vælg, hvilken inputkanal der skal optages fra. Brug dette, hvis din lydgrænseflade har flere indgange.", + "average": "Gennemsnit af alle kanaler", + "channel": "Kanal {{n}}" + }, + "audioFeedback": { + "label": "Lydfeedback", + "description": "Afspil lyd, når optagelse starter og stopper" + }, + "outputDevice": { + "title": "Outputenhed", + "description": "Vælg din foretrukne lydoutputenhed til feedbacklyde", + "placeholder": "Vælg outputenhed...", + "loading": "Indlæser..." + }, + "volume": { + "title": "Lydstyrke", + "description": "Juster lydstyrken for lydfeedback" + } + }, + "advanced": { + "groups": { + "app": "App", + "output": "Output", + "transcription": "Transskription", + "history": "Historik", + "experimental": "Eksperimentel" + }, + "experimentalToggle": { + "label": "Eksperimentelle funktioner", + "description": "Aktivér eksperimentelle funktioner, der stadig er under udvikling." + }, + "lazyStreamClose": { + "label": "Hold mikrofon åben mellem transskriptioner", + "description": "Holder mikrofonstrømmen åben i 30 sekunder, efter at optagelsen stopper, hvilket reducerer ventetid ved transskriptioner lige efter hinanden. Kan forringe Bluetooth-lydkvaliteten, mens den er aktiv." + }, + "acceleration": { + "transcribe": { + "title": "transcribe.cpp-acceleration", + "description": "Hardwareacceleration til transcribe.cpp-modeller (Whisper-familien). Auto bruger GPU, hvis tilgængelig (Metal på macOS, Vulkan på Windows/Linux)." + }, + "ort": { + "title": "ONNX-acceleration", + "description": "Hardwareacceleration til ONNX-modeller (Parakeet, Canary, Moonshine osv.). DirectML på Windows er eksperimentelt. Modeller kan fejle under transskription." + }, + "gpuDevice": { + "auto": "Automatisk" + } + }, + "startHidden": { + "label": "Start skjult", + "description": "Vis på meddelelsesområdet uden at åbne vinduet." + }, + "autostart": { + "label": "Start ved opstart", + "description": "Start Handy automatisk, når du logger ind på din computer." + }, + "showTrayIcon": { + "label": "Vis proceslinjeikon", + "description": "Vis Handy-ikonet i meddelelsesområdet." + }, + "overlay": { + "style": { + "title": "Overlay", + "description": "Vælg optagelses-overlay: Ingen skjuler det, Minimal viser en kompakt pille, Live viser transskription i realtid, mens du taler (kun modeller med streaming-understøttelse — se efter Streaming-mærket i modelvælgeren). På Linux anbefales 'Ingen'.", + "options": { + "none": "Ingen", + "minimal": "Minimal", + "live": "Live" + } + }, + "position": { + "title": "Overlay-placering", + "description": "Hvor overlayet vises på skærmen under optagelse og transskription.", + "options": { + "bottom": "Bund", + "top": "Top" + } + } + }, + "pasteMethod": { + "title": "Indsætningsmetode", + "description": "Vælg, hvordan tekst indsættes. Direkte: simulerer tastetryk via systeminput. Ingen: springer indsætning over, opdaterer kun historik/udklipsholder.", + "options": { + "clipboard": "Udklipsholder ({{modifier}}+V)", + "clipboardCtrlShiftV": "Udklipsholder (Ctrl+Shift+V)", + "clipboardShiftInsert": "Udklipsholder (Shift+Insert)", + "direct": "Direkte", + "none": "Ingen", + "externalScript": "Eksternt script" + }, + "externalScriptPlaceholder": "/sti/til/dit/script.sh" + }, + "typingTool": { + "title": "Skriveværktøj", + "description": "Vælg hvilket Linux-skriveværktøj der skal bruges til Direkte indsætningsmetode. Auto registrerer og bruger automatisk det bedste tilgængelige værktøj til dit system.", + "options": { + "auto": "Auto (anbefalet)" + } + }, + "clipboardHandling": { + "title": "Håndtering af udklipsholder", + "description": "Rør ikke ved udklipsholder bevarer dit nuværende udklipsholderindhold efter transskription. Kopiér til udklipsholder efterlader transskriptionsresultatet i din udklipsholder efter indsætning.", + "options": { + "dontModify": "Rør ikke ved udklipsholder", + "copyToClipboard": "Kopiér til udklipsholder" + } + }, + "autoSubmit": { + "title": "Automatisk indsendelse", + "description": "Send automatisk den valgte tastekombination efter tekstindsættelse. Cmd+Enter gælder på macOS, mens Windows/Linux bruger Super+Enter.", + "options": { + "off": "Fra", + "enter": "Enter", + "cmdEnter": "Cmd+Enter", + "superEnter": "Super+Enter", + "ctrlEnter": "Ctrl+Enter" + } + }, + "translateToEnglish": { + "label": "Oversæt til engelsk", + "description": "Oversæt automatisk tale fra andre sprog til engelsk under transskription." + }, + "modelUnload": { + "title": "Frigør model", + "description": "Frigør automatisk GPU-/CPU-hukommelse, når modellen ikke har været brugt i den angivne tid", + "options": { + "never": "Aldrig", + "immediately": "Med det samme", + "min2": "Efter 2 minutter", + "min5": "Efter 5 minutter", + "min10": "Efter 10 minutter", + "min15": "Efter 15 minutter", + "hour1": "Efter 1 time", + "sec15": "Efter 15 sekunder (fejlfinding)" + } + }, + "customWords": { + "title": "Brugerdefinerede ord", + "description": "Hjælp understøttede modeller med at genkende navne og specialiserede udtryk. Fuzzy-korrektion er i øjeblikket begrænset til ord, der bruger A-Z og tal.", + "placeholder": "Tilføj et ord", + "add": "Tilføj", + "remove": "Fjern {{word}}", + "duplicate": "\"{{word}}\" findes allerede" + }, + "voiceActivityDetection": { + "title": "Stemmeaktivitetsdetektion", + "description": "Filtrer stilhed fra optagelser. Streaming-modeller bruger en længere VAD-hale; deaktivering af VAD optager rå lyd." + } + }, + "postProcessing": { + "hotkey": { + "title": "Genvejstast" + }, + "api": { + "title": "API (OpenAI-kompatibel)", + "provider": { + "title": "Udbyder", + "description": "Vælg en OpenAI-kompatibel udbyder." + }, + "appleIntelligence": { + "unavailable": "Apple Intelligence er ikke tilgængelig på denne enhed. Kræver en Apple Silicon Mac med macOS Tahoe (26.0) eller nyere med Apple Intelligence aktiveret i Systemindstillinger." + }, + "baseUrl": { + "title": "Basis-URL", + "description": "API-basis-URL for den valgte udbyder. Kun den brugerdefinerede udbyder kan redigeres.", + "placeholder": "https://api.openai.com/v1" + }, + "apiKey": { + "title": "API-nøgle", + "description": "API-nøgle til den valgte udbyder.", + "placeholder": "sk-..." + }, + "model": { + "title": "Model", + "descriptionCustom": "Angiv den modelidentifikator, som det brugerdefinerede endpoint forventer.", + "descriptionDefault": "Vælg en model, der udbydes af den valgte udbyder.", + "placeholderWithOptions": "Søg eller vælg en model", + "placeholderNoOptions": "Skriv et modelnavn", + "refreshModels": "Opdater modeller" + } + }, + "prompts": { + "title": "Prompt", + "selectedPrompt": { + "title": "Valgt prompt", + "description": "Vælg en skabelon til at forfine transskriptioner, eller opret en ny. Brug ${output} i promptteksten for at referere til den fangede transskription." + }, + "noPrompts": "Ingen prompts tilgængelige", + "selectPrompt": "Vælg en prompt", + "createNew": "Opret ny prompt", + "promptLabel": "Promptnavn", + "promptLabelPlaceholder": "Indtast promptnavn", + "promptInstructions": "Promptinstruktioner", + "promptInstructionsPlaceholder": "Skriv de instruktioner, der skal køres efter transskription. Eksempel: Forbedr grammatik og klarhed for følgende tekst: ${output}", + "promptTip": "Tip: Brug ${output} til at indsætte den transskriberede tekst i din prompt.", + "updatePrompt": "Opdater prompt", + "deletePrompt": "Slet prompt", + "createPrompt": "Opret prompt", + "cancel": "Annuller", + "selectToEdit": "Vælg en prompt ovenfor for at se og redigere dens detaljer.", + "createFirst": "Klik på 'Opret ny prompt' ovenfor for at oprette din første efterbehandlingsprompt." + } + }, + "history": { + "title": "Historik", + "openFolder": "Åbn optagelsesmappe", + "loading": "Indlæser historik...", + "empty": "Ingen transskriptioner endnu. Start optagelse for at opbygge din historik!", + "copyToClipboard": "Kopiér transskription til udklipsholder", + "save": "Gem transskription", + "unsave": "Fjern fra gemte", + "delete": "Slet post", + "deleteError": "Kunne ikke slette posten. Prøv igen.", + "retranscribe": "Transskribér igen", + "retranscribeError": "Kunne ikke transskribere igen. Prøv igen.", + "transcribing": "Transskriberer...", + "transcriptionFailed": "Transskription mislykkedes. Du kan transskribere igen ved hjælp af genforsøgsikonet." + }, + "debug": { + "title": "Fejlfinding", + "logDirectory": { + "title": "Logmappe", + "description": "Placering af logfiler" + }, + "logLevel": { + "title": "Logniveau", + "description": "Detaljeringsgrad for logning" + }, + "liveLogs": { + "title": "Live logs", + "description": "Stream applikationslogs i realtid for at diagnosticere problemer uden at åbne logfilen. Kun logs udsendt, mens dette panel er åbent, vises; respekterer indstillingen Logniveau ovenfor.", + "live": "Live", + "paused": "På pause", + "pause": "Pause", + "resume": "Genoptag", + "copied": "Kopieret", + "lineCount": "{{count}} linjer", + "empty": "Venter på logs… Poster vises her, når appen udsender dem." + }, + "updateChecks": { + "label": "Tjek for opdateringer", + "description": "Tjek automatisk for nye versioner af Handy" + }, + "whatsNewPreview": { + "title": "Forhåndsvisning af nyheder", + "description": "Åbn de seneste medfølgende udgivelsesnoter uden at markere dem som set", + "button": "Forhåndsvisning", + "noNotes": "Ingen medfølgende udgivelsesnoter fundet", + "error": "Kunne ikke forhåndsvise udgivelsesnoter" + }, + "soundTheme": { + "label": "Lydtema", + "description": "Vælg et lydtema til feedback ved start og stop af optagelse" + }, + "wordCorrectionThreshold": { + "title": "Tærskel for ordkorrektion", + "description": "Følsomhed for brugerdefinerede ordkorrektioner" + }, + "historyLimit": { + "title": "Historikgrænse", + "description": "Maksimalt antal historikposter, der skal beholdes", + "entries": "poster" + }, + "recordingRetention": { + "title": "Automatisk sletning af optagelser", + "description": "Slet automatisk gamle optagelser for at spare plads", + "never": "Aldrig", + "preserveLimit": "Behold de seneste {{count}}", + "days3": "Efter 3 dage", + "weeks2": "Efter 2 uger", + "months3": "Efter 3 måneder", + "placeholder": "Vælg opbevaringsperiode..." + }, + "alwaysOnMicrophone": { + "label": "Altid aktiv mikrofon", + "description": "Hold mikrofonen aktiv for hurtigere reaktion" + }, + "clamshellMicrophone": { + "title": "Clamshell-mikrofon", + "description": "Mikrofon, der skal bruges, når den bærbares låg er lukket" + }, + "postProcessingToggle": { + "label": "Efterbehandling", + "description": "Aktivér AI-drevet tekstforbedring efter transskription" + }, + "muteWhileRecording": { + "label": "Mute under optagelse", + "description": "Sluk for systemlyd under optagelse" + }, + "appendTrailingSpace": { + "label": "Tilføj efterfølgende mellemrum", + "description": "Tilføj et mellemrum efter indsat transskription" + }, + "keyboardImplementation": { + "title": "Tastaturimplementering", + "description": "Vælg backend til tastaturgenveje.", + "bindingsReset": "Tastaturgenveje var inkompatible og blev nulstillet til standard" + }, + "paths": { + "appData": "Appdata:", + "models": "Modeller:", + "settings": "Indstillinger:" + }, + "pasteDelay": { + "title": "Forsinkelse af indsættelse (før)", + "description": "Forsinkelse (i millisekunder) efter kopiering af tekst, før tastetrykket for indsætning sendes. Øg, hvis intet bliver indsat." + }, + "pasteDelayAfter": { + "title": "Forsinkelse af indsættelse (efter)", + "description": "Forsinkelse (i millisekunder) efter tastetrykket for indsætning, før din tidligere udklipsholder gendannes. Øg, hvis dit gamle udklipsholderindhold bliver indsat i stedet for transskriptionen." + }, + "reliablePaste": { + "title": "Pålidelig indsættelse (Beta)", + "description": "Gendan først din udklipsholder, når målappen faktisk har læst transskriptionen, i stedet for efter en fast forsinkelse. Skal afhjælpe, at gammelt udklipsholderindhold indsættes, når systemet er belastet. Kræver en indsætningsmetode via udklipsholderen; kun macOS og Windows." + }, + "recordingBuffer": { + "title": "Ekstra optagelsesbuffer", + "description": "Ekstra tid (i millisekunder) til at fortsætte optagelsen, efter du slipper tasten, for at fange efterfølgende lyd. 0 = ingen ekstra buffer." + }, + "keyboardDiagnostic": { + "title": "Tastaturdiagnostik", + "description": "Kontrollerer, om tastaturhændelser når frem til Handy. Kun antallet af hændelser registreres — aldrig hvilke taster du trykker på.", + "run": "Kør 10 s diagnostik", + "running": "Lytter… tryk på din genvej et par gange (f.eks. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "aktiveret", + "disabled": "deaktiveret", + "holder": "holdes af {{name}} (pid {{pid}})", + "holderUnknown": "ingen synlig indehaver", + "keyDown": "Tast ned", + "keyUp": "Tast op", + "flagsChanged": "Modifikatorer", + "mouse": "Mus", + "verdictBlocked": "Secure Input blokerer tastehændelser — genveje med en almindelig tast kan ikke fungere, før det er løst.", + "verdictSuspicious": "Der kom modifikatorhændelser, men ingen tastehændelser — noget undertrykker taster, selvom Secure Input rapporteres som deaktiveret. Rapportér det gerne på GitHub.", + "verdictOk": "Tastehændelser når frem til Handy som normalt.", + "verdictNoEvents": "Ingen hændelser registreret — trykkede du på nogen taster under testen?", + "failed": "Diagnostik mislykkedes: {{error}}" + } + }, + "about": { + "title": "Om", + "version": { + "title": "Version", + "description": "Nuværende version af Handy" + }, + "whatsNewUpdates": { + "label": "Vis nyheder", + "description": "Vis udgivelsesnoter, efter Handy opdateres" + }, + "appDataDirectory": { + "title": "Appdatamappe", + "description": "Placering, hvor Handy gemmer sine data" + }, + "sourceCode": { + "title": "Kildekode", + "description": "Se kildekode og bidrag", + "button": "Vis på GitHub" + }, + "supportDevelopment": { + "title": "Støt udviklingen", + "description": "Hjælp os med at fortsætte udviklingen af Handy", + "button": "Donér" + }, + "acknowledgments": { + "title": "Anerkendelser", + "ggml": { + "title": "ggml", + "description": "Højtydende tensor-bibliotek til maskinlæringsinferens på enheden", + "details": "Handys lokale tale-til-tekst funktionalitet er bygget på transcribe.cpp og ggml. Tak for det fantastiske arbejde af Georgi Gerganov og bidragyderne." + } + } + } + }, + "footer": { + "checkingUpdates": "Tjekker for opdateringer...", + "updateAvailableShort": "Opdatering tilgængelig", + "upToDate": "Opdateret", + "updateCheckingDisabled": "Tjek for opdateringer deaktiveret", + "downloading": "Downloader... {{progress}}%", + "installing": "Installerer...", + "preparing": "Forbereder...", + "checkForUpdates": "Tjek for opdateringer", + "portableUpdateTitle": "Manuel opdatering påkrævet", + "portableUpdateMessage": "Flytbare installationer kan ikke opdateres automatisk. Download installeren til dit system nedenfor, kør den, og installer i den samme mappe — din Data/-mappe (indstillinger, modeller, optagelser) bliver bevaret.", + "portableUpdateButton": "Download installer", + "portableUpdateBrowseMessage": "Flytbare installationer kan ikke opdateres automatisk. Download den nyeste version til dit system fra GitHub Releases, og installer den i den samme mappe — din Data/-mappe (indstillinger, modeller, optagelser) bliver bevaret.", + "portableUpdateBrowseButton": "Åbn GitHub Releases" + }, + "whatsNew": { + "title": "Nyt i Handy v{{version}}" + }, + "common": { + "loading": "Indlæser...", + "delete": "Slet", + "close": "Luk", + "open": "Åbn", + "copy": "Kopiér", + "clear": "Ryd", + "noOptionsFound": "Ingen muligheder fundet" + }, + "accessibility": { + "permissionsDescription": "Handy har brug for tilgængelighedstilladelser for at skrive den transskriberede tekst.", + "openSettings": "Åbn systemindstillinger" + }, + "errors": { + "loadDirectory": "Fejl ved indlæsning af mappe: {{error}}", + "micPermissionDeniedTitle": "Mikrofonadgang nægtet", + "micPermissionDenied": { + "generic": "Mikrofonadgang blev nægtet af operativsystemet. Giv venligst mikrofontilladelse i dine systemindstillinger.", + "windows": "Aktivér mikrofonadgang under Indstillinger → Privatliv og sikkerhed → Mikrofon (inklusive adgang for skrivebordsapps).", + "macos": "Giv mikrofonadgang under Systemindstillinger → Privatliv og sikkerhed → Mikrofon.", + "linux": "Giv mikrofonadgang i dit systems lyd- eller privatlivsindstillinger." + }, + "noInputDeviceTitle": "Ingen mikrofon fundet", + "noInputDevice": "Ingen lydinputenhed blev registreret. Tilslut en mikrofon eller et headset, og prøv igen.", + "recordingFailed": "Kunne ikke starte optagelse: {{error}}", + "modelLoadFailed": "Kunne ikke indlæse model: {{model}}", + "modelLoadFailedUnknown": "ukendt model", + "pasteFailedTitle": "Kunne ikke indsætte tekst", + "pasteFailed": "Teksten kunne ikke indsættes i det aktive program.", + "transcriptionFailedTitle": "Transskription mislykkedes" + }, + "appLanguage": { + "title": "Applikationssprog", + "description": "Skift sproget for Handy-grænsefladen" + }, + "theme": { + "title": "Applikationstema", + "description": "Vælg om Handy skal følge dit systemtema eller forblive lyst eller mørkt", + "options": { + "system": "System", + "light": "Lys", + "dark": "Mørk" + } + }, + "overlay": { + "transcribing": "Transskriberer...", + "processing": "Behandler..." + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} blokerer muligvis 1 genvej", + "blockedWithCulprit_other": "{{name}} blokerer muligvis {{count}} genveje", + "blockedNoCulprit_one": "macOS blokerer midlertidigt 1 genvej", + "blockedNoCulprit_other": "macOS blokerer midlertidigt {{count}} genveje", + "recorderBlockedWithCulprit": "{{name}} blokerer muligvis ændringer af genveje", + "recorderBlockedNoCulprit": "macOS blokerer midlertidigt ændringer af genveje", + "learnMore": "Sådan løser du det", + "recorderBlocked": "Genveje kan ikke optages lige nu — macOS Secure Input blokerer tastehændelser. Løs advarslen om Secure Input først.", + "dismiss": "Afvis" + } +} diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 65633fca56..f2d76ca726 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Modell entladen", "model": "Modell", "quit": "Beenden", - "cancel": "Abbrechen" + "cancel": "Abbrechen", + "secureInputWarning": "⚠ Tastenkürzel durch Secure Input blockiert" }, "sidebar": { "general": "Allgemein", @@ -164,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} ist dein aktives Modell. Das Löschen stoppt Transkriptionen, bis du ein neues Modell auswählst. Bist du sicher?", "deleteTitle": "Modell löschen", "filters": { - "allLanguages": "Alle Sprachen" + "allLanguages": "Alle Sprachen", + "streaming": "Modelle filtern, die Live-Transkription unterstützen", + "translation": "Modelle filtern, die Übersetzungen ins Englische unterstützen" }, "noModelsMatch": "Keine Modelle entsprechen diesem Filter.", "yourModels": "Heruntergeladene Modelle", @@ -209,7 +212,7 @@ "description": "Wähle die Sprache für die Spracherkennung. Auto erkennt die Sprache automatisch, die Auswahl einer bestimmten Sprache kann die Genauigkeit verbessern.", "searchPlaceholder": "Sprachen suchen...", "noResults": "Keine Sprachen gefunden", - "auto": "Auto" + "auto": "Automatisch" }, "pushToTalk": { "label": "Push-to-Talk", @@ -224,6 +227,12 @@ "placeholder": "Mikrofon auswählen...", "loading": "Wird geladen..." }, + "channel": { + "title": "Eingangskanal", + "description": "Wähle den Eingangskanal aus, von dem aufgenommen werden soll. Verwende diese Option, wenn dein Audio-Interface mehrere Eingänge hat.", + "average": "Alle Kanäle mitteln", + "channel": "Kanal {{n}}" + }, "audioFeedback": { "label": "Audio-Feedback", "description": "Ton bei Start und Ende der Aufnahme abspielen" @@ -274,7 +283,7 @@ "description": "Hardwarebeschleunigung für ONNX-Modelle (Parakeet, Canary, Moonshine usw.). DirectML unter Windows ist experimentell. Modelle können bei der Transkription fehlschlagen." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automatisch" } }, "startHidden": { @@ -533,10 +542,34 @@ "title": "Einfügeverzögerung (nachher)", "description": "Verzögerung (in Millisekunden) nach dem Einfüge-Tastendruck, bevor deine vorherige Zwischenablage wiederhergestellt wird. Erhöhe den Wert, wenn der alte Inhalt der Zwischenablage statt der Transkription eingefügt wird." }, + "reliablePaste": { + "title": "Zuverlässiges Einfügen (Beta)", + "description": "Die Zwischenablage erst wiederherstellen, wenn die Ziel-App die Transkription tatsächlich gelesen hat, statt nach einer festen Verzögerung. Soll verhindern, dass bei Systemlast der alte Inhalt der Zwischenablage eingefügt wird. Erfordert eine Einfügemethode über die Zwischenablage; nur macOS und Windows." + }, "recordingBuffer": { "title": "Zusätzlicher Aufnahmepuffer", "description": "Zusätzliche Zeit (in Millisekunden), um nach dem Loslassen der Taste weiter aufzunehmen, um nachlaufendes Audio zu erfassen. 0 = kein zusätzlicher Puffer." }, + "keyboardDiagnostic": { + "title": "Tastatur-Diagnose", + "description": "Prüft, ob Tastaturereignisse Handy erreichen. Es wird nur die Anzahl der Ereignisse erfasst — niemals, welche Tasten du drückst.", + "run": "10-Sekunden-Diagnose starten", + "running": "Lauscht… drücke dein Tastenkürzel ein paar Mal (z. B. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "aktiviert", + "disabled": "deaktiviert", + "holder": "gehalten von {{name}} (pid {{pid}})", + "holderUnknown": "kein sichtbarer Verursacher", + "keyDown": "Taste gedrückt", + "keyUp": "Taste losgelassen", + "flagsChanged": "Modifikatoren", + "mouse": "Maus", + "verdictBlocked": "Secure Input blockiert Tastenereignisse — Tastenkürzel mit einer normalen Taste können erst wieder funktionieren, wenn das behoben ist.", + "verdictSuspicious": "Es kamen Modifikator-Ereignisse an, aber keine Tastenereignisse — irgendetwas unterdrückt Tasten, obwohl Secure Input als deaktiviert gemeldet wird. Bitte melde das auf GitHub.", + "verdictOk": "Tastenereignisse erreichen Handy normal.", + "verdictNoEvents": "Keine Ereignisse erfasst — hast du während des Tests Tasten gedrückt?", + "failed": "Diagnose fehlgeschlagen: {{error}}" + }, "whatsNewPreview": { "title": "Vorschau der Neuerungen", "description": "Die neuesten gebündelten Versionshinweise öffnen, ohne sie als gesehen zu markieren", @@ -589,8 +622,10 @@ "preparing": "Wird vorbereitet...", "checkForUpdates": "Nach Updates suchen", "portableUpdateTitle": "Manuelles Update erforderlich", - "portableUpdateMessage": "Portable Installationen können nicht automatisch aktualisiert werden. Zum Aktualisieren: Lade den neuesten NSIS-Installer von GitHub Releases herunter, installiere ihn im selben Ordner und kopiere dann deinen Data/-Ordner (Einstellungen, Modelle, Aufnahmen) von der alten Version in die neue.", - "portableUpdateButton": "GitHub Releases öffnen" + "portableUpdateMessage": "Portable Installationen können nicht automatisch aktualisiert werden. Lade unten den Installer für dein System herunter, führe ihn aus und installiere in denselben Ordner — dein Data/-Ordner (Einstellungen, Modelle, Aufnahmen) bleibt erhalten.", + "portableUpdateButton": "Installer herunterladen", + "portableUpdateBrowseMessage": "Portable Installationen können nicht automatisch aktualisiert werden. Lade die neueste Version für dein System von GitHub Releases herunter und installiere sie in denselben Ordner — dein Data/-Ordner (Einstellungen, Modelle, Aufnahmen) bleibt erhalten.", + "portableUpdateBrowseButton": "GitHub Releases öffnen" }, "common": { "loading": "Wird geladen...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Neu in Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} blockiert möglicherweise 1 Tastenkürzel", + "blockedWithCulprit_other": "{{name}} blockiert möglicherweise {{count}} Tastenkürzel", + "blockedNoCulprit_one": "macOS blockiert vorübergehend 1 Tastenkürzel", + "blockedNoCulprit_other": "macOS blockiert vorübergehend {{count}} Tastenkürzel", + "recorderBlockedWithCulprit": "{{name}} blockiert möglicherweise Änderungen an Tastenkürzeln", + "recorderBlockedNoCulprit": "macOS blockiert vorübergehend Änderungen an Tastenkürzeln", + "learnMore": "So behebst du das", + "recorderBlocked": "Tastenkürzel können gerade nicht aufgezeichnet werden — macOS Secure Input blockiert Tastenereignisse. Behebe zuerst die Secure-Input-Warnung.", + "dismiss": "Ausblenden" } } diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 8cf5394d5b..359625a483 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Unload Model", "model": "Model", "quit": "Quit", - "cancel": "Cancel" + "cancel": "Cancel", + "secureInputWarning": "⚠ Shortcuts blocked by Secure Input" }, "sidebar": { "general": "General", @@ -208,7 +209,9 @@ "deleteActiveConfirm": "{{modelName}} is your active model. Deleting it will stop transcriptions until you select a new model. Are you sure?", "deleteTitle": "Delete Model", "filters": { - "allLanguages": "All Languages" + "allLanguages": "All Languages", + "streaming": "Filter models that support live streaming transcription", + "translation": "Filter models that support translation to English" }, "rescan": { "label": "Rescan", @@ -224,6 +227,12 @@ "placeholder": "Select microphone...", "loading": "Loading..." }, + "channel": { + "title": "Input Channel", + "description": "Select which input channel to record from. Use this if your audio interface has multiple inputs.", + "average": "Average all channels", + "channel": "Channel {{n}}" + }, "audioFeedback": { "label": "Audio Feedback", "description": "Play sound when recording starts and stops" @@ -540,9 +549,33 @@ "title": "Paste Delay (After)", "description": "Delay (in milliseconds) after the paste keystroke, before restoring your previous clipboard. Increase if your old clipboard content is being pasted instead of the transcription." }, + "reliablePaste": { + "title": "Reliable Paste (Beta)", + "description": "Restore the clipboard only after the target app actually reads the transcription, instead of after a fixed delay. Intended to fix old clipboard content being pasted under system load. Requires a clipboard paste method; macOS and Windows only." + }, "recordingBuffer": { "title": "Extra Recording Buffer", "description": "Extra time (in milliseconds) to keep recording after you release the key, to capture trailing audio. 0 = no extra buffer." + }, + "keyboardDiagnostic": { + "title": "Keyboard Diagnostic", + "description": "Checks whether keyboard events reach Handy. Only event counts are recorded — never which keys you press.", + "run": "Run 10s diagnostic", + "running": "Listening… press your shortcut a few times (e.g. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "enabled", + "disabled": "disabled", + "holder": "held by {{name}} (pid {{pid}})", + "holderUnknown": "no visible holder", + "keyDown": "Key down", + "keyUp": "Key up", + "flagsChanged": "Modifiers", + "mouse": "Mouse", + "verdictBlocked": "Secure Input is blocking key events — keyed shortcuts cannot work until it is resolved.", + "verdictSuspicious": "Modifier events arrived but no key events — something is suppressing keys even though Secure Input reports disabled. Please report this on GitHub.", + "verdictOk": "Key events are reaching Handy normally.", + "verdictNoEvents": "No events captured — did you press any keys during the test?", + "failed": "Diagnostic failed: {{error}}" } }, "about": { @@ -589,8 +622,10 @@ "preparing": "Preparing...", "checkForUpdates": "Check for updates", "portableUpdateTitle": "Manual update required", - "portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.", - "portableUpdateButton": "Open GitHub Releases" + "portableUpdateMessage": "Portable installs cannot be updated automatically. Download the installer for your system below, run it, and install to the same folder — your Data/ folder (settings, models, recordings) is kept in place.", + "portableUpdateButton": "Download installer", + "portableUpdateBrowseMessage": "Portable installs cannot be updated automatically. Download the latest version for your system from GitHub Releases and install it to the same folder — your Data/ folder (settings, models, recordings) is kept in place.", + "portableUpdateBrowseButton": "Open GitHub Releases" }, "whatsNew": { "title": "New in Handy v{{version}}" @@ -642,5 +677,16 @@ "overlay": { "transcribing": "Transcribing...", "processing": "Processing..." + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} may be blocking 1 shortcut", + "blockedWithCulprit_other": "{{name}} may be blocking {{count}} shortcuts", + "blockedNoCulprit_one": "macOS is temporarily blocking 1 shortcut", + "blockedNoCulprit_other": "macOS is temporarily blocking {{count}} shortcuts", + "recorderBlockedWithCulprit": "{{name}} may be blocking shortcut changes", + "recorderBlockedNoCulprit": "macOS is temporarily blocking shortcut changes", + "learnMore": "How to fix", + "recorderBlocked": "Can't record shortcuts right now — macOS Secure Input is blocking key events. Resolve the Secure Input warning first.", + "dismiss": "Dismiss" } } diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index a4f8c6f58a..88a9bd92f1 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Descargar modelo", "model": "Modelo", "quit": "Salir", - "cancel": "Cancelar" + "cancel": "Cancelar", + "secureInputWarning": "⚠ Atajos bloqueados por Secure Input" }, "sidebar": { "general": "General", @@ -164,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} es tu modelo activo. Eliminarlo detendrá las transcripciones hasta que selecciones un nuevo modelo. ¿Estás seguro?", "deleteTitle": "Eliminar modelo", "filters": { - "allLanguages": "Todos los idiomas" + "allLanguages": "Todos los idiomas", + "streaming": "Filtrar modelos compatibles con la transcripción en vivo", + "translation": "Filtrar modelos compatibles con la traducción al inglés" }, "noModelsMatch": "Ningún modelo coincide con este filtro.", "yourModels": "Modelos descargados", @@ -209,7 +212,7 @@ "description": "Selecciona el idioma para el reconocimiento de voz. Auto detectará automáticamente el idioma, mientras que seleccionar un idioma específico puede mejorar la precisión para ese idioma.", "searchPlaceholder": "Buscar idiomas...", "noResults": "No se encontraron idiomas", - "auto": "Auto" + "auto": "Automático" }, "pushToTalk": { "label": "Presionar para hablar", @@ -224,6 +227,12 @@ "placeholder": "Seleccionar micrófono...", "loading": "Cargando..." }, + "channel": { + "title": "Canal de entrada", + "description": "Selecciona el canal de entrada desde el que se grabará. Usa esta opción si tu interfaz de audio tiene varias entradas.", + "average": "Promediar todos los canales", + "channel": "Canal {{n}}" + }, "audioFeedback": { "label": "Retroalimentación de audio", "description": "Reproducir sonido cuando la grabación inicia y se detiene" @@ -274,7 +283,7 @@ "description": "Aceleración por hardware para modelos ONNX (Parakeet, Canary, Moonshine, etc.). DirectML en Windows es experimental. Los modelos pueden fallar al transcribir." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automático" } }, "startHidden": { @@ -341,10 +350,10 @@ "description": "Envía automáticamente la combinación de teclas seleccionada después de insertar el texto. Cmd+Enter se aplica en macOS, mientras que Windows/Linux usan Super+Enter.", "options": { "off": "Desactivado", - "enter": "Enter", - "cmdEnter": "Cmd+Enter", - "superEnter": "Super+Enter", - "ctrlEnter": "Ctrl+Enter" + "enter": "Intro", + "cmdEnter": "Cmd+Intro", + "superEnter": "Super+Intro", + "ctrlEnter": "Ctrl+Intro" } }, "translateToEnglish": { @@ -533,10 +542,34 @@ "title": "Retraso de pegado (después)", "description": "Retraso (en milisegundos) después de la pulsación de tecla de pegar, antes de restaurar el portapapeles anterior. Auméntelo si se pega el contenido antiguo del portapapeles en lugar de la transcripción." }, + "reliablePaste": { + "title": "Pegado fiable (Beta)", + "description": "Restaura el portapapeles solo después de que la aplicación de destino lea realmente la transcripción, en lugar de tras un retraso fijo. Pensado para corregir que se pegue el contenido antiguo del portapapeles cuando el sistema está cargado. Requiere un método de pegado por portapapeles; solo macOS y Windows." + }, "recordingBuffer": { "title": "Búfer de grabación adicional", "description": "Tiempo adicional (en milisegundos) para seguir grabando después de soltar la tecla, para capturar el audio restante. 0 = sin búfer adicional." }, + "keyboardDiagnostic": { + "title": "Diagnóstico del teclado", + "description": "Comprueba si los eventos de teclado llegan a Handy. Solo se registra el número de eventos, nunca qué teclas pulsas.", + "run": "Ejecutar diagnóstico de 10 s", + "running": "Escuchando… pulsa tu atajo varias veces (p. ej. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "activado", + "disabled": "desactivado", + "holder": "retenido por {{name}} (pid {{pid}})", + "holderUnknown": "sin responsable visible", + "keyDown": "Tecla pulsada", + "keyUp": "Tecla soltada", + "flagsChanged": "Modificadores", + "mouse": "Ratón", + "verdictBlocked": "Secure Input está bloqueando los eventos de teclado: los atajos que incluyen una tecla normal no pueden funcionar hasta que se resuelva.", + "verdictSuspicious": "Llegaron eventos de modificadores, pero ningún evento de tecla: algo está suprimiendo las teclas aunque Secure Input aparezca como desactivado. Por favor, repórtalo en GitHub.", + "verdictOk": "Los eventos de teclado llegan a Handy con normalidad.", + "verdictNoEvents": "No se capturó ningún evento: ¿pulsaste alguna tecla durante la prueba?", + "failed": "El diagnóstico falló: {{error}}" + }, "whatsNewPreview": { "title": "Vista previa de novedades", "description": "Abrir la nota de la versión incluida más reciente sin marcarla como vista", @@ -589,8 +622,10 @@ "preparing": "Preparando...", "checkForUpdates": "Buscar actualizaciones", "portableUpdateTitle": "Actualización manual necesaria", - "portableUpdateMessage": "Las instalaciones portátiles no se pueden actualizar automáticamente. Para actualizar: descarga el instalador NSIS más reciente desde GitHub Releases, instálalo en la misma carpeta y luego copia tu carpeta Data/ (ajustes, modelos, grabaciones) de la versión anterior a la nueva.", - "portableUpdateButton": "Abrir GitHub Releases" + "portableUpdateMessage": "Las instalaciones portátiles no se pueden actualizar automáticamente. Descarga abajo el instalador para tu sistema, ejecútalo e instálalo en la misma carpeta: tu carpeta Data/ (ajustes, modelos, grabaciones) se conserva.", + "portableUpdateButton": "Descargar instalador", + "portableUpdateBrowseMessage": "Las instalaciones portátiles no se pueden actualizar automáticamente. Descarga la versión más reciente para tu sistema desde GitHub Releases e instálala en la misma carpeta: tu carpeta Data/ (ajustes, modelos, grabaciones) se conserva.", + "portableUpdateBrowseButton": "Abrir GitHub Releases" }, "common": { "loading": "Cargando...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Nuevo en Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} puede estar bloqueando 1 atajo", + "blockedWithCulprit_other": "{{name}} puede estar bloqueando {{count}} atajos", + "blockedNoCulprit_one": "macOS está bloqueando temporalmente 1 atajo", + "blockedNoCulprit_other": "macOS está bloqueando temporalmente {{count}} atajos", + "recorderBlockedWithCulprit": "{{name}} puede estar bloqueando los cambios de atajos", + "recorderBlockedNoCulprit": "macOS está bloqueando temporalmente los cambios de atajos", + "learnMore": "Cómo solucionarlo", + "recorderBlocked": "Ahora mismo no se pueden grabar atajos: Secure Input de macOS está bloqueando los eventos de teclado. Resuelve primero el aviso de Secure Input.", + "dismiss": "Descartar" } } diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index c9da37b11c..7144d78073 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Décharger le modèle", "model": "Modèle", "quit": "Quitter", - "cancel": "Annuler" + "cancel": "Annuler", + "secureInputWarning": "⚠ Raccourcis bloqués par Secure Input" }, "sidebar": { "general": "Général", @@ -164,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} est votre modèle actif. Le supprimer arrêtera les transcriptions jusqu'à ce que vous sélectionniez un nouveau modèle. Êtes-vous sûr ?", "deleteTitle": "Supprimer le modèle", "filters": { - "allLanguages": "Toutes les langues" + "allLanguages": "Toutes les langues", + "streaming": "Filtrer les modèles prenant en charge la transcription en direct", + "translation": "Filtrer les modèles prenant en charge la traduction vers l'anglais" }, "noModelsMatch": "Aucun modèle ne correspond à ce filtre.", "yourModels": "Modèles téléchargés", @@ -209,7 +212,7 @@ "description": "Sélectionnez la langue pour la reconnaissance vocale. Auto déterminera automatiquement la langue, tandis que sélectionner une langue spécifique peut améliorer la précision pour cette langue.", "searchPlaceholder": "Rechercher des langues...", "noResults": "Aucune langue trouvée", - "auto": "Auto" + "auto": "Automatique" }, "pushToTalk": { "label": "Appuyer pour parler", @@ -224,6 +227,12 @@ "placeholder": "Sélectionner un microphone...", "loading": "Chargement..." }, + "channel": { + "title": "Canal d’entrée", + "description": "Sélectionnez le canal d’entrée à enregistrer. Utilisez cette option si votre interface audio possède plusieurs entrées.", + "average": "Moyenne de tous les canaux", + "channel": "Canal {{n}}" + }, "audioFeedback": { "label": "Signal sonore", "description": "Jouer un son au début et à la fin de l'enregistrement" @@ -274,7 +283,7 @@ "description": "Accélération matérielle pour les modèles ONNX (Parakeet, Canary, Moonshine, etc.). DirectML sur Windows est expérimental. Les modèles peuvent échouer à transcrire." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automatique" } }, "startHidden": { @@ -342,9 +351,9 @@ "options": { "off": "Désactivé", "enter": "Entrée", - "cmdEnter": "Cmd+Enter", - "superEnter": "Super+Enter", - "ctrlEnter": "Ctrl+Enter" + "cmdEnter": "Cmd+Entrée", + "superEnter": "Super+Entrée", + "ctrlEnter": "Ctrl+Entrée" } }, "translateToEnglish": { @@ -533,10 +542,34 @@ "title": "Délai de collage (après)", "description": "Délai (en millisecondes) après la touche de collage, avant la restauration de votre presse-papiers précédent. Augmentez si l'ancien contenu du presse-papiers est collé à la place de la transcription." }, + "reliablePaste": { + "title": "Collage fiable (Beta)", + "description": "Restaure le presse-papiers uniquement après que l'application cible a réellement lu la transcription, au lieu d'attendre un délai fixe. Vise à corriger le collage de l'ancien contenu du presse-papiers lorsque le système est chargé. Nécessite une méthode de collage par presse-papiers ; macOS et Windows uniquement." + }, "recordingBuffer": { "title": "Tampon d'enregistrement supplémentaire", "description": "Temps supplémentaire (en millisecondes) pour continuer l'enregistrement après avoir relâché la touche, pour capturer l'audio restant. 0 = pas de tampon supplémentaire." }, + "keyboardDiagnostic": { + "title": "Diagnostic du clavier", + "description": "Vérifie si les événements clavier atteignent Handy. Seul le nombre d'événements est enregistré, jamais les touches sur lesquelles vous appuyez.", + "run": "Lancer un diagnostic de 10 s", + "running": "Écoute en cours… appuyez plusieurs fois sur votre raccourci (par ex. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "activé", + "disabled": "désactivé", + "holder": "détenu par {{name}} (pid {{pid}})", + "holderUnknown": "aucun détenteur visible", + "keyDown": "Touche enfoncée", + "keyUp": "Touche relâchée", + "flagsChanged": "Modificateurs", + "mouse": "Souris", + "verdictBlocked": "Secure Input bloque les événements clavier : les raccourcis contenant une touche normale ne peuvent pas fonctionner tant que ce n'est pas résolu.", + "verdictSuspicious": "Des événements de modificateurs sont arrivés, mais aucun événement de touche : quelque chose supprime les touches alors que Secure Input est signalé comme désactivé. Merci de le signaler sur GitHub.", + "verdictOk": "Les événements clavier atteignent Handy normalement.", + "verdictNoEvents": "Aucun événement capturé : avez-vous appuyé sur des touches pendant le test ?", + "failed": "Échec du diagnostic : {{error}}" + }, "whatsNewPreview": { "title": "Aperçu des nouveautés", "description": "Ouvrir la dernière note de version intégrée sans la marquer comme vue", @@ -589,8 +622,10 @@ "preparing": "Préparation...", "checkForUpdates": "Rechercher des mises à jour", "portableUpdateTitle": "Mise à jour manuelle requise", - "portableUpdateMessage": "Les installations portables ne peuvent pas être mises à jour automatiquement. Pour mettre à jour : téléchargez le dernier installateur NSIS depuis GitHub Releases, installez-le dans le même dossier, puis copiez votre dossier Data/ (paramètres, modèles, enregistrements) de l’ancienne version vers la nouvelle.", - "portableUpdateButton": "Ouvrir GitHub Releases" + "portableUpdateMessage": "Les installations portables ne peuvent pas être mises à jour automatiquement. Téléchargez ci-dessous l’installateur correspondant à votre système, exécutez-le et installez-le dans le même dossier : votre dossier Data/ (paramètres, modèles, enregistrements) est conservé.", + "portableUpdateButton": "Télécharger l’installateur", + "portableUpdateBrowseMessage": "Les installations portables ne peuvent pas être mises à jour automatiquement. Téléchargez la dernière version correspondant à votre système depuis GitHub Releases et installez-la dans le même dossier : votre dossier Data/ (paramètres, modèles, enregistrements) est conservé.", + "portableUpdateBrowseButton": "Ouvrir GitHub Releases" }, "common": { "loading": "Chargement...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Nouveautés de Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} bloque peut-être 1 raccourci", + "blockedWithCulprit_other": "{{name}} bloque peut-être {{count}} raccourcis", + "blockedNoCulprit_one": "macOS bloque temporairement 1 raccourci", + "blockedNoCulprit_other": "macOS bloque temporairement {{count}} raccourcis", + "recorderBlockedWithCulprit": "{{name}} bloque peut-être la modification des raccourcis", + "recorderBlockedNoCulprit": "macOS bloque temporairement la modification des raccourcis", + "learnMore": "Comment résoudre ce problème", + "recorderBlocked": "Impossible d'enregistrer un raccourci pour le moment : Secure Input de macOS bloque les événements clavier. Résolvez d'abord l'avertissement Secure Input.", + "dismiss": "Ignorer" } } diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index fbdad30df2..c83dcf1c8d 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -6,7 +6,8 @@ "unloadModel": "פרוק מודל", "model": "מודל", "quit": "יציאה", - "cancel": "ביטול" + "cancel": "ביטול", + "secureInputWarning": "⚠ קיצורי המקשים חסומים על ידי Secure Input" }, "sidebar": { "general": "כללי", @@ -19,10 +20,10 @@ }, "onboarding": { "subtitle": "כדי להתחיל, בחר מודל תמלול", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "מודלים תואמים", + "downloadModelsTitle": "זמינים להורדה", + "showAllModels": "הצג את כל {{total}} המודלים", + "showFewerModels": "הצג פחות מודלים", "recommended": "מומלץ", "customModelDescription": "לא נתמך באופן רשמי", "modelCard": { @@ -96,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "בחירת המודל נכשלה" }, "permissions": { "title": "נדרשות הרשאות", @@ -145,7 +146,7 @@ "capabilities": { "languageSelection": "תומך בכמה שפות קלט", "singleLanguage": "תומך רק בשפה הזו", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} שפות", "languageOnly": "{{language}} בלבד", "translation": "יכול לתרגם לאנגלית", "translate": "תרגם לאנגלית", @@ -207,7 +208,9 @@ "deleteActiveConfirm": "{{modelName}} הוא המודל הפעיל שלך כרגע. מחיקה שלו תעצור את התמלול עד שתבחר מודל חדש. להמשיך?", "deleteTitle": "מחיקת מודל", "filters": { - "allLanguages": "כל השפות" + "allLanguages": "כל השפות", + "streaming": "סינון דגמים התומכים בתמלול חי", + "translation": "סינון דגמים התומכים בתרגום לאנגלית" }, "noModelsMatch": "אין מודלים שמתאימים לסינון הזה.", "searchPlaceholder": "חיפוש מודלים לפי שם…", @@ -224,6 +227,12 @@ "placeholder": "בחר מיקרופון...", "loading": "טוען..." }, + "channel": { + "title": "ערוץ קלט", + "description": "בחרו את ערוץ הקלט שממנו תתבצע ההקלטה. השתמשו באפשרות זו אם לממשק השמע שלכם יש מספר כניסות.", + "average": "ממוצע של כל הערוצים", + "channel": "ערוץ {{n}}" + }, "audioFeedback": { "label": "משוב קולי", "description": "נגן צליל כשההקלטה מתחילה ומסתיימת" @@ -291,12 +300,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "שכבת-על", + "description": "בחר את שכבת-העל בהקלטה: 'ללא' מסתירה אותה, 'מינימלי' מציג סמן קומפקטי, 'בזמן אמת' מציג תמלול בזמן אמת בזמן הדיבור (מודלים התומכים בסטרימינג בלבד — חפש את התווית 'סטרימינג' בבורר המודלים). ב-Linux מומלץ לבחור 'ללא'.", "options": { "none": "ללא", - "minimal": "Minimal", - "live": "Live" + "minimal": "מינימלי", + "live": "בזמן אמת" } }, "position": { @@ -374,8 +383,8 @@ "duplicate": "\"{{word}}\" כבר קיימת" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "זיהוי פעילות קולית", + "description": "סינון שקט מההקלטות. מודלים התומכים בסטרימינג משתמשים בזנב VAD ארוך יותר; ביטול VAD מקליט אודיו גולמי." } }, "postProcessing": { @@ -533,10 +542,34 @@ "title": "השהיית הדבקה (אחרי)", "description": "השהיה (במילישניות) לאחר צירוף ההדבקה, לפני שחזור הלוח הקודם. הגדל אם התוכן הישן של הלוח מודבק במקום התמלול." }, + "reliablePaste": { + "title": "הדבקה אמינה (Beta)", + "description": "שחזור הלוח רק אחרי שאפליקציית היעד קוראת בפועל את התמלול, במקום אחרי השהיה קבועה. נועד לתקן הדבקה של תוכן לוח ישן כשהמערכת עמוסה. דורש שיטת הדבקה דרך הלוח; ב-macOS וב-Windows בלבד." + }, "recordingBuffer": { "title": "באפר הקלטה נוסף", "description": "זמן נוסף (במילישניות) להמשך הקלטה אחרי שחרור המקש, כדי ללכוד סוף דיבור. 0 = בלי באפר נוסף." }, + "keyboardDiagnostic": { + "title": "אבחון מקלדת", + "description": "בודק אם אירועי מקלדת מגיעים ל-Handy. נרשם רק מספר האירועים — לעולם לא אילו מקשים הקשת.", + "run": "הרצת אבחון של 10 שניות", + "running": "מאזין… הקש על קיצור המקשים שלך כמה פעמים (למשל Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "מופעל", + "disabled": "מושבת", + "holder": "מוחזק על ידי {{name}} (pid {{pid}})", + "holderUnknown": "אין מחזיק גלוי", + "keyDown": "לחיצת מקש", + "keyUp": "שחרור מקש", + "flagsChanged": "מקשי החלפה", + "mouse": "עכבר", + "verdictBlocked": "Secure Input חוסם אירועי מקשים — קיצורים הכוללים מקש רגיל לא יוכלו לפעול עד שהבעיה תיפתר.", + "verdictSuspicious": "הגיעו אירועים של מקשי החלפה אך לא אירועי מקשים — משהו מדכא מקשים למרות ש-Secure Input מדווח כמושבת. אנא דווח על כך ב-GitHub.", + "verdictOk": "אירועי מקשים מגיעים ל-Handy כרגיל.", + "verdictNoEvents": "לא נקלטו אירועים — האם הקשת על מקשים במהלך הבדיקה?", + "failed": "האבחון נכשל: {{error}}" + }, "whatsNewPreview": { "title": "תצוגה מקדימה של מה חדש", "description": "פתח את הערת הגרסה המצורפת האחרונה בלי לסמן אותה כנצפתה", @@ -589,8 +622,10 @@ "preparing": "מכין...", "checkForUpdates": "בדוק עדכונים", "portableUpdateTitle": "נדרש עדכון ידני", - "portableUpdateMessage": "התקנות ניידות אינן מתעדכנות אוטומטית. כדי לעדכן, הורד את מתקין ה-NSIS האחרון מ-GitHub Releases, התקן לאותה תיקייה, ואז העתק את תיקיית Data/ (הגדרות, מודלים, הקלטות) מהגרסה הישנה לחדשה.", - "portableUpdateButton": "פתח את GitHub Releases" + "portableUpdateMessage": "התקנות ניידות אינן מתעדכנות אוטומטית. הורד למטה את המתקין המתאים למערכת שלך, הפעל אותו והתקן לאותה תיקייה — תיקיית Data/ (הגדרות, מודלים, הקלטות) נשמרת במקומה.", + "portableUpdateButton": "הורדת המתקין", + "portableUpdateBrowseMessage": "התקנות ניידות אינן מתעדכנות אוטומטית. הורד את הגרסה האחרונה המתאימה למערכת שלך מ-GitHub Releases והתקן אותה לאותה תיקייה — תיקיית Data/ (הגדרות, מודלים, הקלטות) נשמרת במקומה.", + "portableUpdateBrowseButton": "פתח את GitHub Releases" }, "common": { "loading": "טוען...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "חדש ב-Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "ייתכן ש-{{name}} חוסם קיצור דרך אחד", + "blockedWithCulprit_other": "ייתכן ש-{{name}} חוסם {{count}} קיצורי דרך", + "blockedNoCulprit_one": "macOS חוסם זמנית קיצור דרך אחד", + "blockedNoCulprit_other": "macOS חוסם זמנית {{count}} קיצורי דרך", + "recorderBlockedWithCulprit": "ייתכן ש-{{name}} חוסם שינויים בקיצורי דרך", + "recorderBlockedNoCulprit": "macOS חוסם זמנית שינויים בקיצורי דרך", + "learnMore": "איך לתקן את זה", + "recorderBlocked": "לא ניתן להקליט קיצורים כרגע — Secure Input של macOS חוסם אירועי מקשים. פתור תחילה את אזהרת Secure Input.", + "dismiss": "התעלם" } } diff --git a/src/i18n/locales/hi/translation.json b/src/i18n/locales/hi/translation.json index b79aa01953..801b227040 100644 --- a/src/i18n/locales/hi/translation.json +++ b/src/i18n/locales/hi/translation.json @@ -6,7 +6,8 @@ "unloadModel": "मॉडल अनलोड करें", "model": "मॉडल", "quit": "बाहर निकलें", - "cancel": "रद्द करें" + "cancel": "रद्द करें", + "secureInputWarning": "⚠ Secure Input ने शॉर्टकट ब्लॉक किए हैं" }, "sidebar": { "general": "सामान्य", @@ -208,7 +209,9 @@ "deleteActiveConfirm": "{{modelName}} अभी इस्तेमाल में है. इसे हटाने पर ट्रांसक्रिप्शन तब तक रुका रहेगा, जब तक आप कोई नया मॉडल नहीं चुन लेते. क्या आप वाकई हटाना चाहते हैं?", "deleteTitle": "मॉडल हटाएं", "filters": { - "allLanguages": "सभी भाषाएं" + "allLanguages": "सभी भाषाएं", + "streaming": "लाइव ट्रांसक्रिप्शन का समर्थन करने वाले मॉडल फ़िल्टर करें", + "translation": "अंग्रेज़ी में अनुवाद का समर्थन करने वाले मॉडल फ़िल्टर करें" }, "rescan": { "label": "फिर से स्कैन करें", @@ -224,6 +227,12 @@ "placeholder": "माइक्रोफ़ोन चुनें...", "loading": "लोड हो रहा है..." }, + "channel": { + "title": "इनपुट चैनल", + "description": "रिकॉर्ड करने के लिए इनपुट चैनल चुनें। यदि आपके ऑडियो इंटरफ़ेस में कई इनपुट हैं, तो इसका उपयोग करें।", + "average": "सभी चैनलों का औसत", + "channel": "चैनल {{n}}" + }, "audioFeedback": { "label": "ऑडियो फ़ीडबैक", "description": "रिकॉर्डिंग शुरू और बंद होने पर साउंड चलाएं" @@ -540,9 +549,33 @@ "title": "पेस्ट में देरी (बाद में)", "description": "पेस्ट कीस्ट्रोक के बाद और आपका पिछला क्लिपबोर्ड वापस लाने से पहले की देरी (मिलीसेकंड में). अगर ट्रांसक्रिप्शन की जगह आपके क्लिपबोर्ड का पुराना कॉन्टेंट पेस्ट हो रहा हो, तो इसे बढ़ाएं." }, + "reliablePaste": { + "title": "भरोसेमंद पेस्ट (Beta)", + "description": "क्लिपबोर्ड को तय देरी के बाद नहीं, बल्कि टारगेट ऐप असल में ट्रांसक्रिप्शन पढ़ लेने के बाद ही वापस लाएं. यह सिस्टम पर लोड होने के दौरान पुराना क्लिपबोर्ड कॉन्टेंट पेस्ट होने की समस्या ठीक करने के लिए है. इसके लिए क्लिपबोर्ड वाला पेस्ट तरीका ज़रूरी है; सिर्फ़ macOS और Windows पर." + }, "recordingBuffer": { "title": "अतिरिक्त रिकॉर्डिंग बफ़र", "description": "बटन छोड़ने के बाद भी रिकॉर्डिंग जारी रखने का अतिरिक्त समय (मिलीसेकंड में), ताकि आखिरी हिस्से का ऑडियो कैप्चर हो सके. 0 = कोई अतिरिक्त बफ़र नहीं." + }, + "keyboardDiagnostic": { + "title": "कीबोर्ड डायग्नोस्टिक", + "description": "जाँचता है कि कीबोर्ड इवेंट Handy तक पहुँच रहे हैं या नहीं. सिर्फ़ इवेंट की गिनती रिकॉर्ड होती है — कभी नहीं कि आपने कौन-सी कुंजियाँ दबाईं.", + "run": "10 सेकंड का डायग्नोस्टिक चलाएँ", + "running": "सुन रहे हैं… अपना शॉर्टकट कुछ बार दबाएँ (जैसे Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "चालू", + "disabled": "बंद", + "holder": "{{name}} (pid {{pid}}) ने रोका हुआ है", + "holderUnknown": "कोई दिखने वाला ऐप नहीं", + "keyDown": "कुंजी दबाई", + "keyUp": "कुंजी छोड़ी", + "flagsChanged": "मॉडिफ़ायर", + "mouse": "माउस", + "verdictBlocked": "Secure Input कुंजी इवेंट ब्लॉक कर रहा है — जब तक यह हल नहीं होता, सामान्य कुंजी वाले शॉर्टकट काम नहीं कर सकते.", + "verdictSuspicious": "मॉडिफ़ायर इवेंट आए, लेकिन कोई कुंजी इवेंट नहीं — Secure Input के बंद दिखने के बावजूद कुछ कुंजियों को दबा रहा है. कृपया इसकी रिपोर्ट GitHub पर करें.", + "verdictOk": "कुंजी इवेंट Handy तक सामान्य रूप से पहुँच रहे हैं.", + "verdictNoEvents": "कोई इवेंट कैप्चर नहीं हुआ — क्या आपने टेस्ट के दौरान कोई कुंजी दबाई थी?", + "failed": "डायग्नोस्टिक विफल: {{error}}" } }, "about": { @@ -589,8 +622,10 @@ "preparing": "तैयारी हो रही है...", "checkForUpdates": "अपडेट जांचें", "portableUpdateTitle": "मैन्युअल अपडेट ज़रूरी है", - "portableUpdateMessage": "पोर्टेबल इंस्टॉल अपने आप अपडेट नहीं हो सकते. अपडेट करने के लिए: GitHub Releases से सबसे नया NSIS इंस्टॉलर डाउनलोड करें, उसे उसी फ़ोल्डर में इंस्टॉल करें, फिर पुराने वर्शन से अपना Data/ फ़ोल्डर (सेटिंग, मॉडल, रिकॉर्डिंग) नए वर्शन में कॉपी करें.", - "portableUpdateButton": "GitHub Releases खोलें" + "portableUpdateMessage": "पोर्टेबल इंस्टॉल अपने आप अपडेट नहीं हो सकते. नीचे से अपने सिस्टम के लिए इंस्टॉलर डाउनलोड करें, उसे चलाएँ और उसी फ़ोल्डर में इंस्टॉल करें — आपका Data/ फ़ोल्डर (सेटिंग, मॉडल, रिकॉर्डिंग) जहाँ है वहीं बना रहता है.", + "portableUpdateButton": "इंस्टॉलर डाउनलोड करें", + "portableUpdateBrowseMessage": "पोर्टेबल इंस्टॉल अपने आप अपडेट नहीं हो सकते. GitHub Releases से अपने सिस्टम के लिए नया वर्शन डाउनलोड करें और उसे उसी फ़ोल्डर में इंस्टॉल करें — आपका Data/ फ़ोल्डर (सेटिंग, मॉडल, रिकॉर्डिंग) जहाँ है वहीं बना रहता है.", + "portableUpdateBrowseButton": "GitHub Releases खोलें" }, "whatsNew": { "title": "Handy v{{version}} में नया क्या है" @@ -642,5 +677,16 @@ "overlay": { "transcribing": "ट्रांसक्राइब हो रहा है...", "processing": "प्रोसेस हो रहा है..." + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} शायद 1 शॉर्टकट को ब्लॉक कर रहा है", + "blockedWithCulprit_other": "{{name}} शायद {{count}} शॉर्टकट को ब्लॉक कर रहा है", + "blockedNoCulprit_one": "macOS अस्थायी रूप से 1 शॉर्टकट को ब्लॉक कर रहा है", + "blockedNoCulprit_other": "macOS अस्थायी रूप से {{count}} शॉर्टकट को ब्लॉक कर रहा है", + "recorderBlockedWithCulprit": "{{name}} शायद शॉर्टकट में बदलाव को ब्लॉक कर रहा है", + "recorderBlockedNoCulprit": "macOS अस्थायी रूप से शॉर्टकट में बदलाव को ब्लॉक कर रहा है", + "learnMore": "इसे कैसे ठीक करें", + "recorderBlocked": "अभी शॉर्टकट रिकॉर्ड नहीं किए जा सकते — macOS Secure Input कुंजी इवेंट ब्लॉक कर रहा है. पहले Secure Input चेतावनी हल करें.", + "dismiss": "खारिज करें" } } diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index da8dfc9d19..0d60522fd0 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Rilascia modello", "model": "Modello", "quit": "Esci", - "cancel": "Annulla" + "cancel": "Annulla", + "secureInputWarning": "⚠ Scorciatoie bloccate da Secure Input" }, "sidebar": { "general": "Generale", @@ -164,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} è il tuo modello attivo. Eliminarlo interromperà le trascrizioni fino a quando non selezionerai un nuovo modello. Sei sicuro?", "deleteTitle": "Elimina modello", "filters": { - "allLanguages": "Tutte le lingue" + "allLanguages": "Tutte le lingue", + "streaming": "Filtra i modelli che supportano la trascrizione in tempo reale", + "translation": "Filtra i modelli che supportano la traduzione in inglese" }, "noModelsMatch": "Nessun modello corrisponde a questo filtro.", "yourModels": "Modelli scaricati", @@ -209,7 +212,7 @@ "description": "Scegli la lingua per il riconoscimento vocale. Auto la determinerà automaticamente, mentre scegliere una lingua specifica può migliorare l'accuratezza per quella lingua.", "searchPlaceholder": "Cerca lingue...", "noResults": "Nessuna lingua trovata", - "auto": "Auto" + "auto": "Automatico" }, "pushToTalk": { "label": "Premi per Parlare", @@ -224,6 +227,12 @@ "placeholder": "Scegli microfono...", "loading": "Caricamento..." }, + "channel": { + "title": "Canale di ingresso", + "description": "Seleziona il canale di ingresso da cui registrare. Usa questa opzione se l'interfaccia audio dispone di più ingressi.", + "average": "Media di tutti i canali", + "channel": "Canale {{n}}" + }, "audioFeedback": { "label": "Feedback Audio", "description": "Riproduci un suono quando la registrazione inizia e finisce" @@ -274,7 +283,7 @@ "description": "Accelerazione hardware per i modelli ONNX (Parakeet, Canary, Moonshine, ecc.). DirectML su Windows è sperimentale. I modelli potrebbero non riuscire a trascrivere." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automatico" } }, "startHidden": { @@ -341,10 +350,10 @@ "description": "Invia automaticamente la combinazione di tasti selezionata dopo l'inserimento del testo. Cmd+Enter si applica su macOS, mentre Windows/Linux usano Super+Enter.", "options": { "off": "Disattivato", - "enter": "Enter", - "cmdEnter": "Cmd+Enter", - "superEnter": "Super+Enter", - "ctrlEnter": "Ctrl+Enter" + "enter": "Invio", + "cmdEnter": "Cmd+Invio", + "superEnter": "Super+Invio", + "ctrlEnter": "Ctrl+Invio" } }, "translateToEnglish": { @@ -533,10 +542,34 @@ "title": "Ritardo incolla (dopo)", "description": "Ritardo (in millisecondi) dopo il tasto incolla, prima di ripristinare gli appunti precedenti. Aumentare se viene incollato il vecchio contenuto degli appunti invece della trascrizione." }, + "reliablePaste": { + "title": "Incolla affidabile (Beta)", + "description": "Ripristina gli appunti solo dopo che l'app di destinazione ha effettivamente letto la trascrizione, anziché dopo un ritardo fisso. Serve a risolvere l'incollaggio del vecchio contenuto degli appunti quando il sistema è sotto carico. Richiede un metodo di incollaggio tramite appunti; solo macOS e Windows." + }, "recordingBuffer": { "title": "Buffer di registrazione extra", "description": "Tempo extra (in millisecondi) per continuare a registrare dopo aver rilasciato il tasto, per catturare l'audio finale. 0 = nessun buffer extra." }, + "keyboardDiagnostic": { + "title": "Diagnostica tastiera", + "description": "Verifica se gli eventi della tastiera arrivano a Handy. Viene registrato solo il numero di eventi, mai quali tasti premi.", + "run": "Esegui diagnostica di 10 s", + "running": "In ascolto… premi la tua scorciatoia alcune volte (ad es. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "abilitato", + "disabled": "disabilitato", + "holder": "trattenuto da {{name}} (pid {{pid}})", + "holderUnknown": "nessun responsabile visibile", + "keyDown": "Tasto premuto", + "keyUp": "Tasto rilasciato", + "flagsChanged": "Modificatori", + "mouse": "Mouse", + "verdictBlocked": "Secure Input sta bloccando gli eventi dei tasti: le scorciatoie che includono un tasto normale non possono funzionare finché non viene risolto.", + "verdictSuspicious": "Sono arrivati eventi dei modificatori ma nessun evento dei tasti: qualcosa sta sopprimendo i tasti anche se Secure Input risulta disabilitato. Segnalalo su GitHub.", + "verdictOk": "Gli eventi dei tasti arrivano a Handy normalmente.", + "verdictNoEvents": "Nessun evento rilevato: hai premuto qualche tasto durante il test?", + "failed": "Diagnostica non riuscita: {{error}}" + }, "whatsNewPreview": { "title": "Anteprima delle novità", "description": "Apri le note di rilascio incluse più recenti senza segnarle come viste", @@ -589,8 +622,10 @@ "preparing": "Preparazione...", "checkForUpdates": "Controlla aggiornamenti", "portableUpdateTitle": "Aggiornamento manuale necessario", - "portableUpdateMessage": "Le installazioni portatili non possono essere aggiornate automaticamente. Per aggiornare: scarica l'ultima versione del programma di installazione NSIS da GitHub Releases, installala nella stessa cartella, quindi copia la cartella Data/ (impostazioni, modelli, registrazioni) dalla vecchia versione a quella nuova..", - "portableUpdateButton": "Apri i rilasci su GitHub" + "portableUpdateMessage": "Le installazioni portatili non possono essere aggiornate automaticamente. Scarica qui sotto il programma di installazione per il tuo sistema, eseguilo e installalo nella stessa cartella: la cartella Data/ (impostazioni, modelli, registrazioni) viene mantenuta.", + "portableUpdateButton": "Scarica il programma di installazione", + "portableUpdateBrowseMessage": "Le installazioni portatili non possono essere aggiornate automaticamente. Scarica da GitHub Releases la versione più recente per il tuo sistema e installala nella stessa cartella: la cartella Data/ (impostazioni, modelli, registrazioni) viene mantenuta.", + "portableUpdateBrowseButton": "Apri i rilasci su GitHub" }, "common": { "loading": "Caricamento...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Novità in Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} potrebbe bloccare 1 scorciatoia", + "blockedWithCulprit_other": "{{name}} potrebbe bloccare {{count}} scorciatoie", + "blockedNoCulprit_one": "macOS sta bloccando temporaneamente 1 scorciatoia", + "blockedNoCulprit_other": "macOS sta bloccando temporaneamente {{count}} scorciatoie", + "recorderBlockedWithCulprit": "{{name}} potrebbe bloccare le modifiche alle scorciatoie", + "recorderBlockedNoCulprit": "macOS sta bloccando temporaneamente le modifiche alle scorciatoie", + "learnMore": "Come risolvere", + "recorderBlocked": "Non è possibile registrare scorciatoie in questo momento: Secure Input di macOS sta bloccando gli eventi dei tasti. Risolvi prima l'avviso di Secure Input.", + "dismiss": "Ignora" } } diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 0fb9b54984..ade9421366 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -6,7 +6,8 @@ "unloadModel": "モデルをアンロード", "model": "モデル", "quit": "終了", - "cancel": "キャンセル" + "cancel": "キャンセル", + "secureInputWarning": "⚠ Secure Input でショートカットがブロック中" }, "sidebar": { "general": "一般", @@ -164,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}}は現在使用中のモデルです。削除すると、新しいモデルを選択するまで文字起こしが停止します。本当に削除しますか?", "deleteTitle": "モデルを削除", "filters": { - "allLanguages": "すべての言語" + "allLanguages": "すべての言語", + "streaming": "リアルタイム文字起こしに対応するモデルを絞り込む", + "translation": "英語への翻訳に対応するモデルを絞り込む" }, "noModelsMatch": "このフィルターに一致するモデルがありません。", "yourModels": "ダウンロード済みモデル", @@ -224,6 +227,12 @@ "placeholder": "マイクを選択…", "loading": "読み込み中…" }, + "channel": { + "title": "入力チャンネル", + "description": "録音に使用する入力チャンネルを選択します。オーディオインターフェースに複数の入力がある場合に使用してください。", + "average": "すべてのチャンネルの平均", + "channel": "チャンネル{{n}}" + }, "audioFeedback": { "label": "オーディオフィードバック", "description": "録音の開始と停止時にサウンドを再生" @@ -533,10 +542,34 @@ "title": "貼り付け遅延(後)", "description": "貼り付けキーを送信した後、以前のクリップボードを復元する前の遅延(ミリ秒)。文字起こしではなく古いクリップボードの内容が貼り付けられる場合は増やしてください。" }, + "reliablePaste": { + "title": "確実な貼り付け(Beta)", + "description": "固定の遅延ではなく、対象アプリが実際に文字起こしを読み取った後にのみクリップボードを復元します。システム負荷が高いときに古いクリップボードの内容が貼り付けられる問題を解決するための設定です。クリップボードを使う貼り付け方法が必要です。macOS と Windows のみ。" + }, "recordingBuffer": { "title": "追加録音バッファ", "description": "キーを離した後に録音を続ける追加時間(ミリ秒)。末尾の音声を捕捉するため。0 = 追加バッファなし。" }, + "keyboardDiagnostic": { + "title": "キーボード診断", + "description": "キーボードイベントが Handy に届いているかを確認します。記録されるのはイベント数のみで、押したキーの内容は記録されません。", + "run": "10 秒間の診断を実行", + "running": "待機中… ショートカットを数回押してください (例: Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "有効", + "disabled": "無効", + "holder": "{{name}} (pid {{pid}}) が保持中", + "holderUnknown": "保持元は不明", + "keyDown": "キー押下", + "keyUp": "キー解放", + "flagsChanged": "修飾キー", + "mouse": "マウス", + "verdictBlocked": "Secure Input がキーイベントをブロックしています。解消されるまで、通常キーを含むショートカットは動作しません。", + "verdictSuspicious": "修飾キーのイベントは届いていますが、キーイベントは届いていません。Secure Input は無効と報告されているのに、何かがキーを抑制しています。GitHub で報告してください。", + "verdictOk": "キーイベントは Handy に正常に届いています。", + "verdictNoEvents": "イベントを検出できませんでした。テスト中にキーを押しましたか?", + "failed": "診断に失敗しました: {{error}}" + }, "whatsNewPreview": { "title": "新機能をプレビュー", "description": "最新の同梱リリースノートを既読にせずに開く", @@ -589,8 +622,10 @@ "preparing": "準備中…", "checkForUpdates": "アップデートを確認", "portableUpdateTitle": "手動での更新が必要です", - "portableUpdateMessage": "ポータブル版は自動的に更新できません。更新するには、GitHub Releases から最新の NSIS インストーラーをダウンロードし、同じフォルダーにインストールしてから、古いバージョンの Data フォルダー(設定、モデル、録音)を新しいバージョンにコピーしてください。", - "portableUpdateButton": "GitHub Releases を開く" + "portableUpdateMessage": "ポータブル版は自動的に更新できません。下のボタンからお使いのシステム向けのインストーラーをダウンロードし、実行して同じフォルダーにインストールしてください。Data フォルダー(設定、モデル、録音)はそのまま残ります。", + "portableUpdateButton": "インストーラーをダウンロード", + "portableUpdateBrowseMessage": "ポータブル版は自動的に更新できません。GitHub Releases からお使いのシステム向けの最新版をダウンロードし、同じフォルダーにインストールしてください。Data フォルダー(設定、モデル、録音)はそのまま残ります。", + "portableUpdateBrowseButton": "GitHub Releases を開く" }, "common": { "loading": "読み込み中…", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Handy v{{version}} の新機能" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} が1個のショートカットをブロックしている可能性があります", + "blockedWithCulprit_other": "{{name}} が{{count}}個のショートカットをブロックしている可能性があります", + "blockedNoCulprit_one": "macOS が一時的に1個のショートカットをブロックしています", + "blockedNoCulprit_other": "macOS が一時的に{{count}}個のショートカットをブロックしています", + "recorderBlockedWithCulprit": "{{name}} がショートカットの変更をブロックしている可能性があります", + "recorderBlockedNoCulprit": "macOS が一時的にショートカットの変更をブロックしています", + "learnMore": "解決方法", + "recorderBlocked": "現在ショートカットを記録できません。macOS の Secure Input がキーイベントをブロックしています。先に Secure Input の警告を解消してください。", + "dismiss": "閉じる" } } diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 094511fd3c..393ce61385 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -6,7 +6,8 @@ "unloadModel": "모델 언로드", "model": "모델", "quit": "종료", - "cancel": "취소" + "cancel": "취소", + "secureInputWarning": "⚠ Secure Input으로 단축키가 차단됨" }, "sidebar": { "general": "일반", @@ -19,10 +20,10 @@ }, "onboarding": { "subtitle": "시작하려면 음성 인식 모델을 선택하세요", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "호환 가능한 모델", + "downloadModelsTitle": "다운로드 가능", + "showAllModels": "전체 {{total}}개 모델 표시", + "showFewerModels": "간략히 표시", "recommended": "추천", "customModelDescription": "공식 지원되지 않음", "modelCard": { @@ -96,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "모델을 선택하지 못했습니다" }, "permissions": { "title": "권한 필요", @@ -145,7 +146,7 @@ "capabilities": { "languageSelection": "여러 입력 언어를 지원합니다", "singleLanguage": "이 언어만 지원합니다", - "languageCount": "{{total}} languages", + "languageCount": "{{total}}개 언어", "languageOnly": "{{language}} 전용", "translation": "영어로 번역 가능", "translate": "영어로 번역", @@ -206,6 +207,12 @@ "placeholder": "마이크 선택...", "loading": "로딩 중..." }, + "channel": { + "title": "입력 채널", + "description": "녹음할 입력 채널을 선택하세요. 오디오 인터페이스에 입력이 여러 개 있는 경우 사용하세요.", + "average": "모든 채널 평균", + "channel": "채널 {{n}}" + }, "audioFeedback": { "label": "오디오 피드백", "description": "녹음 시작 및 정지 시 소리 재생" @@ -239,7 +246,9 @@ "deleteActiveConfirm": "{{modelName}}은(는) 현재 활성 모델입니다. 삭제하면 새 모델을 선택할 때까지 전사가 중지됩니다. 정말 삭제하시겠습니까?", "deleteTitle": "모델 삭제", "filters": { - "allLanguages": "모든 언어" + "allLanguages": "모든 언어", + "streaming": "실시간 음성 인식을 지원하는 모델 필터링", + "translation": "영어 번역을 지원하는 모델 필터링" }, "noModelsMatch": "이 필터에 맞는 모델이 없습니다.", "searchPlaceholder": "이름으로 모델 검색…", @@ -291,12 +300,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "오버레이", + "description": "녹음 오버레이를 선택하세요. '없음'은 오버레이를 숨기고, '간단'은 작은 알약 모양 표시를 보여주며, '실시간'은 말하는 동안 전사 결과를 실시간으로 표시합니다(스트리밍 지원 모델만 해당 — 모델 선택기에서 '스트리밍' 배지를 확인하세요). Linux에서는 '없음'을 권장합니다.", "options": { "none": "없음", - "minimal": "Minimal", - "live": "Live" + "minimal": "간단", + "live": "실시간" } }, "position": { @@ -374,8 +383,8 @@ "duplicate": "\"{{word}}\"이(가) 이미 존재합니다" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "음성 활동 감지", + "description": "녹음에서 무음 구간을 걸러냅니다. 스트리밍 지원 모델은 더 긴 VAD 테일을 사용하며, VAD를 끄면 원본 오디오가 그대로 녹음됩니다." } }, "postProcessing": { @@ -528,10 +537,34 @@ "title": "붙여넣기 지연 (이후)", "description": "붙여넣기 키 입력을 보낸 후 이전 클립보드를 복원하기 전의 지연 시간(밀리초). 전사된 텍스트 대신 이전 클립보드 내용이 붙여넣어지면 늘리세요." }, + "reliablePaste": { + "title": "안정적인 붙여넣기 (Beta)", + "description": "고정된 지연 시간이 아니라 대상 앱이 실제로 전사된 텍스트를 읽은 뒤에만 클립보드를 복원합니다. 시스템 부하가 높을 때 이전 클립보드 내용이 붙여넣어지는 문제를 해결하기 위한 옵션입니다. 클립보드를 사용하는 붙여넣기 방법이 필요하며 macOS와 Windows에서만 지원됩니다." + }, "recordingBuffer": { "title": "추가 녹음 버퍼", "description": "키를 놓은 후 추가로 녹음을 계속하는 시간(밀리초). 후행 오디오를 캡처하기 위함. 0 = 추가 버퍼 없음." }, + "keyboardDiagnostic": { + "title": "키보드 진단", + "description": "키보드 이벤트가 Handy에 도달하는지 확인합니다. 이벤트 개수만 기록되며, 어떤 키를 눌렀는지는 절대 기록되지 않습니다.", + "run": "10초 진단 실행", + "running": "수신 중… 단축키를 몇 번 눌러 보세요 (예: Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "사용 중", + "disabled": "사용 안 함", + "holder": "{{name}}(pid {{pid}})이(가) 점유 중", + "holderUnknown": "점유 중인 앱을 확인할 수 없음", + "keyDown": "키 누름", + "keyUp": "키 뗌", + "flagsChanged": "조합 키", + "mouse": "마우스", + "verdictBlocked": "Secure Input이 키 이벤트를 차단하고 있습니다. 해결될 때까지 일반 키가 포함된 단축키는 작동할 수 없습니다.", + "verdictSuspicious": "조합 키 이벤트는 도착했지만 키 이벤트는 없습니다. Secure Input은 꺼져 있다고 보고되지만 무언가가 키를 가로막고 있습니다. GitHub에 신고해 주세요.", + "verdictOk": "키 이벤트가 Handy에 정상적으로 도달하고 있습니다.", + "verdictNoEvents": "이벤트가 감지되지 않았습니다. 테스트 중에 키를 누르셨나요?", + "failed": "진단 실패: {{error}}" + }, "paths": { "appData": "앱 데이터:", "models": "모델:", @@ -589,8 +622,10 @@ "preparing": "준비 중...", "checkForUpdates": "업데이트 확인", "portableUpdateTitle": "수동 업데이트 필요", - "portableUpdateMessage": "포터블 설치는 자동으로 업데이트할 수 없습니다. 업데이트하려면 GitHub Releases에서 최신 NSIS 설치 프로그램을 다운로드하여 같은 폴더에 설치한 다음, 이전 버전의 Data/ 폴더(설정, 모델, 녹음)를 새 버전으로 복사하세요.", - "portableUpdateButton": "GitHub Releases 열기" + "portableUpdateMessage": "포터블 설치는 자동으로 업데이트할 수 없습니다. 아래에서 사용 중인 시스템에 맞는 설치 프로그램을 다운로드해 실행하고 같은 폴더에 설치하세요. Data/ 폴더(설정, 모델, 녹음)는 그대로 유지됩니다.", + "portableUpdateButton": "설치 프로그램 다운로드", + "portableUpdateBrowseMessage": "포터블 설치는 자동으로 업데이트할 수 없습니다. GitHub Releases에서 사용 중인 시스템에 맞는 최신 버전을 다운로드해 같은 폴더에 설치하세요. Data/ 폴더(설정, 모델, 녹음)는 그대로 유지됩니다.", + "portableUpdateBrowseButton": "GitHub Releases 열기" }, "common": { "loading": "로딩 중...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Handy v{{version}}의 새 소식" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}}이(가) 단축키 1개를 차단하고 있을 수 있습니다", + "blockedWithCulprit_other": "{{name}}이(가) 단축키 {{count}}개를 차단하고 있을 수 있습니다", + "blockedNoCulprit_one": "macOS가 단축키 1개를 일시적으로 차단하고 있습니다", + "blockedNoCulprit_other": "macOS가 단축키 {{count}}개를 일시적으로 차단하고 있습니다", + "recorderBlockedWithCulprit": "{{name}}이(가) 단축키 변경을 차단하고 있을 수 있습니다", + "recorderBlockedNoCulprit": "macOS가 단축키 변경을 일시적으로 차단하고 있습니다", + "learnMore": "해결 방법", + "recorderBlocked": "지금은 단축키를 등록할 수 없습니다. macOS Secure Input이 키 이벤트를 차단하고 있습니다. Secure Input 경고를 먼저 해결하세요.", + "dismiss": "닫기" } } diff --git a/src/i18n/locales/ne/translation.json b/src/i18n/locales/ne/translation.json index 883b1d7349..dfc9592930 100644 --- a/src/i18n/locales/ne/translation.json +++ b/src/i18n/locales/ne/translation.json @@ -6,7 +6,8 @@ "unloadModel": "मोडेल अनलोड गर्नुहोस्", "model": "मोडेल", "quit": "बाहिर निस्कनुहोस्", - "cancel": "रद्द गर्नुहोस्" + "cancel": "रद्द गर्नुहोस्", + "secureInputWarning": "⚠ Secure Input ले सर्टकटहरू रोकेको छ" }, "sidebar": { "general": "सामान्य", @@ -208,7 +209,9 @@ "deleteActiveConfirm": "{{modelName}} तपाईंको सक्रिय मोडेल हो। यसलाई मेटाउँदा नयाँ मोडेल चयन नगरेसम्म ट्रान्सक्रिप्सन रोकिनेछ। के तपाईं साँच्चै मेटाउन चाहनुहुन्छ?", "deleteTitle": "मोडेल मेटाउनुहोस्", "filters": { - "allLanguages": "सबै भाषाहरू" + "allLanguages": "सबै भाषाहरू", + "streaming": "लाइभ ट्रान्सक्रिप्सन समर्थन गर्ने मोडेलहरू फिल्टर गर्नुहोस्", + "translation": "अङ्ग्रेजीमा अनुवाद समर्थन गर्ने मोडेलहरू फिल्टर गर्नुहोस्" }, "rescan": { "label": "फेरि स्क्यान गर्नुहोस्", @@ -224,6 +227,12 @@ "placeholder": "माइक्रोफोन चयन गर्नुहोस्...", "loading": "लोड गरिँदैछ..." }, + "channel": { + "title": "इनपुट च्यानल", + "description": "रेकर्ड गर्ने इनपुट च्यानल चयन गर्नुहोस्। तपाईंको अडियो इन्टरफेसमा धेरै इनपुटहरू छन् भने यो प्रयोग गर्नुहोस्।", + "average": "सबै च्यानलको औसत", + "channel": "च्यानल {{n}}" + }, "audioFeedback": { "label": "अडियो फिडब्याक", "description": "रेकर्डिङ सुरु र बन्द हुँदा आवाज बजाउनुहोस्" @@ -540,9 +549,33 @@ "title": "पेस्ट ढिलाइ (पछि)", "description": "पेस्ट किस्ट्रोकपछि, तपाईंको अघिल्लो क्लिपबोर्ड पुनर्स्थापना गर्नुअघिको ढिलाइ (मिलिसेकेन्डमा)। ट्रान्सक्रिप्सनको सट्टा पुरानो क्लिपबोर्ड सामग्री पेस्ट भइरहेको छ भने बढाउनुहोस्।" }, + "reliablePaste": { + "title": "भरपर्दो पेस्ट (Beta)", + "description": "निश्चित ढिलाइपछि नभई, लक्षित एपले ट्रान्सक्रिप्सन साँच्चै पढेपछि मात्र क्लिपबोर्ड पुनर्स्थापना गर्नुहोस्। प्रणालीमा भार बढ्दा पुरानो क्लिपबोर्ड सामग्री पेस्ट हुने समस्या समाधान गर्न बनाइएको। क्लिपबोर्ड प्रयोग गर्ने पेस्ट विधि आवश्यक; macOS र Windows मा मात्र।" + }, "recordingBuffer": { "title": "अतिरिक्त रेकर्डिङ बफर", "description": "कि छोडेपछि अन्तिम अडियो समात्न रेकर्डिङ जारी राख्ने अतिरिक्त समय (मिलिसेकेन्डमा)। 0 = कुनै अतिरिक्त बफर छैन।" + }, + "keyboardDiagnostic": { + "title": "किबोर्ड डायग्नोस्टिक", + "description": "किबोर्ड इभेन्टहरू Handy सम्म पुग्छन् कि पुग्दैनन् भनी जाँच्छ। इभेन्टको संख्या मात्र रेकर्ड हुन्छ — तपाईंले कुन कुञ्जी थिच्नुभयो भन्ने कहिल्यै होइन।", + "run": "१० सेकेन्डको डायग्नोस्टिक चलाउनुहोस्", + "running": "सुनिरहेको छ… आफ्नो सर्टकट केही पटक थिच्नुहोस् (जस्तै Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "सक्षम", + "disabled": "अक्षम", + "holder": "{{name}} (pid {{pid}}) ले समातेको छ", + "holderUnknown": "देखिने कुनै धारक छैन", + "keyDown": "कुञ्जी थिचिएको", + "keyUp": "कुञ्जी छाडिएको", + "flagsChanged": "मोडिफायर", + "mouse": "माउस", + "verdictBlocked": "Secure Input ले कुञ्जी इभेन्टहरू रोकिरहेको छ — यो समाधान नभएसम्म सामान्य कुञ्जी भएका सर्टकटहरूले काम गर्न सक्दैनन्।", + "verdictSuspicious": "मोडिफायर इभेन्टहरू आए तर कुञ्जी इभेन्ट आएनन् — Secure Input अक्षम देखिए पनि केहीले कुञ्जीहरू दबाइरहेको छ। कृपया यसको रिपोर्ट GitHub मा गर्नुहोस्।", + "verdictOk": "कुञ्जी इभेन्टहरू Handy सम्म सामान्य रूपमा पुगिरहेका छन्।", + "verdictNoEvents": "कुनै इभेन्ट कैद भएन — के तपाईंले परीक्षणको क्रममा कुनै कुञ्जी थिच्नुभयो?", + "failed": "डायग्नोस्टिक असफल: {{error}}" } }, "about": { @@ -589,8 +622,10 @@ "preparing": "तयारी गरिँदैछ...", "checkForUpdates": "अपडेटहरू जाँच गर्नुहोस्", "portableUpdateTitle": "म्यानुअल अपडेट आवश्यक", - "portableUpdateMessage": "पोर्टेबल इन्स्टलहरू स्वतः अपडेट गर्न सकिँदैन। अपडेट गर्न: GitHub Releases बाट पछिल्लो NSIS इन्स्टलर डाउनलोड गर्नुहोस्, उही फोल्डरमा इन्स्टल गर्नुहोस्, अनि पुरानो संस्करणबाट आफ्नो Data/ फोल्डर (सेटिङ, मोडेल, रेकर्डिङ) नयाँमा कपी गर्नुहोस्।", - "portableUpdateButton": "GitHub Releases खोल्नुहोस्" + "portableUpdateMessage": "पोर्टेबल इन्स्टलहरू स्वतः अपडेट गर्न सकिँदैन। तलबाट आफ्नो प्रणालीका लागि इन्स्टलर डाउनलोड गर्नुहोस्, चलाउनुहोस् र उही फोल्डरमा इन्स्टल गर्नुहोस् — तपाईंको Data/ फोल्डर (सेटिङ, मोडेल, रेकर्डिङ) जस्ताको तस्तै रहन्छ।", + "portableUpdateButton": "इन्स्टलर डाउनलोड गर्नुहोस्", + "portableUpdateBrowseMessage": "पोर्टेबल इन्स्टलहरू स्वतः अपडेट गर्न सकिँदैन। GitHub Releases बाट आफ्नो प्रणालीका लागि पछिल्लो संस्करण डाउनलोड गर्नुहोस् र उही फोल्डरमा इन्स्टल गर्नुहोस् — तपाईंको Data/ फोल्डर (सेटिङ, मोडेल, रेकर्डिङ) जस्ताको तस्तै रहन्छ।", + "portableUpdateBrowseButton": "GitHub Releases खोल्नुहोस्" }, "whatsNew": { "title": "Handy v{{version}} मा नयाँ" @@ -642,5 +677,16 @@ "overlay": { "transcribing": "ट्रान्सक्राइब गरिँदैछ...", "processing": "प्रशोधन गरिँदैछ..." + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} ले 1 सर्टकट रोकिरहेको हुन सक्छ", + "blockedWithCulprit_other": "{{name}} ले {{count}} सर्टकटहरू रोकिरहेको हुन सक्छ", + "blockedNoCulprit_one": "macOS ले अस्थायी रूपमा 1 सर्टकट रोकिरहेको छ", + "blockedNoCulprit_other": "macOS ले अस्थायी रूपमा {{count}} सर्टकटहरू रोकिरहेको छ", + "recorderBlockedWithCulprit": "{{name}} ले सर्टकट परिवर्तन रोकिरहेको हुन सक्छ", + "recorderBlockedNoCulprit": "macOS ले अस्थायी रूपमा सर्टकट परिवर्तन रोकिरहेको छ", + "learnMore": "यसलाई कसरी ठिक गर्ने", + "recorderBlocked": "अहिले सर्टकट रेकर्ड गर्न सकिँदैन — macOS Secure Input ले कुञ्जी इभेन्टहरू रोकिरहेको छ। पहिले Secure Input चेतावनी समाधान गर्नुहोस्।", + "dismiss": "हटाउनुहोस्" } } diff --git a/src/i18n/locales/nl/translation.json b/src/i18n/locales/nl/translation.json index 63c02f1980..7e4c1d5d05 100644 --- a/src/i18n/locales/nl/translation.json +++ b/src/i18n/locales/nl/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Model ontladen", "model": "Model", "quit": "Afsluiten", - "cancel": "Annuleren" + "cancel": "Annuleren", + "secureInputWarning": "⚠ Sneltoetsen geblokkeerd door Secure Input" }, "sidebar": { "general": "Algemeen", @@ -208,7 +209,9 @@ "deleteActiveConfirm": "{{modelName}} is je actieve model. Als je dit verwijdert, stoppen de transcripties totdat je een nieuw model selecteert. Weet je het zeker?", "deleteTitle": "Model verwijderen", "filters": { - "allLanguages": "Alle talen" + "allLanguages": "Alle talen", + "streaming": "Filter modellen die live transcriptie ondersteunen", + "translation": "Filter modellen die vertaling naar het Engels ondersteunen" }, "rescan": { "label": "Opnieuw scannen", @@ -224,6 +227,12 @@ "placeholder": "Microfoon selecteren...", "loading": "Laden..." }, + "channel": { + "title": "Ingangskanaal", + "description": "Selecteer het ingangskanaal waarvan moet worden opgenomen. Gebruik dit als je audio-interface meerdere ingangen heeft.", + "average": "Gemiddelde van alle kanalen", + "channel": "Kanaal {{n}}" + }, "audioFeedback": { "label": "Geluidsfeedback", "description": "Geluid afspelen wanneer de opname start en stopt" @@ -540,9 +549,33 @@ "title": "Plakvertraging (na)", "description": "Vertraging (in milliseconden) na de plak-toetsaanslag, voordat je vorige klembord wordt hersteld. Verhoog dit als de oude klembordinhoud wordt geplakt in plaats van de transcriptie." }, + "reliablePaste": { + "title": "Betrouwbaar plakken (Beta)", + "description": "Herstel het klembord pas nadat de doelapp de transcriptie daadwerkelijk heeft gelezen, in plaats van na een vaste vertraging. Bedoeld om te verhelpen dat bij systeembelasting de oude klembordinhoud wordt geplakt. Vereist een plakmethode via het klembord; alleen macOS en Windows." + }, "recordingBuffer": { "title": "Extra opnamebuffer", "description": "Extra tijd (in milliseconden) om te blijven opnemen nadat je de toets loslaat, om naloopaudio op te vangen. 0 = geen extra buffer." + }, + "keyboardDiagnostic": { + "title": "Toetsenborddiagnose", + "description": "Controleert of toetsenbordgebeurtenissen Handy bereiken. Alleen het aantal gebeurtenissen wordt vastgelegd — nooit welke toetsen je indrukt.", + "run": "Diagnose van 10 s uitvoeren", + "running": "Luisteren… druk je sneltoets een paar keer in (bijv. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "ingeschakeld", + "disabled": "uitgeschakeld", + "holder": "vastgehouden door {{name}} (pid {{pid}})", + "holderUnknown": "geen zichtbare veroorzaker", + "keyDown": "Toets ingedrukt", + "keyUp": "Toets losgelaten", + "flagsChanged": "Modificatietoetsen", + "mouse": "Muis", + "verdictBlocked": "Secure Input blokkeert toetsgebeurtenissen — sneltoetsen met een gewone toets kunnen pas weer werken als dit is opgelost.", + "verdictSuspicious": "Er kwamen wel modificatiegebeurtenissen binnen, maar geen toetsgebeurtenissen — iets onderdrukt toetsen terwijl Secure Input als uitgeschakeld wordt gemeld. Meld dit op GitHub.", + "verdictOk": "Toetsgebeurtenissen bereiken Handy normaal.", + "verdictNoEvents": "Geen gebeurtenissen vastgelegd — heb je tijdens de test toetsen ingedrukt?", + "failed": "Diagnose mislukt: {{error}}" } }, "about": { @@ -589,8 +622,10 @@ "preparing": "Voorbereiden...", "checkForUpdates": "Controleren op updates", "portableUpdateTitle": "Handmatige update vereist", - "portableUpdateMessage": "Draagbare (portable) installaties kunnen niet automatisch worden bijgewerkt. Om bij te werken: download het nieuwste NSIS-installatiebestand van GitHub Releases, installeer het in dezelfde map en kopieer vervolgens je Data/-map (instellingen, modellen, opnames) van de oude versie naar de nieuwe.", - "portableUpdateButton": "GitHub Releases openen" + "portableUpdateMessage": "Draagbare (portable) installaties kunnen niet automatisch worden bijgewerkt. Download hieronder het installatiebestand voor je systeem, voer het uit en installeer in dezelfde map — je Data/-map (instellingen, modellen, opnames) blijft behouden.", + "portableUpdateButton": "Installatiebestand downloaden", + "portableUpdateBrowseMessage": "Draagbare (portable) installaties kunnen niet automatisch worden bijgewerkt. Download de nieuwste versie voor je systeem van GitHub Releases en installeer die in dezelfde map — je Data/-map (instellingen, modellen, opnames) blijft behouden.", + "portableUpdateBrowseButton": "GitHub Releases openen" }, "whatsNew": { "title": "Nieuw in Handy v{{version}}" @@ -642,5 +677,16 @@ "overlay": { "transcribing": "Transcriberen...", "processing": "Verwerken..." + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} blokkeert mogelijk 1 sneltoets", + "blockedWithCulprit_other": "{{name}} blokkeert mogelijk {{count}} sneltoetsen", + "blockedNoCulprit_one": "macOS blokkeert tijdelijk 1 sneltoets", + "blockedNoCulprit_other": "macOS blokkeert tijdelijk {{count}} sneltoetsen", + "recorderBlockedWithCulprit": "{{name}} blokkeert mogelijk wijzigingen aan sneltoetsen", + "recorderBlockedNoCulprit": "macOS blokkeert tijdelijk wijzigingen aan sneltoetsen", + "learnMore": "Hoe je dit oplost", + "recorderBlocked": "Sneltoetsen kunnen nu niet worden opgenomen — Secure Input van macOS blokkeert toetsgebeurtenissen. Los eerst de Secure Input-waarschuwing op.", + "dismiss": "Sluiten" } } diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 7b66ed77dc..45b7ba0951 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Zwolnij model", "model": "Model", "quit": "Zamknij", - "cancel": "Anuluj" + "cancel": "Anuluj", + "secureInputWarning": "⚠ Skróty zablokowane przez Secure Input" }, "sidebar": { "general": "Ogólne", @@ -19,10 +20,10 @@ }, "onboarding": { "subtitle": "Aby rozpocząć, wybierz model transkrypcji", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Zgodne modele", + "downloadModelsTitle": "Dostępne do pobrania", + "showAllModels": "Pokaż wszystkie modele ({{total}})", + "showFewerModels": "Pokaż mniej modeli", "recommended": "Polecane", "customModelDescription": "Nieoficjalnie wspierane", "modelCard": { @@ -96,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Nie udało się wybrać modelu" }, "permissions": { "title": "Wymagane uprawnienia", @@ -142,7 +143,7 @@ "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "Obsługuje wiele języków wejściowych", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} języków", "translation": "Może tłumaczyć na angielski", "translate": "Tłumacz na angielski", "singleLanguage": "Obsługuje tylko ten język", @@ -164,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} jest Twoim aktywnym modelem. Usunięcie go zatrzyma transkrypcje, dopóki nie wybierzesz nowego modelu. Czy na pewno chcesz kontynuować?", "deleteTitle": "Usuń model", "filters": { - "allLanguages": "Wszystkie języki" + "allLanguages": "Wszystkie języki", + "streaming": "Filtruj modele obsługujące transkrypcję na żywo", + "translation": "Filtruj modele obsługujące tłumaczenie na język angielski" }, "noModelsMatch": "Żadne modele nie pasują do tego filtra.", "yourModels": "Pobrane modele", @@ -209,10 +212,10 @@ "description": "Wybierz język rozpoznawania mowy. Opcja Auto automatycznie określi język, a wybór konkretnego języka może poprawić dokładność.", "searchPlaceholder": "Szukaj języka...", "noResults": "Nie znaleziono języków", - "auto": "Auto" + "auto": "Automatycznie" }, "pushToTalk": { - "label": "Push To Talk", + "label": "Naciśnij i mów", "description": "Przytrzymaj, aby nagrywać, puść, aby zatrzymać" } }, @@ -224,6 +227,12 @@ "placeholder": "Wybierz mikrofon...", "loading": "Wczytywanie..." }, + "channel": { + "title": "Kanał wejściowy", + "description": "Wybierz kanał wejściowy, z którego chcesz nagrywać. Użyj tej opcji, jeśli interfejs audio ma wiele wejść.", + "average": "Uśrednij wszystkie kanały", + "channel": "Kanał {{n}}" + }, "audioFeedback": { "label": "Informacja dźwiękowa", "description": "Odtwarzaj dźwięk przy rozpoczęciu i zakończeniu nagrywania" @@ -274,7 +283,7 @@ "description": "Akceleracja sprzętowa dla modeli ONNX (Parakeet, Canary, Moonshine itp.). DirectML na Windows jest eksperymentalne. Modele mogą nie transkrybować poprawnie." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automatycznie" } }, "startHidden": { @@ -291,12 +300,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Nakładka", + "description": "Wybierz nakładkę nagrywania: „Brak“ ją ukrywa, „Minimalna“ pokazuje kompaktowy wskaźnik, „Na żywo“ wyświetla transkrypcję w czasie rzeczywistym podczas mówienia (tylko modele obsługujące streaming — poszukaj etykiety „Streaming“ w selektorze modeli). W systemie Linux zalecana jest opcja „Brak“.", "options": { "none": "Brak", - "minimal": "Minimal", - "live": "Live" + "minimal": "Minimalna", + "live": "Na żywo" } }, "position": { @@ -374,8 +383,8 @@ "duplicate": "\"{{word}}\" już istnieje" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Wykrywanie aktywności głosowej", + "description": "Odfiltrowuje ciszę z nagrań. Modele obsługujące streaming używają dłuższego marginesu VAD; wyłączenie VAD nagrywa surowy dźwięk." } }, "postProcessing": { @@ -533,10 +542,34 @@ "title": "Opóźnienie wklejania (po)", "description": "Opóźnienie (w milisekundach) po klawiszu wklejania, przed przywróceniem poprzedniego schowka. Zwiększ, jeśli zamiast transkrypcji wklejana jest stara zawartość schowka." }, + "reliablePaste": { + "title": "Niezawodne wklejanie (Beta)", + "description": "Przywracaj schowek dopiero po tym, jak docelowa aplikacja faktycznie odczyta transkrypcję, a nie po ustalonym opóźnieniu. Ma naprawić wklejanie starej zawartości schowka przy obciążeniu systemu. Wymaga metody wklejania przez schowek; tylko macOS i Windows." + }, "recordingBuffer": { "title": "Dodatkowy bufor nagrywania", "description": "Dodatkowy czas (w milisekundach) na kontynuowanie nagrywania po zwolnieniu klawisza, aby przechwycić końcowy dźwięk. 0 = brak dodatkowego bufora." }, + "keyboardDiagnostic": { + "title": "Diagnostyka klawiatury", + "description": "Sprawdza, czy zdarzenia klawiatury docierają do Handy. Zapisywana jest tylko liczba zdarzeń — nigdy to, które klawisze naciskasz.", + "run": "Uruchom 10-sekundową diagnostykę", + "running": "Nasłuchiwanie… naciśnij swój skrót kilka razy (np. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "włączony", + "disabled": "wyłączony", + "holder": "trzymany przez {{name}} (pid {{pid}})", + "holderUnknown": "brak widocznego posiadacza", + "keyDown": "Klawisz wciśnięty", + "keyUp": "Klawisz zwolniony", + "flagsChanged": "Modyfikatory", + "mouse": "Mysz", + "verdictBlocked": "Secure Input blokuje zdarzenia klawiszy — skróty zawierające zwykły klawisz nie zadziałają, dopóki nie zostanie to rozwiązane.", + "verdictSuspicious": "Dotarły zdarzenia modyfikatorów, ale żadnych zdarzeń klawiszy — coś tłumi klawisze, mimo że Secure Input jest zgłaszany jako wyłączony. Zgłoś to na GitHubie.", + "verdictOk": "Zdarzenia klawiszy docierają do Handy normalnie.", + "verdictNoEvents": "Nie przechwycono żadnych zdarzeń — czy w trakcie testu naciśnięto jakieś klawisze?", + "failed": "Diagnostyka nie powiodła się: {{error}}" + }, "whatsNewPreview": { "title": "Podgląd nowości", "description": "Otwórz najnowsze dołączone informacje o wydaniu bez oznaczania ich jako wyświetlone", @@ -589,8 +622,10 @@ "preparing": "Przygotowywanie...", "checkForUpdates": "Sprawdź aktualizacje", "portableUpdateTitle": "Wymagana ręczna aktualizacja", - "portableUpdateMessage": "Instalacji przenośnych nie można aktualizować automatycznie. Aby zaktualizować: pobierz najnowszy instalator NSIS z GitHub Releases, zainstaluj go w tym samym folderze, a następnie skopiuj swój folder Data/ (ustawienia, modele, nagrania) ze starej wersji do nowej.", - "portableUpdateButton": "Otwórz GitHub Releases" + "portableUpdateMessage": "Instalacji przenośnych nie można aktualizować automatycznie. Pobierz poniżej instalator dla swojego systemu, uruchom go i zainstaluj w tym samym folderze — Twój folder Data/ (ustawienia, modele, nagrania) pozostaje na miejscu.", + "portableUpdateButton": "Pobierz instalator", + "portableUpdateBrowseMessage": "Instalacji przenośnych nie można aktualizować automatycznie. Pobierz najnowszą wersję dla swojego systemu z GitHub Releases i zainstaluj ją w tym samym folderze — Twój folder Data/ (ustawienia, modele, nagrania) pozostaje na miejscu.", + "portableUpdateBrowseButton": "Otwórz GitHub Releases" }, "common": { "loading": "Wczytywanie...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Nowości w Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} może blokować 1 skrót", + "blockedWithCulprit_other": "{{name}} może blokować {{count}} skrótów", + "blockedNoCulprit_one": "macOS tymczasowo blokuje 1 skrót", + "blockedNoCulprit_other": "macOS tymczasowo blokuje {{count}} skrótów", + "recorderBlockedWithCulprit": "{{name}} może blokować zmiany skrótów", + "recorderBlockedNoCulprit": "macOS tymczasowo blokuje zmiany skrótów", + "learnMore": "Jak to naprawić", + "recorderBlocked": "Nie można teraz nagrać skrótu — Secure Input w macOS blokuje zdarzenia klawiszy. Najpierw rozwiąż ostrzeżenie o Secure Input.", + "dismiss": "Odrzuć" } } diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index e7d34c175f..25dc76b2ed 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Descarregar modelo", "model": "Modelo", "quit": "Sair", - "cancel": "Cancelar" + "cancel": "Cancelar", + "secureInputWarning": "⚠ Atalhos bloqueados pelo Secure Input" }, "sidebar": { "general": "Geral", @@ -19,10 +20,10 @@ }, "onboarding": { "subtitle": "Para começar, escolha um modelo de transcrição", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Modelos compatíveis", + "downloadModelsTitle": "Disponíveis para download", + "showAllModels": "Mostrar todos os {{total}} modelos", + "showFewerModels": "Mostrar menos modelos", "recommended": "Recomendado", "customModelDescription": "Não suportado oficialmente", "modelCard": { @@ -96,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Falha ao selecionar o modelo" }, "permissions": { "title": "Permissões Necessárias", @@ -142,7 +143,7 @@ "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "Suporta vários idiomas de entrada", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} idiomas", "translation": "Pode traduzir para inglês", "translate": "Traduzir para inglês", "singleLanguage": "Suporta apenas este idioma", @@ -191,7 +192,7 @@ "description": "Selecione o idioma para reconhecimento de fala. Auto detectará automaticamente o idioma, enquanto selecionar um idioma específico pode melhorar a precisão para esse idioma.", "searchPlaceholder": "Buscar idiomas...", "noResults": "Nenhum idioma encontrado", - "auto": "Auto" + "auto": "Automático" }, "pushToTalk": { "label": "Pressionar para Falar", @@ -205,7 +206,9 @@ "deleteActiveConfirm": "{{modelName}} é o seu modelo ativo. Excluí-lo interromperá as transcrições até que você selecione um novo modelo. Tem certeza?", "deleteTitle": "Excluir Modelo", "filters": { - "allLanguages": "Todos os Idiomas" + "allLanguages": "Todos os Idiomas", + "streaming": "Filtrar modelos compatíveis com transcrição ao vivo", + "translation": "Filtrar modelos compatíveis com tradução para inglês" }, "noModelsMatch": "Nenhum modelo corresponde a este filtro.", "yourModels": "Modelos baixados", @@ -224,6 +227,12 @@ "placeholder": "Selecionar microfone...", "loading": "Carregando..." }, + "channel": { + "title": "Canal de entrada", + "description": "Selecione o canal de entrada que será gravado. Use esta opção se a sua interface de áudio tiver várias entradas.", + "average": "Média de todos os canais", + "channel": "Canal {{n}}" + }, "audioFeedback": { "label": "Feedback de Áudio", "description": "Reproduzir som quando a gravação iniciar e parar" @@ -274,7 +283,7 @@ "description": "Aceleração por hardware para modelos ONNX (Parakeet, Canary, Moonshine, etc.). DirectML no Windows é experimental. Os modelos podem falhar na transcrição." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automático" } }, "startHidden": { @@ -291,12 +300,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Sobreposição", + "description": "Escolha a sobreposição de gravação: 'Nenhum' a oculta, 'Minimalista' mostra um indicador compacto, 'Ao vivo' mostra a transcrição em tempo real enquanto você fala (apenas modelos com suporte a streaming — procure o selo 'Streaming' no seletor de modelos). No Linux, recomenda-se 'Nenhum'.", "options": { "none": "Nenhum", - "minimal": "Minimal", - "live": "Live" + "minimal": "Minimalista", + "live": "Ao vivo" } }, "position": { @@ -374,8 +383,8 @@ "duplicate": "\"{{word}}\" já existe" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Detecção de atividade de voz", + "description": "Filtra o silêncio das gravações. Modelos com suporte a streaming usam uma cauda de VAD mais longa; desativar o VAD grava o áudio bruto." } }, "postProcessing": { @@ -533,10 +542,34 @@ "title": "Atraso de colagem (depois)", "description": "Atraso (em milissegundos) após a tecla de colar, antes de restaurar a área de transferência anterior. Aumente se o conteúdo antigo da área de transferência estiver sendo colado em vez da transcrição." }, + "reliablePaste": { + "title": "Colagem confiável (Beta)", + "description": "Restaura a área de transferência somente depois que o aplicativo de destino realmente lê a transcrição, em vez de após um atraso fixo. Serve para corrigir a colagem do conteúdo antigo da área de transferência quando o sistema está sobrecarregado. Requer um método de colar via área de transferência; apenas macOS e Windows." + }, "recordingBuffer": { "title": "Buffer de gravação extra", "description": "Tempo extra (em milissegundos) para continuar gravando após soltar a tecla, para capturar áudio restante. 0 = sem buffer extra." }, + "keyboardDiagnostic": { + "title": "Diagnóstico do teclado", + "description": "Verifica se os eventos de teclado chegam ao Handy. Apenas a contagem de eventos é registrada — nunca quais teclas você pressiona.", + "run": "Executar diagnóstico de 10 s", + "running": "Ouvindo… pressione seu atalho algumas vezes (ex.: Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "ativado", + "disabled": "desativado", + "holder": "retido por {{name}} (pid {{pid}})", + "holderUnknown": "sem responsável visível", + "keyDown": "Tecla pressionada", + "keyUp": "Tecla liberada", + "flagsChanged": "Modificadores", + "mouse": "Mouse", + "verdictBlocked": "O Secure Input está bloqueando os eventos de tecla — atalhos que incluem uma tecla normal não funcionam até que isso seja resolvido.", + "verdictSuspicious": "Chegaram eventos de modificadores, mas nenhum evento de tecla — algo está suprimindo as teclas mesmo com o Secure Input indicando desativado. Por favor, relate isso no GitHub.", + "verdictOk": "Os eventos de tecla estão chegando normalmente ao Handy.", + "verdictNoEvents": "Nenhum evento capturado — você pressionou alguma tecla durante o teste?", + "failed": "Falha no diagnóstico: {{error}}" + }, "whatsNewPreview": { "title": "Pré-visualizar novidades", "description": "Abrir a nota de versão incorporada mais recente sem marcá-la como vista", @@ -589,8 +622,10 @@ "preparing": "Preparando...", "checkForUpdates": "Verificar atualizações", "portableUpdateTitle": "Atualização manual necessária", - "portableUpdateMessage": "Instalações portáteis não podem ser atualizadas automaticamente. Para atualizar: baixe o instalador NSIS mais recente do GitHub Releases, instale-o na mesma pasta e, em seguida, copie sua pasta Data/ (configurações, modelos, gravações) da versão antiga para a nova.", - "portableUpdateButton": "Abrir GitHub Releases" + "portableUpdateMessage": "Instalações portáteis não podem ser atualizadas automaticamente. Baixe abaixo o instalador para o seu sistema, execute-o e instale na mesma pasta — a sua pasta Data/ (configurações, modelos, gravações) é mantida.", + "portableUpdateButton": "Baixar instalador", + "portableUpdateBrowseMessage": "Instalações portáteis não podem ser atualizadas automaticamente. Baixe a versão mais recente para o seu sistema no GitHub Releases e instale-a na mesma pasta — a sua pasta Data/ (configurações, modelos, gravações) é mantida.", + "portableUpdateBrowseButton": "Abrir GitHub Releases" }, "common": { "loading": "Carregando...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Novidades no Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} pode estar bloqueando 1 atalho", + "blockedWithCulprit_other": "{{name}} pode estar bloqueando {{count}} atalhos", + "blockedNoCulprit_one": "O macOS está bloqueando temporariamente 1 atalho", + "blockedNoCulprit_other": "O macOS está bloqueando temporariamente {{count}} atalhos", + "recorderBlockedWithCulprit": "{{name}} pode estar bloqueando alterações nos atalhos", + "recorderBlockedNoCulprit": "O macOS está bloqueando temporariamente alterações nos atalhos", + "learnMore": "Como resolver isso", + "recorderBlocked": "Não é possível gravar atalhos agora — o Secure Input do macOS está bloqueando os eventos de tecla. Resolva primeiro o aviso do Secure Input.", + "dismiss": "Dispensar" } } diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 1c8c078f82..0de1a2af84 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Выгрузить модель", "model": "Модель", "quit": "Выход", - "cancel": "Отмена" + "cancel": "Отмена", + "secureInputWarning": "⚠ Сочетания клавиш заблокированы Secure Input" }, "sidebar": { "general": "Общие", @@ -19,10 +20,10 @@ }, "onboarding": { "subtitle": "Для начала выберите модель транскрипции", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Совместимые модели", + "downloadModelsTitle": "Доступны для загрузки", + "showAllModels": "Показать все модели ({{total}})", + "showFewerModels": "Показать меньше моделей", "recommended": "Рекомендуется", "customModelDescription": "Официально не поддерживается", "modelCard": { @@ -96,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Не удалось выбрать модель" }, "permissions": { "title": "Требуются разрешения", @@ -142,7 +143,7 @@ "downloadSpeed": "{{speed}} МБ/с", "capabilities": { "languageSelection": "Поддерживает несколько языков ввода", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} языков", "translation": "Может переводить на английский", "translate": "Перевод на английский", "singleLanguage": "Поддерживает только этот язык", @@ -164,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} — ваша активная модель. Удаление остановит транскрипцию, пока вы не выберете новую модель. Вы уверены?", "deleteTitle": "Удалить модель", "filters": { - "allLanguages": "Все языки" + "allLanguages": "Все языки", + "streaming": "Фильтровать модели с поддержкой транскрипции в реальном времени", + "translation": "Фильтровать модели с поддержкой перевода на английский" }, "noModelsMatch": "Нет моделей, соответствующих этому фильтру.", "yourModels": "Загруженные модели", @@ -224,6 +227,12 @@ "placeholder": "Выбрать микрофон...", "loading": "Загрузка..." }, + "channel": { + "title": "Входной канал", + "description": "Выберите входной канал для записи. Используйте эту настройку, если у вашего аудиоинтерфейса несколько входов.", + "average": "Усреднять все каналы", + "channel": "Канал {{n}}" + }, "audioFeedback": { "label": "Обратная звуковая связь", "description": "Воспроизведение звука при запуске и остановке записи" @@ -291,12 +300,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Наложение", + "description": "Выберите наложение при записи: «Нет» скрывает его, «Минимальное» показывает компактный индикатор, «В реальном времени» показывает транскрипцию по мере речи (только модели с поддержкой потоковой обработки — ищите значок «Потоковый» в списке моделей). В Linux рекомендуется «Нет».", "options": { "none": "Нет", - "minimal": "Minimal", - "live": "Live" + "minimal": "Минимальное", + "live": "В реальном времени" } }, "position": { @@ -341,10 +350,10 @@ "description": "Автоматически отправляет выбранную комбинацию клавиш после вставки текста. Cmd+Enter применяется на macOS, а Windows/Linux используют Super+Enter.", "options": { "off": "Выкл.", - "enter": "Enter", - "cmdEnter": "Cmd+Enter", - "superEnter": "Super+Enter", - "ctrlEnter": "Ctrl+Enter" + "enter": "Ввод", + "cmdEnter": "Cmd+Ввод", + "superEnter": "Super+Ввод", + "ctrlEnter": "Ctrl+Ввод" } }, "translateToEnglish": { @@ -374,8 +383,8 @@ "duplicate": "\"{{word}}\" уже существует" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Определение голосовой активности", + "description": "Отфильтровывает тишину из записей. Модели с поддержкой потоковой обработки используют более длинный «хвост» VAD; отключение VAD записывает необработанный звук." } }, "postProcessing": { @@ -533,10 +542,34 @@ "title": "Задержка вставки (после)", "description": "Задержка (в миллисекундах) после нажатия клавиши вставки, перед восстановлением предыдущего буфера обмена. Увеличьте, если вместо транскрипции вставляется старое содержимое буфера обмена." }, + "reliablePaste": { + "title": "Надёжная вставка (Beta)", + "description": "Восстанавливать буфер обмена только после того, как целевое приложение действительно прочитает транскрипцию, а не по фиксированной задержке. Предназначено для устранения вставки старого содержимого буфера обмена при нагрузке на систему. Требуется метод вставки через буфер обмена; только macOS и Windows." + }, "recordingBuffer": { "title": "Дополнительный буфер записи", "description": "Дополнительное время (в миллисекундах) для продолжения записи после отпускания клавиши, чтобы захватить завершающий звук. 0 = без дополнительного буфера." }, + "keyboardDiagnostic": { + "title": "Диагностика клавиатуры", + "description": "Проверяет, доходят ли события клавиатуры до Handy. Записывается только количество событий — никогда то, какие клавиши вы нажимаете.", + "run": "Запустить 10-секундную диагностику", + "running": "Прослушивание… нажмите своё сочетание клавиш несколько раз (например, Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "включён", + "disabled": "выключен", + "holder": "удерживает {{name}} (pid {{pid}})", + "holderUnknown": "видимый владелец не найден", + "keyDown": "Нажатие клавиши", + "keyUp": "Отпускание клавиши", + "flagsChanged": "Модификаторы", + "mouse": "Мышь", + "verdictBlocked": "Secure Input блокирует события клавиш — сочетания с обычной клавишей не смогут работать, пока это не будет устранено.", + "verdictSuspicious": "События модификаторов приходят, а события клавиш — нет: что-то подавляет клавиши, хотя Secure Input отмечен как выключенный. Пожалуйста, сообщите об этом на GitHub.", + "verdictOk": "События клавиш доходят до Handy нормально.", + "verdictNoEvents": "События не зафиксированы — вы нажимали клавиши во время теста?", + "failed": "Диагностика не удалась: {{error}}" + }, "whatsNewPreview": { "title": "Предпросмотр новинок", "description": "Открыть последние встроенные примечания к выпуску, не отмечая их как просмотренные", @@ -589,8 +622,10 @@ "preparing": "Подготовка...", "checkForUpdates": "Проверить наличие обновлений", "portableUpdateTitle": "Требуется ручное обновление", - "portableUpdateMessage": "Портативные установки нельзя обновить автоматически. Чтобы обновить: скачайте последний установщик NSIS со страницы GitHub Releases, установите его в ту же папку, затем скопируйте папку Data/ (настройки, модели, записи) из старой версии в новую.", - "portableUpdateButton": "Открыть GitHub Releases" + "portableUpdateMessage": "Портативные установки нельзя обновить автоматически. Скачайте ниже установщик для вашей системы, запустите его и установите в ту же папку — папка Data/ (настройки, модели, записи) останется на месте.", + "portableUpdateButton": "Скачать установщик", + "portableUpdateBrowseMessage": "Портативные установки нельзя обновить автоматически. Скачайте последнюю версию для вашей системы со страницы GitHub Releases и установите её в ту же папку — папка Data/ (настройки, модели, записи) останется на месте.", + "portableUpdateBrowseButton": "Открыть GitHub Releases" }, "common": { "loading": "Загрузка...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Новое в Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} может блокировать 1 сочетание клавиш", + "blockedWithCulprit_other": "{{name}} может блокировать {{count}} сочетаний клавиш", + "blockedNoCulprit_one": "macOS временно блокирует 1 сочетание клавиш", + "blockedNoCulprit_other": "macOS временно блокирует {{count}} сочетаний клавиш", + "recorderBlockedWithCulprit": "{{name}} может блокировать изменение сочетаний клавиш", + "recorderBlockedNoCulprit": "macOS временно блокирует изменение сочетаний клавиш", + "learnMore": "Как это исправить", + "recorderBlocked": "Сейчас нельзя записать сочетание клавиш — Secure Input в macOS блокирует события клавиш. Сначала устраните предупреждение о Secure Input.", + "dismiss": "Скрыть" } } diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index 07b0293d5d..8078195c5b 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Avlasta modell", "model": "Modell", "quit": "Avsluta", - "cancel": "Avbryt" + "cancel": "Avbryt", + "secureInputWarning": "⚠ Kortkommandon blockeras av Secure Input" }, "sidebar": { "general": "Allmänt", @@ -19,10 +20,10 @@ }, "onboarding": { "subtitle": "För att komma igång, välj en transkriptionsmodell", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Kompatibla modeller", + "downloadModelsTitle": "Tillgängliga för nedladdning", + "showAllModels": "Visa alla {{total}} modeller", + "showFewerModels": "Visa färre modeller", "recommended": "Rekommenderat", "customModelDescription": "Stöds inte officiellt", "modelCard": { @@ -96,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Det gick inte att välja modell" }, "permissions": { "title": "Behörigheter krävs", @@ -145,7 +146,7 @@ "capabilities": { "languageSelection": "Stöder flera indataspråk", "singleLanguage": "Stöder endast detta språk", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} språk", "languageOnly": "Endast {{language}}", "translation": "Kan översätta till engelska", "translate": "Översätt till engelska", @@ -191,7 +192,7 @@ "description": "Välj språk för taligenkänning. Auto kommer automatiskt att bestämma språket, medan val av ett specifikt språk kan förbättra noggrannheten för det språket.", "searchPlaceholder": "Sök språk...", "noResults": "Inga språk hittades", - "auto": "Auto" + "auto": "Automatiskt" }, "pushToTalk": { "label": "Tryck för att tala", @@ -207,7 +208,9 @@ "deleteActiveConfirm": "{{modelName}} är din aktiva modell. Om du tar bort den avbryts transkriptioner tills du väljer en ny modell. Är du säker?", "deleteTitle": "Ta bort modell", "filters": { - "allLanguages": "Alla språk" + "allLanguages": "Alla språk", + "streaming": "Filtrera modeller som stöder transkription i realtid", + "translation": "Filtrera modeller som stöder översättning till engelska" }, "noModelsMatch": "Inga modeller matchar detta filter.", "searchPlaceholder": "Sök modeller efter namn…", @@ -224,6 +227,12 @@ "placeholder": "Välj mikrofon...", "loading": "Laddar..." }, + "channel": { + "title": "Ingångskanal", + "description": "Välj vilken ingångskanal du vill spela in från. Använd detta om ditt ljudgränssnitt har flera ingångar.", + "average": "Medelvärde av alla kanaler", + "channel": "Kanal {{n}}" + }, "audioFeedback": { "label": "Ljudåterkoppling", "description": "Spela upp ljud när inspelningen startar och stoppar" @@ -274,7 +283,7 @@ "description": "Hårdvaruacceleration för ONNX-modeller (Parakeet, Canary, Moonshine, etc.). DirectML på Windows är experimentellt. Modeller kan misslyckas med att transkribera." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automatiskt" } }, "startHidden": { @@ -292,7 +301,7 @@ "overlay": { "style": { "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "description": "Välj inspelningsöverlägget: ”Ingen” döljer det, ”Minimal” visar en kompakt indikator, ”Live” visar transkriberingen i realtid medan du talar (endast modeller med streamingstöd — leta efter Streaming-märket i modellväljaren). På Linux rekommenderas ”Ingen”.", "options": { "none": "Ingen", "minimal": "Minimal", @@ -374,8 +383,8 @@ "duplicate": "\"{{word}}\" finns redan" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Röstaktivitetsdetektering", + "description": "Filtrerar bort tystnad från inspelningar. Modeller med streamingstöd använder en längre VAD-svans; om VAD inaktiveras spelas rått ljud in." } }, "postProcessing": { @@ -533,10 +542,34 @@ "title": "Fördröjning vid klistra in (efter)", "description": "Fördröjning (i millisekunder) efter tangenttryckningen för att klistra in, innan ditt tidigare urklipp återställs. Öka om det gamla urklippsinnehållet klistras in i stället för transkriptionen." }, + "reliablePaste": { + "title": "Tillförlitlig inklistring (Beta)", + "description": "Återställ urklipp först när målappen faktiskt har läst transkriptionen, i stället för efter en fast fördröjning. Avsett att åtgärda att det gamla urklippsinnehållet klistras in när systemet är belastat. Kräver en klistra-in-metod via urklipp; endast macOS och Windows." + }, "recordingBuffer": { "title": "Extra inspelningsbuffert", "description": "Extra tid (i millisekunder) för att fortsätta spela in efter att du släppt tangenten, för att fånga upp avslutande ljud. 0 = ingen extra buffert." }, + "keyboardDiagnostic": { + "title": "Tangentbordsdiagnostik", + "description": "Kontrollerar om tangentbordshändelser når Handy. Endast antalet händelser registreras — aldrig vilka tangenter du trycker på.", + "run": "Kör 10 s diagnostik", + "running": "Lyssnar… tryck på ditt kortkommando några gånger (t.ex. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "aktiverat", + "disabled": "inaktiverat", + "holder": "hålls av {{name}} (pid {{pid}})", + "holderUnknown": "ingen synlig innehavare", + "keyDown": "Tangent ned", + "keyUp": "Tangent upp", + "flagsChanged": "Modifierare", + "mouse": "Mus", + "verdictBlocked": "Secure Input blockerar tangenthändelser — kortkommandon med en vanlig tangent kan inte fungera förrän det är löst.", + "verdictSuspicious": "Modifierarhändelser kom fram men inga tangenthändelser — något undertrycker tangenter trots att Secure Input rapporteras som inaktiverat. Rapportera gärna detta på GitHub.", + "verdictOk": "Tangenthändelser når Handy som vanligt.", + "verdictNoEvents": "Inga händelser fångades — tryckte du på några tangenter under testet?", + "failed": "Diagnostiken misslyckades: {{error}}" + }, "whatsNewPreview": { "title": "Förhandsvisa nyheter", "description": "Öppna den senaste medföljande versionsinformationen utan att markera den som visad", @@ -589,8 +622,10 @@ "preparing": "Förbereder...", "checkForUpdates": "Sök efter uppdateringar", "portableUpdateTitle": "Manuell uppdatering krävs", - "portableUpdateMessage": "Portabla installationer kan inte uppdateras automatiskt. Så här uppdaterar du: ladda ner den senaste NSIS-installeraren från GitHub Releases, installera den i samma mapp och kopiera sedan din Data/-mapp (inställningar, modeller, inspelningar) från den gamla versionen till den nya.", - "portableUpdateButton": "Öppna GitHub Releases" + "portableUpdateMessage": "Portabla installationer kan inte uppdateras automatiskt. Ladda ner installeraren för ditt system nedan, kör den och installera i samma mapp — din Data/-mapp (inställningar, modeller, inspelningar) behålls.", + "portableUpdateButton": "Ladda ner installeraren", + "portableUpdateBrowseMessage": "Portabla installationer kan inte uppdateras automatiskt. Ladda ner den senaste versionen för ditt system från GitHub Releases och installera den i samma mapp — din Data/-mapp (inställningar, modeller, inspelningar) behålls.", + "portableUpdateBrowseButton": "Öppna GitHub Releases" }, "common": { "loading": "Laddar...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Nytt i Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} kanske blockerar 1 kortkommando", + "blockedWithCulprit_other": "{{name}} kanske blockerar {{count}} kortkommandon", + "blockedNoCulprit_one": "macOS blockerar tillfälligt 1 kortkommando", + "blockedNoCulprit_other": "macOS blockerar tillfälligt {{count}} kortkommandon", + "recorderBlockedWithCulprit": "{{name}} kanske blockerar ändringar av kortkommandon", + "recorderBlockedNoCulprit": "macOS blockerar tillfälligt ändringar av kortkommandon", + "learnMore": "Så här löser du det", + "recorderBlocked": "Det går inte att spela in kortkommandon just nu — macOS Secure Input blockerar tangenthändelser. Lös varningen om Secure Input först.", + "dismiss": "Avfärda" } } diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 88fc90991b..806188e677 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Modeli boşalt", "model": "Model", "quit": "Çıkış", - "cancel": "İptal" + "cancel": "İptal", + "secureInputWarning": "⚠ Kısayollar Secure Input tarafından engelleniyor" }, "sidebar": { "general": "Genel", @@ -19,10 +20,10 @@ }, "onboarding": { "subtitle": "Başlamak için bir transkripsiyon modeli seçin", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Uyumlu Modeller", + "downloadModelsTitle": "İndirilebilir", + "showAllModels": "{{total}} modelin tümünü göster", + "showFewerModels": "Daha az model göster", "recommended": "Önerilen", "customModelDescription": "Resmi olarak desteklenmiyor", "modelCard": { @@ -96,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Model seçilemedi" }, "permissions": { "title": "İzinler Gerekli", @@ -142,7 +143,7 @@ "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "Birden fazla giriş dilini destekler", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} dil", "translation": "İngilizce'ye çevirebilir", "translate": "İngilizce'ye çevir", "singleLanguage": "Yalnızca bu dili destekler", @@ -164,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} aktif modelinizdir. Silmek, yeni bir model seçene kadar transkripsiyonları durduracaktır. Emin misiniz?", "deleteTitle": "Modeli Sil", "filters": { - "allLanguages": "Tüm Diller" + "allLanguages": "Tüm Diller", + "streaming": "Canlı transkripsiyonu destekleyen modelleri filtrele", + "translation": "İngilizce'ye çeviriyi destekleyen modelleri filtrele" }, "noModelsMatch": "Bu filtreyle eşleşen model yok.", "yourModels": "İndirilen modeller", @@ -224,6 +227,12 @@ "placeholder": "Mikrofon seçin...", "loading": "Yükleniyor..." }, + "channel": { + "title": "Giriş Kanalı", + "description": "Kayıt yapılacak giriş kanalını seçin. Ses arabiriminizde birden fazla giriş varsa bu seçeneği kullanın.", + "average": "Tüm kanalların ortalaması", + "channel": "Kanal {{n}}" + }, "audioFeedback": { "label": "Sesli Geri Bildirim", "description": "Kayıt başladığında ve bittiğinde ses çalar" @@ -291,12 +300,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Yer Paylaşımı", + "description": "Kayıt sırasındaki yer paylaşımını seçin: 'Yok' gizler, 'Sade' küçük bir gösterge gösterir, 'Canlı' siz konuşurken transkripsiyonu gerçek zamanlı gösterir (yalnızca akış destekli modeller — model seçicide 'Akış' rozetini arayın). Linux'ta 'Yok' önerilir.", "options": { "none": "Yok", - "minimal": "Minimal", - "live": "Live" + "minimal": "Sade", + "live": "Canlı" } }, "position": { @@ -374,8 +383,8 @@ "duplicate": "\"{{word}}\" zaten mevcut" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Ses Etkinliği Algılama", + "description": "Kayıtlardaki sessizliği filtreler. Akış destekli modeller daha uzun bir VAD kuyruğu kullanır; VAD devre dışı bırakıldığında ham ses kaydedilir." } }, "postProcessing": { @@ -411,7 +420,7 @@ } }, "prompts": { - "title": "Prompt", + "title": "İstem", "selectedPrompt": { "title": "Seçili Prompt", "description": "Transkripsiyonları iyileştirmek için bir şablon seçin veya yeni bir tane oluşturun. Yakalanan transkripte referans vermek için prompt metni içinde ${output} kullanın." @@ -533,10 +542,34 @@ "title": "Yapıştırma gecikmesi (sonra)", "description": "Yapıştırma tuşundan sonra, önceki panonuz geri yüklenmeden önceki gecikme (milisaniye cinsinden). Yazıya dökülen metin yerine eski pano içeriği yapıştırılıyorsa artırın." }, + "reliablePaste": { + "title": "Güvenilir yapıştırma (Beta)", + "description": "Panoyu sabit bir gecikmenin ardından değil, hedef uygulama yazıya dökülen metni gerçekten okuduktan sonra geri yükler. Sistem yükü altında eski pano içeriğinin yapıştırılması sorununu gidermeyi amaçlar. Pano üzerinden çalışan bir yapıştırma yöntemi gerektirir; yalnızca macOS ve Windows." + }, "recordingBuffer": { "title": "Ekstra kayıt tamponu", "description": "Tuşu bıraktıktan sonra arka plandaki sesi yakalamak için kaydı sürdürme süresi (milisaniye). 0 = ekstra tampon yok." }, + "keyboardDiagnostic": { + "title": "Klavye Tanılaması", + "description": "Klavye olaylarının Handy'ye ulaşıp ulaşmadığını denetler. Yalnızca olay sayıları kaydedilir — hangi tuşlara bastığınız asla kaydedilmez.", + "run": "10 sn'lik tanılamayı çalıştır", + "running": "Dinleniyor… kısayolunuza birkaç kez basın (ör. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "etkin", + "disabled": "devre dışı", + "holder": "{{name}} tarafından tutuluyor (pid {{pid}})", + "holderUnknown": "görünür bir sahip yok", + "keyDown": "Tuş basma", + "keyUp": "Tuş bırakma", + "flagsChanged": "Değiştiriciler", + "mouse": "Fare", + "verdictBlocked": "Secure Input tuş olaylarını engelliyor — normal tuş içeren kısayollar bu sorun çözülene kadar çalışamaz.", + "verdictSuspicious": "Değiştirici olayları geldi ancak hiç tuş olayı gelmedi — Secure Input devre dışı görünmesine rağmen bir şey tuşları bastırıyor. Lütfen bunu GitHub'da bildirin.", + "verdictOk": "Tuş olayları Handy'ye normal şekilde ulaşıyor.", + "verdictNoEvents": "Hiç olay yakalanmadı — test sırasında herhangi bir tuşa bastınız mı?", + "failed": "Tanılama başarısız: {{error}}" + }, "whatsNewPreview": { "title": "Yenilikleri Önizle", "description": "En son paketlenmiş sürüm notunu görüldü olarak işaretlemeden aç", @@ -589,8 +622,10 @@ "preparing": "Hazırlanıyor...", "checkForUpdates": "Güncellemeleri kontrol et", "portableUpdateTitle": "Manuel güncelleme gerekli", - "portableUpdateMessage": "Taşınabilir kurulumlar otomatik olarak güncellenemez. Güncellemek için: GitHub Releases’ten en son NSIS yükleyicisini indirin, aynı klasöre kurun ve ardından Data/ klasörünüzü (ayarlar, modeller, kayıtlar) eski sürümden yenisine kopyalayın.", - "portableUpdateButton": "GitHub Releases’i aç" + "portableUpdateMessage": "Taşınabilir kurulumlar otomatik olarak güncellenemez. Aşağıdan sisteminize uygun yükleyiciyi indirin, çalıştırın ve aynı klasöre kurun — Data/ klasörünüz (ayarlar, modeller, kayıtlar) yerinde kalır.", + "portableUpdateButton": "Yükleyiciyi indir", + "portableUpdateBrowseMessage": "Taşınabilir kurulumlar otomatik olarak güncellenemez. Sisteminize uygun en son sürümü GitHub Releases’ten indirin ve aynı klasöre kurun — Data/ klasörünüz (ayarlar, modeller, kayıtlar) yerinde kalır.", + "portableUpdateBrowseButton": "GitHub Releases’i aç" }, "common": { "loading": "Yükleniyor...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Handy v{{version}} yenilikleri" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} 1 kısayolu engelliyor olabilir", + "blockedWithCulprit_other": "{{name}} {{count}} kısayolu engelliyor olabilir", + "blockedNoCulprit_one": "macOS geçici olarak 1 kısayolu engelliyor", + "blockedNoCulprit_other": "macOS geçici olarak {{count}} kısayolu engelliyor", + "recorderBlockedWithCulprit": "{{name}} kısayol değişikliklerini engelliyor olabilir", + "recorderBlockedNoCulprit": "macOS geçici olarak kısayol değişikliklerini engelliyor", + "learnMore": "Bu nasıl düzeltilir", + "recorderBlocked": "Şu anda kısayol kaydedilemiyor — macOS Secure Input tuş olaylarını engelliyor. Önce Secure Input uyarısını giderin.", + "dismiss": "Kapat" } } diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index db93baaeca..8b07d81955 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Вивантажити модель", "model": "Модель", "quit": "Вийти", - "cancel": "Скасувати" + "cancel": "Скасувати", + "secureInputWarning": "⚠ Комбінації клавіш заблоковано Secure Input" }, "sidebar": { "general": "Загальні", @@ -19,10 +20,10 @@ }, "onboarding": { "subtitle": "Для початку оберіть модель транскрипції", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Сумісні моделі", + "downloadModelsTitle": "Доступні для завантаження", + "showAllModels": "Показати всі моделі ({{total}})", + "showFewerModels": "Показати менше моделей", "recommended": "Рекомендовано", "customModelDescription": "Офіційно не підтримується", "modelCard": { @@ -96,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Не вдалося вибрати модель" }, "permissions": { "title": "Потрібні дозволи", @@ -142,7 +143,7 @@ "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "Підтримує кілька мов введення", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} мов", "translation": "Може перекладати на англійську", "translate": "Перекласти на англійську", "singleLanguage": "Підтримує лише цю мову", @@ -205,7 +206,9 @@ "deleteActiveConfirm": "{{modelName}} — ваша активна модель. Видалення зупинить транскрипцію, доки ви не оберете нову модель. Ви впевнені?", "deleteTitle": "Видалити модель", "filters": { - "allLanguages": "Усі мови" + "allLanguages": "Усі мови", + "streaming": "Фільтрувати моделі з підтримкою транскрипції в реальному часі", + "translation": "Фільтрувати моделі з підтримкою перекладу англійською" }, "noModelsMatch": "Жодна модель не відповідає цьому фільтру.", "yourModels": "Завантажені моделі", @@ -224,6 +227,12 @@ "placeholder": "Оберіть мікрофон...", "loading": "Завантаження..." }, + "channel": { + "title": "Вхідний канал", + "description": "Виберіть вхідний канал для запису. Використовуйте цю опцію, якщо ваш аудіоінтерфейс має кілька входів.", + "average": "Усереднювати всі канали", + "channel": "Канал {{n}}" + }, "audioFeedback": { "label": "Звукове сповіщення", "description": "Відтворювати звук при початку та зупинці запису" @@ -291,12 +300,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Накладка", + "description": "Виберіть накладку під час запису: «Немає» приховує її, «Мінімальна» показує компактний індикатор, «У реальному часі» показує транскрипцію під час мовлення (лише моделі з підтримкою потокової обробки — шукайте позначку «Потокове» у списку моделей). У Linux рекомендовано «Немає».", "options": { "none": "Немає", - "minimal": "Minimal", - "live": "Live" + "minimal": "Мінімальна", + "live": "У реальному часі" } }, "position": { @@ -374,8 +383,8 @@ "duplicate": "\"{{word}}\" вже існує" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Виявлення голосової активності", + "description": "Відфільтровує тишу із записів. Моделі з підтримкою потокової обробки використовують довший «хвіст» VAD; вимкнення VAD записує необроблений звук." } }, "postProcessing": { @@ -533,10 +542,34 @@ "title": "Затримка вставки (після)", "description": "Затримка (у мілісекундах) після натискання клавіші вставки, перед відновленням попереднього буфера обміну. Збільшіть, якщо замість транскрипції вставляється старий вміст буфера обміну." }, + "reliablePaste": { + "title": "Надійна вставка (Beta)", + "description": "Відновлювати буфер обміну лише після того, як цільова програма справді прочитає транскрипцію, а не через фіксовану затримку. Призначено для усунення вставки старого вмісту буфера обміну під навантаженням на систему. Потрібен метод вставки через буфер обміну; лише macOS і Windows." + }, "recordingBuffer": { "title": "Додатковий буфер запису", "description": "Додатковий час (у мілісекундах) для продовження запису після відпускання клавіші, щоб захопити завершальний звук. 0 = без додаткового буфера." }, + "keyboardDiagnostic": { + "title": "Діагностика клавіатури", + "description": "Перевіряє, чи доходять події клавіатури до Handy. Записується лише кількість подій — ніколи те, які клавіші ви натискаєте.", + "run": "Запустити 10-секундну діагностику", + "running": "Прослуховування… натисніть свою комбінацію кілька разів (наприклад, Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "увімкнено", + "disabled": "вимкнено", + "holder": "утримує {{name}} (pid {{pid}})", + "holderUnknown": "видимого власника не знайдено", + "keyDown": "Натискання клавіші", + "keyUp": "Відпускання клавіші", + "flagsChanged": "Модифікатори", + "mouse": "Миша", + "verdictBlocked": "Secure Input блокує події клавіш — комбінації зі звичайною клавішею не працюватимуть, доки це не буде усунуто.", + "verdictSuspicious": "Події модифікаторів надходять, а події клавіш — ні: щось пригнічує клавіші, хоча Secure Input позначено як вимкнений. Будь ласка, повідомте про це на GitHub.", + "verdictOk": "Події клавіш надходять до Handy нормально.", + "verdictNoEvents": "Подій не зафіксовано — ви натискали клавіші під час тесту?", + "failed": "Не вдалося виконати діагностику: {{error}}" + }, "whatsNewPreview": { "title": "Попередній перегляд новин", "description": "Відкрити останні вбудовані примітки до випуску, не позначаючи їх як переглянуті", @@ -589,8 +622,10 @@ "preparing": "Підготовка...", "checkForUpdates": "Перевірити оновлення", "portableUpdateTitle": "Потрібне ручне оновлення", - "portableUpdateMessage": "Портативні встановлення не можна оновити автоматично. Щоб оновити: завантажте найновіший інсталятор NSIS зі сторінки GitHub Releases, установіть його в ту саму теку, а потім скопіюйте теку Data/ (налаштування, моделі, записи) зі старої версії до нової.", - "portableUpdateButton": "Відкрити GitHub Releases" + "portableUpdateMessage": "Портативні встановлення не можна оновити автоматично. Завантажте нижче інсталятор для вашої системи, запустіть його та встановіть у ту саму теку — ваша тека Data/ (налаштування, моделі, записи) залишиться на місці.", + "portableUpdateButton": "Завантажити інсталятор", + "portableUpdateBrowseMessage": "Портативні встановлення не можна оновити автоматично. Завантажте найновішу версію для вашої системи зі сторінки GitHub Releases та встановіть її в ту саму теку — ваша тека Data/ (налаштування, моделі, записи) залишиться на місці.", + "portableUpdateBrowseButton": "Відкрити GitHub Releases" }, "common": { "loading": "Завантаження...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Нове в Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} може блокувати 1 комбінацію клавіш", + "blockedWithCulprit_other": "{{name}} може блокувати {{count}} комбінацій клавіш", + "blockedNoCulprit_one": "macOS тимчасово блокує 1 комбінацію клавіш", + "blockedNoCulprit_other": "macOS тимчасово блокує {{count}} комбінацій клавіш", + "recorderBlockedWithCulprit": "{{name}} може блокувати зміну комбінацій клавіш", + "recorderBlockedNoCulprit": "macOS тимчасово блокує зміну комбінацій клавіш", + "learnMore": "Як це виправити", + "recorderBlocked": "Зараз неможливо записати комбінацію — Secure Input у macOS блокує події клавіш. Спершу усуньте попередження про Secure Input.", + "dismiss": "Приховати" } } diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 1849596584..dcb17e6379 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Dỡ mô hình", "model": "Mô hình", "quit": "Thoát", - "cancel": "Hủy" + "cancel": "Hủy", + "secureInputWarning": "⚠ Phím tắt bị Secure Input chặn" }, "sidebar": { "general": "Chung", @@ -19,10 +20,10 @@ }, "onboarding": { "subtitle": "Để bắt đầu, hãy chọn một mô hình chuyển đổi giọng nói", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Mô hình tương thích", + "downloadModelsTitle": "Có sẵn để tải xuống", + "showAllModels": "Hiển thị tất cả {{total}} mô hình", + "showFewerModels": "Hiển thị ít mô hình hơn", "recommended": "Đề xuất", "customModelDescription": "Không được hỗ trợ chính thức", "modelCard": { @@ -96,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Không thể chọn mô hình" }, "permissions": { "title": "Cần cấp quyền", @@ -142,7 +143,7 @@ "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "Hỗ trợ nhiều ngôn ngữ đầu vào", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} ngôn ngữ", "translation": "Có thể dịch sang tiếng Anh", "translate": "Dịch sang tiếng Anh", "singleLanguage": "Chỉ hỗ trợ ngôn ngữ này", @@ -164,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} là mô hình đang hoạt động của bạn. Xóa nó sẽ dừng phiên âm cho đến khi bạn chọn một mô hình mới. Bạn có chắc không?", "deleteTitle": "Xóa mô hình", "filters": { - "allLanguages": "Tất cả ngôn ngữ" + "allLanguages": "Tất cả ngôn ngữ", + "streaming": "Lọc các mô hình hỗ trợ phiên âm trực tiếp", + "translation": "Lọc các mô hình hỗ trợ dịch sang tiếng Anh" }, "noModelsMatch": "Không có mô hình nào khớp với bộ lọc này.", "yourModels": "Mô hình đã tải", @@ -224,6 +227,12 @@ "placeholder": "Chọn micrô...", "loading": "Đang tải..." }, + "channel": { + "title": "Kênh đầu vào", + "description": "Chọn kênh đầu vào để ghi âm. Sử dụng tùy chọn này nếu giao diện âm thanh của bạn có nhiều đầu vào.", + "average": "Trung bình tất cả các kênh", + "channel": "Kênh {{n}}" + }, "audioFeedback": { "label": "Phản hồi âm thanh", "description": "Phát âm thanh khi bắt đầu và kết thúc ghi âm" @@ -291,12 +300,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Lớp phủ", + "description": "Chọn lớp phủ khi ghi âm: 'Không có' sẽ ẩn nó, 'Tối giản' hiển thị một chỉ báo nhỏ gọn, 'Trực tiếp' hiển thị bản chép lời theo thời gian thực khi bạn nói (chỉ với các mô hình hỗ trợ truyền trực tiếp — hãy tìm huy hiệu 'Truyền trực tiếp' trong bộ chọn mô hình). Trên Linux, khuyến nghị chọn 'Không có'.", "options": { "none": "Không có", - "minimal": "Minimal", - "live": "Live" + "minimal": "Tối giản", + "live": "Trực tiếp" } }, "position": { @@ -312,9 +321,9 @@ "title": "Phương thức dán", "description": "Chọn cách chèn văn bản. Trực tiếp: mô phỏng gõ phím qua đầu vào hệ thống. Không có: bỏ qua dán, chỉ cập nhật lịch sử/clipboard.", "options": { - "clipboard": "Clipboard ({{modifier}}+V)", - "clipboardCtrlShiftV": "Clipboard (Ctrl+Shift+V)", - "clipboardShiftInsert": "Clipboard (Shift+Insert)", + "clipboard": "Bảng nhớ tạm ({{modifier}}+V)", + "clipboardCtrlShiftV": "Bảng nhớ tạm (Ctrl+Shift+V)", + "clipboardShiftInsert": "Bảng nhớ tạm (Shift+Insert)", "direct": "Trực tiếp", "none": "Không có", "externalScript": "Script bên ngoài" @@ -374,8 +383,8 @@ "duplicate": "\"{{word}}\" đã tồn tại" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Phát hiện hoạt động giọng nói", + "description": "Lọc bỏ khoảng lặng khỏi bản ghi. Các mô hình hỗ trợ truyền trực tiếp dùng đuôi VAD dài hơn; tắt VAD sẽ ghi âm thanh thô." } }, "postProcessing": { @@ -411,7 +420,7 @@ } }, "prompts": { - "title": "Prompt", + "title": "Câu lệnh", "selectedPrompt": { "title": "Prompt đã chọn", "description": "Chọn một mẫu để tinh chỉnh bản ghi hoặc tạo mới. Sử dụng ${output} trong văn bản prompt để tham chiếu bản ghi đã chụp." @@ -533,10 +542,34 @@ "title": "Độ trễ dán (sau)", "description": "Độ trễ (tính bằng mili giây) sau khi gửi phím dán, trước khi khôi phục bảng nhớ tạm trước đó. Tăng nếu nội dung bảng nhớ tạm cũ được dán thay vì bản chép lời." }, + "reliablePaste": { + "title": "Dán đáng tin cậy (Beta)", + "description": "Chỉ khôi phục bảng nhớ tạm sau khi ứng dụng đích thực sự đọc bản chép lời, thay vì sau một độ trễ cố định. Nhằm khắc phục việc nội dung bảng nhớ tạm cũ bị dán khi hệ thống bị tải nặng. Cần một phương thức dán qua bảng nhớ tạm; chỉ dành cho macOS và Windows." + }, "recordingBuffer": { "title": "Bộ đệm ghi âm thêm", "description": "Thời gian thêm (tính bằng mili giây) để tiếp tục ghi âm sau khi nhả phím, để thu âm thanh cuối. 0 = không có bộ đệm thêm." }, + "keyboardDiagnostic": { + "title": "Chẩn đoán bàn phím", + "description": "Kiểm tra xem các sự kiện bàn phím có đến được Handy hay không. Chỉ số lượng sự kiện được ghi lại — không bao giờ ghi lại bạn bấm phím nào.", + "run": "Chạy chẩn đoán 10 giây", + "running": "Đang lắng nghe… hãy bấm phím tắt của bạn vài lần (ví dụ: Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "đang bật", + "disabled": "đang tắt", + "holder": "bị {{name}} (pid {{pid}}) giữ", + "holderUnknown": "không thấy tiến trình nào giữ", + "keyDown": "Nhấn phím", + "keyUp": "Nhả phím", + "flagsChanged": "Phím bổ trợ", + "mouse": "Chuột", + "verdictBlocked": "Secure Input đang chặn các sự kiện phím — phím tắt có chứa phím thường không thể hoạt động cho đến khi vấn đề này được xử lý.", + "verdictSuspicious": "Có sự kiện phím bổ trợ nhưng không có sự kiện phím nào — thứ gì đó đang chặn phím dù Secure Input báo là đang tắt. Vui lòng báo cáo việc này trên GitHub.", + "verdictOk": "Các sự kiện phím đang đến Handy bình thường.", + "verdictNoEvents": "Không ghi nhận sự kiện nào — bạn có bấm phím nào trong lúc kiểm tra không?", + "failed": "Chẩn đoán thất bại: {{error}}" + }, "whatsNewPreview": { "title": "Xem trước điểm mới", "description": "Mở ghi chú phát hành mới nhất được đóng gói mà không đánh dấu là đã xem", @@ -589,8 +622,10 @@ "preparing": "Đang chuẩn bị...", "checkForUpdates": "Kiểm tra cập nhật", "portableUpdateTitle": "Cần cập nhật thủ công", - "portableUpdateMessage": "Bản cài đặt di động không thể cập nhật tự động. Để cập nhật: tải trình cài đặt NSIS mới nhất từ GitHub Releases, cài đặt vào cùng thư mục, sau đó sao chép thư mục Data/ (cài đặt, mô hình, bản ghi) từ phiên bản cũ sang phiên bản mới.", - "portableUpdateButton": "Mở GitHub Releases" + "portableUpdateMessage": "Bản cài đặt di động không thể cập nhật tự động. Hãy tải trình cài đặt phù hợp với hệ thống của bạn bên dưới, chạy nó và cài vào cùng thư mục — thư mục Data/ (cài đặt, mô hình, bản ghi) của bạn được giữ nguyên.", + "portableUpdateButton": "Tải trình cài đặt", + "portableUpdateBrowseMessage": "Bản cài đặt di động không thể cập nhật tự động. Hãy tải phiên bản mới nhất phù hợp với hệ thống của bạn từ GitHub Releases và cài vào cùng thư mục — thư mục Data/ (cài đặt, mô hình, bản ghi) của bạn được giữ nguyên.", + "portableUpdateBrowseButton": "Mở GitHub Releases" }, "common": { "loading": "Đang tải...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Điểm mới trong Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} có thể đang chặn 1 phím tắt", + "blockedWithCulprit_other": "{{name}} có thể đang chặn {{count}} phím tắt", + "blockedNoCulprit_one": "macOS đang tạm thời chặn 1 phím tắt", + "blockedNoCulprit_other": "macOS đang tạm thời chặn {{count}} phím tắt", + "recorderBlockedWithCulprit": "{{name}} có thể đang chặn việc thay đổi phím tắt", + "recorderBlockedNoCulprit": "macOS đang tạm thời chặn việc thay đổi phím tắt", + "learnMore": "Cách khắc phục", + "recorderBlocked": "Hiện không thể ghi phím tắt — Secure Input của macOS đang chặn các sự kiện phím. Hãy xử lý cảnh báo Secure Input trước.", + "dismiss": "Bỏ qua" } } diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index fbfe49bb78..17939d397c 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -6,7 +6,8 @@ "unloadModel": "卸載模型", "model": "模型", "quit": "結束", - "cancel": "取消" + "cancel": "取消", + "secureInputWarning": "⚠ 快捷鍵被 Secure Input 阻擋" }, "sidebar": { "general": "一般", @@ -19,10 +20,10 @@ }, "onboarding": { "subtitle": "首先,請選擇一種模型", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "相容的模型", + "downloadModelsTitle": "可供下載", + "showAllModels": "顯示全部 {{total}} 個模型", + "showFewerModels": "顯示較少模型", "recommended": "推薦", "customModelDescription": "官方不支援", "modelCard": { @@ -96,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "選擇模型失敗" }, "permissions": { "title": "需要權限", @@ -145,7 +146,7 @@ "capabilities": { "languageSelection": "支援多種輸入語言", "singleLanguage": "僅支援此語言", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} 種語言", "languageOnly": "僅 {{language}}", "translation": "可翻譯為英語", "translate": "翻譯為英語", @@ -188,7 +189,7 @@ }, "language": { "title": "語言", - "description": "選擇語音識別的語言。選擇自動將自動判定語言,選擇特定語言可以提高該語言的準確度", + "description": "選擇語音辨識的語言。選擇自動將自動判定語言,選擇特定語言可以提高該語言的準確度", "searchPlaceholder": "搜尋語言...", "noResults": "未找到語言", "auto": "自動" @@ -207,7 +208,9 @@ "deleteActiveConfirm": "{{modelName}} 是您目前使用的模型。刪除後將停止轉錄,直到您選擇新的模型。確定要刪除嗎?", "deleteTitle": "刪除模型", "filters": { - "allLanguages": "所有語言" + "allLanguages": "所有語言", + "streaming": "篩選支援即時轉錄的模型", + "translation": "篩選支援翻譯為英語的模型" }, "noModelsMatch": "沒有符合此篩選條件的模型", "searchPlaceholder": "依名稱搜尋模型…", @@ -224,6 +227,12 @@ "placeholder": "選擇麥克風...", "loading": "載入中..." }, + "channel": { + "title": "輸入聲道", + "description": "選擇要錄音的輸入聲道。如果您的音訊介面有多個輸入,請使用此選項。", + "average": "所有聲道的平均值", + "channel": "聲道 {{n}}" + }, "audioFeedback": { "label": "聲音回饋", "description": "錄製開始和停止時播放聲音" @@ -291,12 +300,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "懸浮窗", + "description": "選擇錄音懸浮窗的樣式:「無」會隱藏懸浮窗,「精簡」顯示小巧的膠囊狀指示,「即時」在您說話時即時顯示轉錄內容(僅限支援串流的模型——請在模型選擇器中尋找「串流」標籤)。在 Linux 上建議選擇「無」", "options": { "none": "無", - "minimal": "Minimal", - "live": "Live" + "minimal": "精簡", + "live": "即時" } }, "position": { @@ -374,8 +383,8 @@ "duplicate": "「{{word}}」已存在" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "語音活動偵測", + "description": "過濾錄音中的靜音。支援串流的模型會使用較長的 VAD 尾段;停用 VAD 則會錄製原始音訊" } }, "postProcessing": { @@ -533,10 +542,34 @@ "title": "貼上延遲(後)", "description": "發送貼上按鍵後、還原先前剪貼簿前的延遲(毫秒)。如果貼上的是舊的剪貼簿內容而非轉錄文字,請增加此值。" }, + "reliablePaste": { + "title": "可靠貼上(Beta)", + "description": "在目標應用程式真正讀取轉錄文字之後才還原剪貼簿,而不是等待固定延遲。用於解決系統負載較高時貼上舊剪貼簿內容的問題。需要使用剪貼簿類的貼上方式;僅支援 macOS 與 Windows。" + }, "recordingBuffer": { "title": "額外錄音緩衝", "description": "放開按鍵後繼續錄音的額外時間(毫秒),以捕捉尾音。0 = 無額外緩衝。" }, + "keyboardDiagnostic": { + "title": "鍵盤診斷", + "description": "檢查鍵盤事件是否能送達 Handy。只會記錄事件數量,絕不記錄您按了哪些按鍵。", + "run": "執行 10 秒診斷", + "running": "正在監聽… 請按幾次您的快捷鍵(例如 Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "已啟用", + "disabled": "已停用", + "holder": "被 {{name}}(pid {{pid}})占用", + "holderUnknown": "找不到占用的程式", + "keyDown": "按鍵按下", + "keyUp": "按鍵放開", + "flagsChanged": "修飾鍵", + "mouse": "滑鼠", + "verdictBlocked": "Secure Input 正在阻擋按鍵事件——在問題解決前,包含一般按鍵的快捷鍵無法運作。", + "verdictSuspicious": "收到了修飾鍵事件,但沒有按鍵事件——即使 Secure Input 回報為已停用,仍有程式在抑制按鍵。請在 GitHub 上回報此問題。", + "verdictOk": "按鍵事件正常送達 Handy。", + "verdictNoEvents": "未擷取到任何事件——測試期間您有按鍵嗎?", + "failed": "診斷失敗:{{error}}" + }, "whatsNewPreview": { "title": "預覽新功能", "description": "開啟最新內建版本資訊,且不標記為已看過", @@ -569,7 +602,7 @@ "title": "致謝", "ggml": { "title": "ggml", - "description": "針對 OpenAI Whisper 自動語音識別模型的高效能推理引擎", + "description": "針對 OpenAI Whisper 自動語音辨識模型的高效能推理引擎", "details": "Handy 使用 ggml 進行快速的本機語音轉文字處理。感謝 Georgi Gerganov 和貢獻者們的傑出工作" } }, @@ -589,8 +622,10 @@ "preparing": "準備中...", "checkForUpdates": "檢查更新", "portableUpdateTitle": "需要手動更新", - "portableUpdateMessage": "可攜式安裝無法自動更新。更新方法:從 GitHub Releases 下載最新的 NSIS 安裝程式,安裝到相同資料夾,然後將舊版本的 Data/ 資料夾(設定、模型、錄音)複製到新版本。", - "portableUpdateButton": "開啟 GitHub Releases" + "portableUpdateMessage": "可攜式安裝無法自動更新。請透過下方按鈕下載適用於你的系統的安裝程式,執行後安裝到相同資料夾,你的 Data/ 資料夾(設定、模型、錄音)會保留在原處。", + "portableUpdateButton": "下載安裝程式", + "portableUpdateBrowseMessage": "可攜式安裝無法自動更新。請從 GitHub Releases 下載適用於你的系統的最新版本,並安裝到相同資料夾,你的 Data/ 資料夾(設定、模型、錄音)會保留在原處。", + "portableUpdateBrowseButton": "開啟 GitHub Releases" }, "common": { "loading": "載入中...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Handy v{{version}} 新功能" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} 可能正在阻擋 1 個快捷鍵", + "blockedWithCulprit_other": "{{name}} 可能正在阻擋 {{count}} 個快捷鍵", + "blockedNoCulprit_one": "macOS 正在暫時阻擋 1 個快捷鍵", + "blockedNoCulprit_other": "macOS 正在暫時阻擋 {{count}} 個快捷鍵", + "recorderBlockedWithCulprit": "{{name}} 可能正在阻擋快捷鍵變更", + "recorderBlockedNoCulprit": "macOS 正在暫時阻擋快捷鍵變更", + "learnMore": "如何解決", + "recorderBlocked": "目前無法錄製快捷鍵——macOS 的 Secure Input 正在阻擋按鍵事件。請先解決 Secure Input 警告。", + "dismiss": "關閉" } } diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index f10129d80e..1d1aa66229 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -6,7 +6,8 @@ "unloadModel": "卸载模型", "model": "模型", "quit": "退出", - "cancel": "取消" + "cancel": "取消", + "secureInputWarning": "⚠ 快捷键被 Secure Input 阻止" }, "sidebar": { "general": "通用", @@ -19,10 +20,10 @@ }, "onboarding": { "subtitle": "请选择一个转录模型以开始使用", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "兼容的模型", + "downloadModelsTitle": "可供下载", + "showAllModels": "显示全部 {{total}} 个模型", + "showFewerModels": "显示较少模型", "recommended": "推荐", "customModelDescription": "非官方支持", "modelCard": { @@ -96,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "选择模型失败" }, "permissions": { "title": "需要权限", @@ -142,7 +143,7 @@ "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "支持多种输入语言", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} 种语言", "translation": "可翻译为英语", "translate": "翻译为英语", "singleLanguage": "仅支持此语言", @@ -164,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} 是您当前使用的模型。删除后将停止转录,直到您选择新的模型。确定要删除吗?", "deleteTitle": "删除模型", "filters": { - "allLanguages": "所有语言" + "allLanguages": "所有语言", + "streaming": "筛选支持实时转录的模型", + "translation": "筛选支持翻译为英语的模型" }, "noModelsMatch": "没有符合此筛选条件的模型。", "yourModels": "已下载的模型", @@ -224,6 +227,12 @@ "placeholder": "选择麦克风...", "loading": "加载中..." }, + "channel": { + "title": "输入通道", + "description": "选择要录制的输入通道。如果您的音频接口有多个输入,请使用此选项。", + "average": "所有通道的平均值", + "channel": "通道 {{n}}" + }, "audioFeedback": { "label": "音频反馈", "description": "录制开始和停止时播放声音" @@ -291,12 +300,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "悬浮窗", + "description": "选择录音悬浮窗的样式:「无」会隐藏悬浮窗,「精简」显示小巧的胶囊状指示,「实时」在您说话时实时显示转录内容(仅限支持流式的模型——请在模型选择器中查找「流式」标签)。在 Linux 上建议选择「无」。", "options": { "none": "无", - "minimal": "Minimal", - "live": "Live" + "minimal": "精简", + "live": "实时" } }, "position": { @@ -341,10 +350,10 @@ "description": "在文本插入后自动发送所选的按键组合。macOS 上使用 Cmd+Enter,Windows/Linux 上使用 Super+Enter。", "options": { "off": "关闭", - "enter": "Enter", - "cmdEnter": "Cmd+Enter", - "superEnter": "Super+Enter", - "ctrlEnter": "Ctrl+Enter" + "enter": "回车", + "cmdEnter": "Cmd+回车", + "superEnter": "Super+回车", + "ctrlEnter": "Ctrl+回车" } }, "translateToEnglish": { @@ -374,8 +383,8 @@ "duplicate": "\"{{word}}\" 已存在" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "语音活动检测", + "description": "过滤录音中的静音。支持流式的模型会使用更长的 VAD 尾段;停用 VAD 则会录制原始音频。" } }, "postProcessing": { @@ -533,10 +542,34 @@ "title": "粘贴延迟(后)", "description": "发送粘贴按键后、恢复先前剪贴板前的延迟(毫秒)。如果粘贴的是旧的剪贴板内容而非转录文本,请增加此值。" }, + "reliablePaste": { + "title": "可靠粘贴(Beta)", + "description": "在目标应用程序真正读取转录文本之后再恢复剪贴板,而不是等待固定延迟。用于解决系统负载较高时粘贴旧剪贴板内容的问题。需要使用剪贴板类的粘贴方式;仅支持 macOS 和 Windows。" + }, "recordingBuffer": { "title": "额外录音缓冲", "description": "放开按键后继续录音的额外时间(毫秒),以捕捉尾音。0 = 无额外缓冲。" }, + "keyboardDiagnostic": { + "title": "键盘诊断", + "description": "检查键盘事件是否能到达 Handy。仅记录事件数量,绝不记录您按了哪些键。", + "run": "运行 10 秒诊断", + "running": "正在监听… 请按几次您的快捷键(例如 Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "已启用", + "disabled": "已禁用", + "holder": "被 {{name}}(pid {{pid}})占用", + "holderUnknown": "未发现占用方", + "keyDown": "按键按下", + "keyUp": "按键释放", + "flagsChanged": "修饰键", + "mouse": "鼠标", + "verdictBlocked": "Secure Input 正在阻止按键事件——在问题解决前,包含普通按键的快捷键无法工作。", + "verdictSuspicious": "收到了修饰键事件,但没有按键事件——尽管 Secure Input 报告为已禁用,仍有程序在抑制按键。请在 GitHub 上反馈此问题。", + "verdictOk": "按键事件正常到达 Handy。", + "verdictNoEvents": "未捕获到任何事件——测试期间您按过键吗?", + "failed": "诊断失败:{{error}}" + }, "whatsNewPreview": { "title": "预览新功能", "description": "打开最新内置发行说明,且不标记为已查看", @@ -589,8 +622,10 @@ "preparing": "准备中...", "checkForUpdates": "检查更新", "portableUpdateTitle": "需要手动更新", - "portableUpdateMessage": "便携版无法自动更新。更新方法:从 GitHub Releases 下载最新的 NSIS 安装程序,安装到同一文件夹,然后将旧版本的 Data/ 文件夹(设置、模型、录音)复制到新版本。", - "portableUpdateButton": "打开 GitHub Releases" + "portableUpdateMessage": "便携版无法自动更新。请通过下方按钮下载适用于你的系统的安装程序,运行后安装到同一文件夹,你的 Data/ 文件夹(设置、模型、录音)会保留在原处。", + "portableUpdateButton": "下载安装程序", + "portableUpdateBrowseMessage": "便携版无法自动更新。请从 GitHub Releases 下载适用于你的系统的最新版本,并安装到同一文件夹,你的 Data/ 文件夹(设置、模型、录音)会保留在原处。", + "portableUpdateBrowseButton": "打开 GitHub Releases" }, "common": { "loading": "加载中...", @@ -642,5 +677,16 @@ }, "whatsNew": { "title": "Handy v{{version}} 新功能" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} 可能正在阻止 1 个快捷键", + "blockedWithCulprit_other": "{{name}} 可能正在阻止 {{count}} 个快捷键", + "blockedNoCulprit_one": "macOS 正在暂时阻止 1 个快捷键", + "blockedNoCulprit_other": "macOS 正在暂时阻止 {{count}} 个快捷键", + "recorderBlockedWithCulprit": "{{name}} 可能正在阻止快捷键更改", + "recorderBlockedNoCulprit": "macOS 正在暂时阻止快捷键更改", + "learnMore": "如何解决", + "recorderBlocked": "当前无法录制快捷键——macOS 的 Secure Input 正在阻止按键事件。请先解决 Secure Input 警告。", + "dismiss": "关闭" } } diff --git a/src/lib/compat.ts b/src/lib/compat.ts new file mode 100644 index 0000000000..b88c2137f7 --- /dev/null +++ b/src/lib/compat.ts @@ -0,0 +1,16 @@ +const objectConstructor = Object as typeof Object & { + hasOwn?: (target: object, key: PropertyKey) => boolean; +}; + +/** Install compatibility shims required by frontend dependencies. */ +export const installCompatShims = (): void => { + // react-markdown uses Object.hasOwn, which is unavailable before Safari 15.4. + if (typeof objectConstructor.hasOwn !== "function") { + Object.defineProperty(Object, "hasOwn", { + value: (target: object, key: PropertyKey): boolean => + Object.prototype.hasOwnProperty.call(target, key), + configurable: true, + writable: true, + }); + } +}; diff --git a/src/main.tsx b/src/main.tsx index 3cd2ed60a5..55c7d5a9e4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,12 +2,15 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { platform } from "@tauri-apps/plugin-os"; import App from "./App"; +import { installCompatShims } from "./lib/compat"; import { applyTheme, getStoredTheme, syncThemeFromSettings, } from "./lib/utils/theme"; +installCompatShims(); + // Set platform before render so CSS can scope per-platform (e.g. scrollbar styles) document.documentElement.dataset.platform = platform(); diff --git a/src/overlay/RecordingOverlay.tsx b/src/overlay/RecordingOverlay.tsx index 1fcfc0ff0d..c32f617dd7 100644 --- a/src/overlay/RecordingOverlay.tsx +++ b/src/overlay/RecordingOverlay.tsx @@ -139,6 +139,8 @@ const RecordingOverlay: React.FC = () => { setOverflowing(false); }, [session]); + if (!isVisible) return null; + // Re-pin when the user is within ~a line of the bottom; unpin otherwise. const handleStreamScroll = () => { const el = capRef.current; diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index f46cf646af..20abfe3e7f 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -99,6 +99,14 @@ const settingUpdaters: { ? "default" : (value as string), ), + selected_channel: async (value) => { + const result = await commands.setSelectedChannel( + (value as number | null | undefined) ?? null, + ); + if (result.status === "error") { + throw new Error(result.error); + } + }, clamshell_microphone: (value) => commands.setClamshellMicrophone( (value as string) === "Default" ? "default" : (value as string), @@ -125,6 +133,8 @@ const settingUpdaters: { commands.changePasteDelayMsSetting(value as number), paste_delay_after_ms: (value) => commands.changePasteDelayAfterMsSetting(value as number), + reliable_paste: (value) => + commands.changeReliablePasteSetting(value as boolean), paste_method: (value) => commands.changePasteMethodSetting(value as string), typing_tool: (value) => commands.changeTypingToolSetting(value as string), external_script_path: (value) => diff --git a/src/styles/theme.css b/src/styles/theme.css index 2494b5b024..d562c2817f 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -14,6 +14,14 @@ --dark-color-logo-primary: #f28cbb; --dark-color-logo-stroke: #fad1ed; + /* Semantic status colors. First used by SecureInputWarning; intended to + replace the ad-hoc yellow-500/red-500 usages (e.g. ui/Alert.tsx) over + time so status styling is themeable from one place. */ + --light-color-warning: #d97706; + --light-color-error: #dc2626; + --dark-color-warning: #fbbf24; + --dark-color-error: #f87171; + /* Tokens that do not change between light and dark */ --color-background-ui: #da5893; --color-text-stroke: #f6f6f6; @@ -25,6 +33,8 @@ --color-background: var(--light-color-background); --color-logo-primary: var(--light-color-logo-primary); --color-logo-stroke: var(--light-color-logo-stroke); + --color-warning: var(--light-color-warning); + --color-error: var(--light-color-error); } @media (prefers-color-scheme: dark) { @@ -33,5 +43,7 @@ --color-background: var(--dark-color-background); --color-logo-primary: var(--dark-color-logo-primary); --color-logo-stroke: var(--dark-color-logo-stroke); + --color-warning: var(--dark-color-warning); + --color-error: var(--dark-color-error); } }