diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 943d6eb..6a3f852 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,13 +16,6 @@ jobs: with: workspaces: "./src-tauri -> target" - - name: Use mock TranscriptionManager (CI only) - working-directory: src-tauri - run: | - # Swap to mock adapter - avoids compiling whisper/Vulkan - cp src/managers/transcription_mock.rs src/managers/transcription.rs - sed -i '/^transcribe-rs/d' Cargo.toml - - name: Run Rust tests working-directory: src-tauri run: cargo test diff --git a/CLAUDE.md b/CLAUDE.md index ec0cdec..2afd0f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,7 @@ Parrot is a cross-platform desktop text-to-speech (TTS) app built with Tauri 2.x ### Backend Structure (`src-tauri/src/`) **Core entry points:** + - `lib.rs` — Tauri setup, manager initialization, plugin wiring, command export via tauri-specta - `main.rs` — Binary entry: CLI arg parsing, Linux GPU workaround, delegates to `lib.rs` - `settings.rs` — `AppSettings` struct and persistence via `tauri-plugin-store` (`settings_store.json`) @@ -41,42 +42,50 @@ Parrot is a cross-platform desktop text-to-speech (TTS) app built with Tauri 2.x - `signal_handle.rs` — Unix signal handlers (SIGUSR1/SIGUSR2) and shared `send_transcription_input()` helper **Action pipeline:** + - `action_coordinator.rs` — Serializes shortcut lifecycle events through a single background thread; prevents concurrent TTS requests; debounces repeated keys (30ms) - `actions.rs` — Action type definitions: `speak`, `cancel`, `play_pause` - `audio_feedback.rs` — Plays start/stop audio cues via rodio - `input.rs` — Enigo initialization for keyboard/mouse control (macOS requires accessibility permissions) **Window management:** + - `overlay.rs` — Speaking overlay window lifecycle management - `tray.rs` / `tray_i18n.rs` — System tray icon, context menu, and i18n-aware menu labels **Utilities:** + - `shortcut.rs` — Global shortcut initialization and coordination - `utils.rs` — Shared helpers (tray refresh, cancellation token management) **Managers** (`managers/`): + - `tts.rs` — Core TTS engine: Kokoro model lifecycle, up to 2 parallel synthesis workers, crossfade between audio chunks (240 samples @ 24 kHz), audio playback via rodio/cpal, pause/resume, configurable model-unload timeout - `model.rs` — Model catalog (Kokoro-82M), multi-component downloads with progress events, cancellation, extraction, and deletion - `history.rs` — SQLite database (rusqlite) for TTS history: WAV file storage, configurable entry limit and retention policy **Commands** (`commands/`): + - `mod.rs` — General: `get_app_settings`, `cancel_operation`, `toggle_tts_pause`, `preload_tts_model`, `get_model_status`, shortcut init/suspend/resume, permission helpers, `is_laptop` - `audio.rs` — Audio devices: `get_available_output_devices`, `set_selected_output_device`, `play_test_sound`, `check_custom_sounds` - `models.rs` — Models: `get_available_models`, `get_kokoro_voices`, `download_model`, `delete_model`, `set_active_model`, `cancel_download` - `history.rs` — History: `get_history_entries`, `toggle_history_entry_saved`, `delete_history_entry`, `update_history_limit`, `update_history_retention_period` **Audio Toolkit** (`audio_toolkit/`): + - `audio/device.rs` — Output device enumeration via cpal - `audio/resampler.rs` — Audio resampling (rubato) - `audio/utils.rs` — WAV file writing and audio utilities - `constants.rs` — Shared audio constants **Shortcut system** (`shortcut/`): + - Dual-backend architecture: **HandyKeys** (default on macOS) and **Tauri** via `tauri-plugin-global-shortcut` (default on Windows/Linux) - Runtime switching via the `keyboard_implementation` setting; HandyKeys auto-falls back to Tauri with persistence on failure - `handler.rs` — Routes shortcut events through ActionCoordinator **Helpers** (`helpers/`): + - `clamshell.rs` — `is_laptop()`: detects laptop (clamshell) vs. desktop ### Frontend Structure (`src/`) @@ -108,32 +117,34 @@ Parrot is a cross-platform desktop text-to-speech (TTS) app built with Tauri 2.x All user-facing strings must use i18next. ESLint enforces this — hardcoded strings in JSX will fail linting. **Adding new text:** + 1. Add the key to `src/i18n/locales/en/translation.json` 2. Use in component: `const { t } = useTranslation(); t('key.path')` **Supported languages (17):** -| Code | Language | Notes | -| ---- | -------- | ----- | -| `en` | English | Source language | -| `zh` | Simplified Chinese | | -| `zh-TW` | Traditional Chinese | | -| `es` | Spanish | | -| `fr` | French | | -| `de` | German | | -| `ja` | Japanese | | -| `ko` | Korean | | -| `vi` | Vietnamese | | -| `pl` | Polish | | -| `it` | Italian | | -| `ru` | Russian | | -| `uk` | Ukrainian | | -| `pt` | Portuguese | | -| `cs` | Czech | | -| `tr` | Turkish | | -| `ar` | Arabic | RTL | +| Code | Language | Notes | +| ------- | ------------------- | --------------- | +| `en` | English | Source language | +| `zh` | Simplified Chinese | | +| `zh-TW` | Traditional Chinese | | +| `es` | Spanish | | +| `fr` | French | | +| `de` | German | | +| `ja` | Japanese | | +| `ko` | Korean | | +| `vi` | Vietnamese | | +| `pl` | Polish | | +| `it` | Italian | | +| `ru` | Russian | | +| `uk` | Ukrainian | | +| `pt` | Portuguese | | +| `cs` | Czech | | +| `tr` | Turkish | | +| `ar` | Arabic | RTL | **Adding a new language:** + 1. Create `src/i18n/locales/{code}/translation.json` 2. Add metadata to `src/i18n/languages.ts` 3. For RTL languages, set `direction: "rtl"` in the metadata entry @@ -153,11 +164,13 @@ src/i18n/ ## Code Style **Rust:** + - Run `cargo fmt` and `cargo clippy` before committing - Handle errors explicitly — avoid `unwrap()` in production code paths - Add doc comments for public APIs **TypeScript/React:** + - Strict TypeScript — avoid `any` - Functional components with hooks - Tailwind CSS for styling @@ -179,6 +192,7 @@ Use conventional commits: Parrot supports CLI flags for integration with scripts, window managers, and autostart configurations. **Implementation files:** + - `src-tauri/src/cli.rs` — Argument definitions (clap derive) - `src-tauri/src/main.rs` — Argument parsing before Tauri initialization - `src-tauri/src/lib.rs` — CLI overrides applied in the setup closure and single-instance callback @@ -186,15 +200,16 @@ Parrot supports CLI flags for integration with scripts, window managers, and aut **Available flags:** -| Flag | Description | -| ---- | ----------- | +| Flag | Description | +| ------------------------ | ---------------------------------------------------------------------------- | | `--toggle-transcription` | Trigger TTS speak on a running instance (via `tauri_plugin_single_instance`) | -| `--cancel` | Cancel the current TTS operation on a running instance | -| `--start-hidden` | Launch without showing the main window (tray icon still visible) | -| `--no-tray` | Launch without the system tray icon (closing window quits the app) | -| `--debug` | Enable debug mode with verbose (Trace) logging | +| `--cancel` | Cancel the current TTS operation on a running instance | +| `--start-hidden` | Launch without showing the main window (tray icon still visible) | +| `--no-tray` | Launch without the system tray icon (closing window quits the app) | +| `--debug` | Enable debug mode with verbose (Trace) logging | **Key design decisions:** + - CLI flags are runtime-only overrides — they do **not** modify persisted settings - Remote flags (`--toggle-transcription`, `--cancel`) work by launching a second instance that forwards its args to the running instance via `tauri_plugin_single_instance`, then exits immediately - `CliArgs` is stored in Tauri managed state (`.manage()`) so it's accessible in `on_window_event` and other handlers diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6615c3d..f516b3c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -122,6 +122,7 @@ We use GitHub Discussions for feature requests rather than issues. This keeps is ### Before Suggesting a Feature 1. **Search existing discussions** at [github.com/rishiskhare/parrot/discussions](https://github.com/rishiskhare/parrot/discussions) + ### Submitting a Feature Request 1. Go to [Discussions](https://github.com/rishiskhare/parrot/discussions) diff --git a/CONTRIBUTING_TRANSLATIONS.md b/CONTRIBUTING_TRANSLATIONS.md index cd61982..574d080 100644 --- a/CONTRIBUTING_TRANSLATIONS.md +++ b/CONTRIBUTING_TRANSLATIONS.md @@ -151,25 +151,25 @@ Some languages have complex plural rules. For now, use a general form that works ## Currently Supported Languages -| Language | Code | Status | -| ------------------- | ------ | ----------------- | -| English | `en` | Complete (source) | -| Arabic | `ar` | Complete | -| Chinese (Simplified)| `zh` | Complete | -| Chinese (Traditional)| `zh-TW` | Complete | -| Czech | `cs` | Complete | -| French | `fr` | Complete | -| German | `de` | Complete | -| Italian | `it` | Complete | -| Japanese | `ja` | Complete | -| Korean | `ko` | Complete | -| Polish | `pl` | Complete | -| Portuguese | `pt` | Complete | -| Russian | `ru` | Complete | -| Spanish | `es` | Complete | -| Turkish | `tr` | Complete | -| Ukrainian | `uk` | Complete | -| Vietnamese | `vi` | Complete | +| Language | Code | Status | +| --------------------- | ------- | ----------------- | +| English | `en` | Complete (source) | +| Arabic | `ar` | Complete | +| Chinese (Simplified) | `zh` | Complete | +| Chinese (Traditional) | `zh-TW` | Complete | +| Czech | `cs` | Complete | +| French | `fr` | Complete | +| German | `de` | Complete | +| Italian | `it` | Complete | +| Japanese | `ja` | Complete | +| Korean | `ko` | Complete | +| Polish | `pl` | Complete | +| Portuguese | `pt` | Complete | +| Russian | `ru` | Complete | +| Spanish | `es` | Complete | +| Turkish | `tr` | Complete | +| Ukrainian | `uk` | Complete | +| Vietnamese | `vi` | Complete | --- diff --git a/README.md b/README.md index 93796db..fe76fec 100644 --- a/README.md +++ b/README.md @@ -2,20 +2,21 @@ Parrot - # Parrot: AI Text-to-Speech +# Parrot: AI Text-to-Speech - **A free, offline, private AI text-to-speech for your desktop** +**A free, offline, private AI text-to-speech for your desktop** - Highlight text in any app, press a shortcut
- Hear it read aloud *instantly, privately, on your device* +Highlight text in any app, press a shortcut
+Hear it read aloud _instantly, privately, on your device_ - Supports **9 languages:**
- English (US & UK) · Spanish · French · Hindi · Italian · Japanese · Portuguese (Brazilian) · Chinese (Mandarin) +Supports **9 languages:**
+English (US & UK) · Spanish · French · Hindi · Italian · Japanese · Portuguese (Brazilian) · Chinese (Mandarin) + +![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey) +![License](https://img.shields.io/badge/license-MIT-red) +![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/rishiskhare/parrot/total) +![Version](https://img.shields.io/badge/version-26.2.4-blue) - ![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey) - ![License](https://img.shields.io/badge/license-MIT-red) - ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/rishiskhare/parrot/total) - ![Version](https://img.shields.io/badge/version-26.2.4-blue) --- @@ -58,42 +59,43 @@ Parrot ships with **Kokoro-82M**, a compact neural TTS model that delivers natur Kokoro supports **54 voices** across 9 languages. The voice is selected automatically based on your language setting, or choose one manually in **Settings → General**. ### Voices -| Language | Female Voices | Male Voices | -|----------|---------------|-------------| -| English (US) | Alloy, Aoede, Bella, Heart, Jessica, Kore, Nicole, Nova, River, Sarah, Sky | Adam, Echo, Eric, Fenrir, Liam, Michael, Onyx, Puck, Santa | -| English (UK) | Alice, Emma, Isabella, Lily | Daniel, Fable, George, Lewis | -| Spanish | Dora | Alex, Santa | -| French | Siwis | - | -| Hindi | Alpha, Beta | Omega, Psi | -| Italian | Sara | Nicola | -| Japanese | Alpha, Gongitsune, Nezumi, Tebukuro | Kumo | -| Portuguese (Brazilian) | Dora | Alex, Santa | -| Chinese (Mandarin) | Xiaobei, Xiaoni, Xiaoxiao, Xiaoyi | Yunjian, Yunxi, Yunxia, Yunyang | + +| Language | Female Voices | Male Voices | +| ---------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------- | +| English (US) | Alloy, Aoede, Bella, Heart, Jessica, Kore, Nicole, Nova, River, Sarah, Sky | Adam, Echo, Eric, Fenrir, Liam, Michael, Onyx, Puck, Santa | +| English (UK) | Alice, Emma, Isabella, Lily | Daniel, Fable, George, Lewis | +| Spanish | Dora | Alex, Santa | +| French | Siwis | - | +| Hindi | Alpha, Beta | Omega, Psi | +| Italian | Sara | Nicola | +| Japanese | Alpha, Gongitsune, Nezumi, Tebukuro | Kumo | +| Portuguese (Brazilian) | Dora | Alex, Santa | +| Chinese (Mandarin) | Xiaobei, Xiaoni, Xiaoxiao, Xiaoyi | Yunjian, Yunxi, Yunxia, Yunyang | ## Keyboard Shortcuts All shortcuts are fully customizable in **Settings → General**. -| Action | macOS | Windows / Linux | -|--------|-------|-----------------| -| Speak selected text | `Option+Space` | `Ctrl+Space` | -| Pause / resume playback | `Option+P` | `Alt+P` | -| Open settings | `Cmd+,` | `Ctrl+,` | -| Open debug panel | `Cmd+Shift+D` | `Ctrl+Shift+D` | +| Action | macOS | Windows / Linux | +| ----------------------- | -------------- | --------------- | +| Speak selected text | `Option+Space` | `Ctrl+Space` | +| Pause / resume playback | `Option+P` | `Alt+P` | +| Open settings | `Cmd+,` | `Ctrl+,` | +| Open debug panel | `Cmd+Shift+D` | `Ctrl+Shift+D` | The pause/resume shortcut is only active while Parrot is playing. It can be customized or disabled in **Settings → General**. ## Settings Overview -| Category | Options | -|----------|---------| -| **General** | Shortcuts, TTS language, voice, output device, audio feedback | -| **Models** | Download, switch, and delete TTS models | -| **Advanced → App** | Start hidden, autostart, tray icon, overlay position, model unload timeout | -| **Advanced → Speech** | Worker threads, playback speed, fast first response | -| **Advanced → History** | Entry limit, auto-delete period | -| **History** | Browse, replay, copy, and delete past utterances | -| **Debug** | Log level, keyboard implementation, diagnostics | +| Category | Options | +| ---------------------- | -------------------------------------------------------------------------- | +| **General** | Shortcuts, TTS language, voice, output device, audio feedback | +| **Models** | Download, switch, and delete TTS models | +| **Advanced → App** | Start hidden, autostart, tray icon, overlay position, model unload timeout | +| **Advanced → Speech** | Worker threads, playback speed, fast first response | +| **Advanced → History** | Entry limit, auto-delete period | +| **History** | Browse, replay, copy, and delete past utterances | +| **Debug** | Log level, keyboard implementation, diagnostics | ## Command-Line Interface @@ -103,19 +105,21 @@ Parrot supports CLI flags for scripting and window manager integration. Remote c parrot [FLAGS] ``` -| Flag | Description | -|------|-------------| -| `--toggle-transcription` | Toggle TTS on/off in the running instance | -| `--start-hidden` | Launch without showing the main window | -| `--no-tray` | Launch without a tray icon (closing the window quits) | -| `--debug` | Enable verbose trace logging | +| Flag | Description | +| ------------------------ | ----------------------------------------------------- | +| `--toggle-transcription` | Toggle TTS on/off in the running instance | +| `--start-hidden` | Launch without showing the main window | +| `--no-tray` | Launch without a tray icon (closing the window quits) | +| `--debug` | Enable verbose trace logging | **Example: bind to a window manager shortcut:** + ```sh parrot --toggle-transcription ``` > **macOS:** When using the app bundle, invoke the binary directly: +> > ```sh > /Applications/Parrot.app/Contents/MacOS/Parrot --toggle-transcription > ``` @@ -126,11 +130,11 @@ parrot --toggle-transcription For reliable text pasting on Linux, install the appropriate tool for your display server: -| Display Server | Recommended | Install | -|----------------|-------------|---------| -| X11 | `xdotool` | `sudo apt install xdotool` | -| Wayland | `wtype` | `sudo apt install wtype` | -| Both | `dotool` | `sudo apt install dotool` | +| Display Server | Recommended | Install | +| -------------- | ----------- | -------------------------- | +| X11 | `xdotool` | `sudo apt install xdotool` | +| Wayland | `wtype` | `sudo apt install wtype` | +| Both | `dotool` | `sudo apt install dotool` | `dotool` requires adding your user to the `input` group: `sudo usermod -aG input $USER` (log out and back in after). @@ -139,19 +143,23 @@ For reliable text pasting on Linux, install the appropriate tool for your displa Parrot's built-in global shortcut capture has limited support on Wayland. The recommended approach is to configure your desktop environment or window manager to invoke the CLI flag instead. **GNOME:** + 1. Open **Settings > Keyboard > Keyboard Shortcuts > Custom Shortcuts** 2. Add a new shortcut with the command `parrot --toggle-transcription` **KDE Plasma:** + 1. Open **System Settings > Shortcuts > Custom Shortcuts** 2. Create a new **Command/URL** shortcut with `parrot --toggle-transcription` **Sway / i3:** + ```ini bindsym $mod+o exec parrot --toggle-transcription ``` **Hyprland:** + ```ini bind = $mainMod, O, exec, parrot --toggle-transcription ``` @@ -160,8 +168,8 @@ bind = $mainMod, O, exec, parrot --toggle-transcription You can also send signals directly to the Parrot process, useful for hotkey daemons that manage their own keybindings: -| Signal | Action | -|--------|--------| +| Signal | Action | +| --------- | ---------- | | `SIGUSR1` | Toggle TTS | | `SIGUSR2` | Toggle TTS | @@ -175,11 +183,11 @@ pkill -USR2 -n parrot # toggle TTS - If the app fails to start, try setting `WEBKIT_DISABLE_DMABUF_RENDERER=1`. - If you see `error while loading shared libraries: libgtk-layer-shell.so.0`, install the runtime package: - | Distro | Package | Command | - |--------|---------|---------| + | Distro | Package | Command | + | ------------- | --------------------- | -------------------------------------- | | Ubuntu/Debian | `libgtk-layer-shell0` | `sudo apt install libgtk-layer-shell0` | - | Fedora/RHEL | `gtk-layer-shell` | `sudo dnf install gtk-layer-shell` | - | Arch | `gtk-layer-shell` | `sudo pacman -S gtk-layer-shell` | + | Fedora/RHEL | `gtk-layer-shell` | `sudo dnf install gtk-layer-shell` | + | Arch | `gtk-layer-shell` | `sudo pacman -S gtk-layer-shell` | ## Building from Source @@ -201,6 +209,7 @@ bun run tauri build ``` > On macOS, if you hit a CMake error: +> > ```sh > CMAKE_POLICY_VERSION_MINIMUM=3.5 bun run tauri dev > ``` diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e36c647..895e4ba 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2127,6 +2127,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -4122,6 +4131,7 @@ dependencies = [ "hound", "log", "once_cell", + "pulldown-cmark", "rdev 0.5.0-2", "reqwest", "rodio", @@ -4568,6 +4578,25 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags 2.10.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pxfm" version = "0.1.25" @@ -7188,6 +7217,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.20" @@ -7200,6 +7235,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 74220b7..4a735ff 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -68,6 +68,7 @@ tts-rs = { version = "2026.2.3", features = ["kokoro"] } handy-keys = "0.2.0" ferrous-opencc = "0.2.3" clap = { version = "4", features = ["derive"] } +pulldown-cmark = "0.13" specta = "=2.0.0-rc.22" specta-typescript = "0.0.9" tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index ad48257..cf39ebc 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -80,7 +80,6 @@ fn generate_tray_translations() { out.push_str(" m\n});\n"); fs::write(Path::new(&out_dir).join("tray_translations.rs"), out).unwrap(); - } fn camel_to_snake(s: &str) -> String { @@ -102,4 +101,3 @@ fn escape_string(s: &str) -> String { .replace('\r', "\\r") .replace('\t', "\\t") } - diff --git a/src-tauri/src/action_coordinator.rs b/src-tauri/src/action_coordinator.rs index aea7196..0002203 100644 --- a/src-tauri/src/action_coordinator.rs +++ b/src-tauri/src/action_coordinator.rs @@ -46,8 +46,7 @@ impl ActionCoordinator { let now = Instant::now(); let should_debounce = !is_speak_binding(&binding_id); if should_debounce - && last_press - .is_some_and(|t| now.duration_since(t) < DEBOUNCE) + && last_press.is_some_and(|t| now.duration_since(t) < DEBOUNCE) { debug!("Debounced press for '{binding_id}'"); continue; @@ -94,12 +93,7 @@ impl ActionCoordinator { /// Send a keyboard/signal input event for a speak binding. /// For signal-based toggles, send a press event followed by a release event. - pub fn send_input( - &self, - binding_id: &str, - hotkey_string: &str, - is_pressed: bool, - ) { + pub fn send_input(&self, binding_id: &str, hotkey_string: &str, is_pressed: bool) { if self .tx .send(Command::Input { diff --git a/src-tauri/src/audio_toolkit/audio/utils.rs b/src-tauri/src/audio_toolkit/audio/utils.rs index 52b5a4f..da06a6f 100644 --- a/src-tauri/src/audio_toolkit/audio/utils.rs +++ b/src-tauri/src/audio_toolkit/audio/utils.rs @@ -4,7 +4,11 @@ use log::debug; use std::path::Path; /// Save audio samples as a WAV file -pub fn save_wav_file>(file_path: P, samples: &[f32], sample_rate: u32) -> Result<()> { +pub fn save_wav_file>( + file_path: P, + samples: &[f32], + sample_rate: u32, +) -> Result<()> { let spec = WavSpec { channels: 1, sample_rate, diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index 4d2a5aa..0b63951 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -62,4 +62,3 @@ pub fn send_copy_ctrl_c(enigo: &mut Enigo) -> Result<(), String> { Ok(()) } - diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 691be23..03ae132 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod action_coordinator; mod actions; mod audio_feedback; pub mod audio_toolkit; @@ -10,7 +11,7 @@ mod overlay; mod settings; mod shortcut; mod signal_handle; -mod action_coordinator; +mod text_normalization; mod tray; mod tray_i18n; mod utils; @@ -18,6 +19,7 @@ mod utils; pub use cli::CliArgs; use tauri_specta::{collect_commands, Builder}; +pub use action_coordinator::ActionCoordinator; use env_filter::Builder as EnvFilterBuilder; use managers::history::HistoryManager; use managers::model::ModelManager; @@ -29,7 +31,6 @@ use signal_hook::iterator::Signals; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Arc; use tauri::image::Image; -pub use action_coordinator::ActionCoordinator; use tauri::tray::TrayIconBuilder; use tauri::{AppHandle, Emitter, Listener, Manager}; @@ -47,7 +48,9 @@ use crate::settings::get_settings; /// Best-effort: if the bundled files are missing (e.g. during `cargo test` /// or a dev build without resources) we return `None` and tts-rs falls back /// to system-installed `espeak-ng`. -fn resolve_bundled_espeak_ng(app_handle: &AppHandle) -> (Option, Option) { +fn resolve_bundled_espeak_ng( + app_handle: &AppHandle, +) -> (Option, Option) { let resolver = app_handle.path(); // --- espeak-ng binary --------------------------------------------------- @@ -75,7 +78,10 @@ fn resolve_bundled_espeak_ng(app_handle: &AppHandle) -> (Option, // Languages this model supports - pub is_custom: bool, // Whether this is a user-provided custom model + pub is_custom: bool, // Whether this is a user-provided custom model /// For models that consist of multiple individual files rather than a single /// archive. When non-empty, `url` is ignored and each component is downloaded /// separately into the model directory. @@ -967,4 +967,3 @@ impl ModelManager { Ok(()) } } - diff --git a/src-tauri/src/managers/tts.rs b/src-tauri/src/managers/tts.rs index 5291ccc..5718682 100644 --- a/src-tauri/src/managers/tts.rs +++ b/src-tauri/src/managers/tts.rs @@ -3,9 +3,9 @@ use cpal::traits::{DeviceTrait, HostTrait}; use log::{debug, error, info, warn}; use rodio::buffer::SamplesBuffer; use rodio::{DeviceSinkBuilder, MixerDeviceSink, Player}; -use std::num::NonZero; use serde::Serialize; use std::collections::BTreeMap; +use std::num::NonZero; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, AtomicUsize, Ordering}; use std::sync::{mpsc, Arc, Condvar, Mutex, TryLockError}; @@ -22,6 +22,7 @@ use crate::audio_feedback; use crate::managers::history::HistoryManager; use crate::managers::model::ModelManager; use crate::settings::{get_settings, ModelUnloadTimeout}; +use crate::text_normalization::normalize_text_for_tts; use crate::utils::{hide_speaking_overlay, show_processing_overlay, show_speaking_overlay}; /// The model ID managed by this TTS manager. @@ -292,7 +293,9 @@ impl TTSManager { }; info!( "Kokoro tuning: target_workers={}, threads_per_worker={} (manual={})", - tuning.target_workers, tuning.threads_per_worker, tts_settings.tts_workers > 0 + tuning.target_workers, + tuning.threads_per_worker, + tts_settings.tts_workers > 0 ); let mut loaded_workers = 0usize; @@ -305,10 +308,8 @@ impl TTSManager { continue; } - let mut kokoro = KokoroEngine::with_espeak( - espeak_ng_path.clone(), - espeak_ng_data_path.clone(), - ); + let mut kokoro = + KokoroEngine::with_espeak(espeak_ng_path.clone(), espeak_ng_data_path.clone()); match kokoro.load_model_with_params( &model_dir, KokoroModelParams { @@ -542,8 +543,15 @@ impl TTSManager { let tts_settings = get_settings(&app_handle); let tts_speed = tts_settings.tts_speed; let shorten_first_chunk = tts_settings.tts_shorten_first_chunk; + let normalized_text = normalize_text_for_tts(&text); - let chunks = split_text_for_playback(&text, shorten_first_chunk); + debug!( + "Prepared {} input chars into {} chars for TTS", + text.len(), + normalized_text.len() + ); + + let chunks = split_text_for_playback(&normalized_text, shorten_first_chunk); if chunks.is_empty() { let message = "No readable text found for TTS."; error!("{}", message); @@ -708,7 +716,7 @@ impl TTSManager { voice: voice_for_worker.clone(), style_index: Some(style_index_for_worker), speed: speed_for_worker, - }), + }), ) .map_err(|e| format!("TTS synthesis failed: {}", e)); @@ -861,10 +869,7 @@ impl TTSManager { lifecycle_state.store(TtsLifecycleState::Speaking as u8, Ordering::SeqCst); show_speaking_overlay(&app_handle, chunks.first().cloned()); crate::shortcut::register_play_pause_shortcut(&app_handle); - audio_feedback::play_sound( - &app_handle, - audio_feedback::SoundType::Start, - ); + audio_feedback::play_sound(&app_handle, audio_feedback::SoundType::Start); shared_sink.play(); // Spawn overlay text updater: sleeps through each chunk's @@ -1577,10 +1582,7 @@ fn split_text_for_playback(text: &str, shorten_first_chunk: bool) -> Vec // Flush the last accumulated chunk. let trailing = current_chunk.trim(); if !trailing.is_empty() { - if is_first_chunk - && shorten_first_chunk - && trailing.len() > FIRST_CHUNK_TARGET_CHARS - { + if is_first_chunk && shorten_first_chunk && trailing.len() > FIRST_CHUNK_TARGET_CHARS { let (head, tail) = split_at_clause_boundary(trailing, FIRST_CHUNK_TARGET_CHARS); chunks.push(head); if let Some(remainder) = tail { diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index 42f962d..acf306d 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -230,7 +230,11 @@ fn calculate_overlay_position( /// Resizes the overlay window and repositions it so the anchored edge /// (bottom or top, depending on settings) stays in the correct place. -fn resize_and_reposition(app_handle: &AppHandle, overlay_window: &tauri::webview::WebviewWindow, height: f64) { +fn resize_and_reposition( + app_handle: &AppHandle, + overlay_window: &tauri::webview::WebviewWindow, + height: f64, +) { let _ = overlay_window.set_size(tauri::Size::Logical(tauri::LogicalSize { width: OVERLAY_WIDTH, height, @@ -242,8 +246,8 @@ fn resize_and_reposition(app_handle: &AppHandle, overlay_window: &tauri::webview } if let Some((x, y)) = calculate_overlay_position(app_handle, OVERLAY_WIDTH, height) { - let _ = overlay_window - .set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); + let _ = + overlay_window.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); } } @@ -399,8 +403,7 @@ pub fn update_overlay_position(app_handle: &AppHandle) { }) .unwrap_or(PROCESSING_HEIGHT); - if let Some((x, y)) = - calculate_overlay_position(app_handle, OVERLAY_WIDTH, current_height) + if let Some((x, y)) = calculate_overlay_position(app_handle, OVERLAY_WIDTH, current_height) { let _ = overlay_window .set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 72cc7ca..5c5a966 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -132,7 +132,6 @@ impl Default for KeyboardImplementation { } } - impl ModelUnloadTimeout { pub fn to_minutes(self) -> Option { match self { @@ -400,7 +399,9 @@ pub fn load_or_create_app_settings(app: &AppHandle) -> AppSettings { // Merge default bindings into existing settings for (key, value) in default_settings.bindings { - if let std::collections::hash_map::Entry::Vacant(e) = settings.bindings.entry(key) { + if let std::collections::hash_map::Entry::Vacant(e) = + settings.bindings.entry(key) + { debug!("Adding missing binding: {}", e.key()); e.insert(value); updated = true; @@ -497,4 +498,3 @@ pub fn get_history_retention_period(app: &AppHandle) -> HistoryRetentionPeriod { let settings = get_settings(app); settings.history_retention_period } - diff --git a/src-tauri/src/shortcut/handler.rs b/src-tauri/src/shortcut/handler.rs index 3875206..6ff05f9 100644 --- a/src-tauri/src/shortcut/handler.rs +++ b/src-tauri/src/shortcut/handler.rs @@ -6,8 +6,8 @@ use log::warn; use tauri::{AppHandle, Manager}; -use crate::actions::ACTION_MAP; use crate::action_coordinator::is_speak_binding; +use crate::actions::ACTION_MAP; use crate::ActionCoordinator; /// Handle a shortcut event from either implementation. diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index 9f0f825..04a5804 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -20,8 +20,7 @@ use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_autostart::ManagerExt; use crate::settings::{ - self, get_settings, KeyboardImplementation, - OverlayPosition, ShortcutBinding, SoundTheme, + self, get_settings, KeyboardImplementation, OverlayPosition, ShortcutBinding, SoundTheme, }; use crate::tray; @@ -280,9 +279,7 @@ pub fn change_keyboard_implementation_setting( settings::write_settings(&app, settings); // Initialize new implementation if needed (HandyKeys needs state) - if new_impl == KeyboardImplementation::HandyKeys - && initialize_handy_keys_with_rollback(&app)? - { + if new_impl == KeyboardImplementation::HandyKeys && initialize_handy_keys_with_rollback(&app)? { // Shortcuts already registered during init return Ok(ImplementationChangeResult { success: true, @@ -698,10 +695,7 @@ pub fn change_tts_speed_setting(app: AppHandle, speed: f32) -> Result<(), String #[tauri::command] #[specta::specta] -pub fn change_tts_shorten_first_chunk_setting( - app: AppHandle, - enabled: bool, -) -> Result<(), String> { +pub fn change_tts_shorten_first_chunk_setting(app: AppHandle, enabled: bool) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.tts_shorten_first_chunk = enabled; settings::write_settings(&app, settings); diff --git a/src-tauri/src/shortcut/tauri_impl.rs b/src-tauri/src/shortcut/tauri_impl.rs index 321811e..b7b1229 100644 --- a/src-tauri/src/shortcut/tauri_impl.rs +++ b/src-tauri/src/shortcut/tauri_impl.rs @@ -7,9 +7,9 @@ use log::{error, warn}; use tauri::AppHandle; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; -use crate::settings::{self, ShortcutBinding}; #[cfg(not(target_os = "linux"))] use crate::settings::get_settings; +use crate::settings::{self, ShortcutBinding}; use super::handler::handle_shortcut_event; diff --git a/src-tauri/src/signal_handle.rs b/src-tauri/src/signal_handle.rs index ed224e0..efdbbe3 100644 --- a/src-tauri/src/signal_handle.rs +++ b/src-tauri/src/signal_handle.rs @@ -1,7 +1,7 @@ use crate::ActionCoordinator; -use log::warn; #[cfg(unix)] use log::debug; +use log::warn; use tauri::{AppHandle, Manager}; #[cfg(unix)] diff --git a/src-tauri/src/text_normalization.rs b/src-tauri/src/text_normalization.rs new file mode 100644 index 0000000..ea0c075 --- /dev/null +++ b/src-tauri/src/text_normalization.rs @@ -0,0 +1,617 @@ +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; + +pub fn normalize_text_for_tts(text: &str) -> String { + if text.trim().is_empty() { + return String::new(); + } + + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_TASKLISTS); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_SMART_PUNCTUATION); + + let parser = Parser::new_ext(text, options); + let mut renderer = SpeechTextRenderer::default(); + + for event in parser { + renderer.handle_event(event); + } + + renderer.finish() +} + +#[derive(Default)] +struct SpeechTextRenderer { + output: String, + pending_breaks: usize, + list_stack: Vec, + link_stack: Vec, + image_stack: Vec, + quote_depth: usize, + skip_code_block_depth: usize, + table_cell_index: usize, +} + +#[derive(Clone, Debug)] +struct ListContext { + next_index: Option, +} + +#[derive(Clone, Debug)] +struct LinkContext { + destination: String, + text: String, +} + +#[derive(Clone, Debug, Default)] +struct ImageContext { + alt_text: String, +} + +impl SpeechTextRenderer { + fn handle_event(&mut self, event: Event<'_>) { + match event { + Event::Start(tag) => self.start_tag(tag), + Event::End(tag) => self.end_tag(tag), + Event::Text(text) | Event::InlineMath(text) | Event::DisplayMath(text) => { + if self.skip_code_block_depth == 0 { + self.push_text(&text); + } + } + Event::Code(code) => self.push_text(&simplify_inline_code(&code)), + Event::Html(html) | Event::InlineHtml(html) => { + if self.skip_code_block_depth == 0 { + self.push_text(&strip_html_tags(&html)); + } + } + Event::SoftBreak => self.push_space(), + Event::HardBreak => self.push_block_break(), + Event::Rule => { + self.ensure_terminal_punctuation(); + self.push_block_break(); + } + Event::TaskListMarker(checked) => { + self.push_text(if checked { + "Completed." + } else { + "Not completed." + }); + } + Event::FootnoteReference(name) => self.push_text(&normalize_inline_whitespace(&name)), + } + } + + fn start_tag(&mut self, tag: Tag<'_>) { + match tag { + Tag::Paragraph => {} + Tag::Heading { .. } => self.push_block_break(), + Tag::BlockQuote(_) => { + self.push_block_break(); + if self.quote_depth == 0 { + self.push_text("Quote:"); + } + self.quote_depth += 1; + } + Tag::CodeBlock(_) => { + self.push_block_break(); + self.push_text("Code example omitted."); + self.push_block_break(); + self.skip_code_block_depth += 1; + } + Tag::List(start) => { + self.push_block_break(); + self.list_stack.push(ListContext { next_index: start }); + } + Tag::Item => { + self.push_block_break(); + if let Some(list) = self.list_stack.last_mut() { + if let Some(index) = list.next_index.as_mut() { + let current = *index; + *index += 1; + self.push_text(&format!("{}.", current)); + } + } + } + Tag::Link { dest_url, .. } => { + self.link_stack.push(LinkContext { + destination: dest_url.into_string(), + text: String::new(), + }); + } + Tag::Image { .. } => { + self.image_stack.push(ImageContext::default()); + } + Tag::Table(_) => self.push_block_break(), + Tag::TableHead => self.push_block_break(), + Tag::TableRow => { + self.push_block_break(); + self.table_cell_index = 0; + } + Tag::TableCell => { + if self.table_cell_index > 0 { + self.push_text(","); + } + self.table_cell_index += 1; + } + Tag::Emphasis + | Tag::Strong + | Tag::Strikethrough + | Tag::FootnoteDefinition(_) + | Tag::MetadataBlock(_) => {} + _ => {} + } + } + + fn end_tag(&mut self, tag: TagEnd) { + match tag { + TagEnd::Paragraph => { + self.ensure_terminal_punctuation(); + self.push_block_break(); + } + TagEnd::Heading(_) => { + self.ensure_terminal_punctuation(); + self.push_block_break(); + } + TagEnd::BlockQuote(_) => { + self.quote_depth = self.quote_depth.saturating_sub(1); + self.ensure_terminal_punctuation(); + self.push_block_break(); + } + TagEnd::CodeBlock => { + self.skip_code_block_depth = self.skip_code_block_depth.saturating_sub(1); + } + TagEnd::List(_) => { + self.list_stack.pop(); + self.push_block_break(); + } + TagEnd::Item => { + self.ensure_terminal_punctuation(); + self.push_block_break(); + } + TagEnd::Link => { + if let Some(link) = self.link_stack.pop() { + let label = normalize_inline_whitespace(&link.text); + let spoken = if label.is_empty() + || normalize_inline_whitespace(&link.destination) + .eq_ignore_ascii_case(&label) + { + simplify_url_for_speech(&link.destination) + } else { + label + }; + self.push_text(&spoken); + } + } + TagEnd::Image => { + if let Some(image) = self.image_stack.pop() { + let alt_text = normalize_inline_whitespace(&image.alt_text); + if !alt_text.is_empty() { + self.push_text(&alt_text); + } + } + } + TagEnd::Table => self.push_block_break(), + TagEnd::TableHead => self.push_block_break(), + TagEnd::TableRow => { + self.ensure_terminal_punctuation(); + self.push_block_break(); + self.table_cell_index = 0; + } + TagEnd::TableCell + | TagEnd::Emphasis + | TagEnd::Strong + | TagEnd::Strikethrough + | TagEnd::FootnoteDefinition + | TagEnd::MetadataBlock(_) => {} + _ => {} + } + } + + fn push_text(&mut self, text: &str) { + let normalized = normalize_inline_whitespace(text); + if normalized.is_empty() { + return; + } + + if let Some(image) = self.image_stack.last_mut() { + append_segment(&mut image.alt_text, &normalized); + return; + } + + if let Some(link) = self.link_stack.last_mut() { + append_segment(&mut link.text, &normalized); + return; + } + + self.flush_breaks(); + + if needs_space_between( + self.output.chars().rev().nth(1), + self.output.chars().next_back(), + normalized.chars().next(), + ) { + self.output.push(' '); + } + self.output.push_str(&normalized); + } + + fn push_space(&mut self) { + if self.pending_breaks > 0 + || self.output.is_empty() + || self.output.ends_with(char::is_whitespace) + { + return; + } + self.output.push(' '); + } + + fn push_block_break(&mut self) { + if self.output.trim().is_empty() { + return; + } + self.pending_breaks = self.pending_breaks.max(2); + } + + fn flush_breaks(&mut self) { + if self.pending_breaks == 0 { + return; + } + + self.output.truncate(self.output.trim_end().len()); + if self.output.is_empty() { + self.pending_breaks = 0; + return; + } + + for _ in 0..self.pending_breaks { + self.output.push('\n'); + } + self.pending_breaks = 0; + } + + fn ensure_terminal_punctuation(&mut self) { + if self.output.trim().is_empty() { + return; + } + + self.output.truncate(self.output.trim_end().len()); + + let Some(last) = self.output.chars().next_back() else { + return; + }; + + if matches!(last, '.' | '!' | '?' | ':' | ';' | '…') { + return; + } + + self.output.push('.'); + } + + fn finish(mut self) -> String { + self.ensure_terminal_punctuation(); + + let mut result = String::new(); + let mut blank_line_count = 0usize; + + for line in self.output.lines().map(str::trim) { + if line.is_empty() { + blank_line_count += 1; + if blank_line_count > 1 { + continue; + } + } else { + blank_line_count = 0; + } + + if !result.is_empty() { + result.push('\n'); + } + result.push_str(line); + } + + result.trim().to_string() + } +} + +fn append_segment(buffer: &mut String, segment: &str) { + if segment.is_empty() { + return; + } + + if needs_space_between( + buffer.chars().rev().nth(1), + buffer.chars().next_back(), + segment.chars().next(), + ) { + buffer.push(' '); + } + buffer.push_str(segment); +} + +fn normalize_inline_whitespace(text: &str) -> String { + let mut normalized = String::new(); + let mut last_was_space = false; + + for ch in text.chars() { + if ch.is_whitespace() { + if !last_was_space { + normalized.push(' '); + last_was_space = true; + } + } else { + normalized.push(ch); + last_was_space = false; + } + } + + normalized.trim().to_string() +} + +fn needs_space_between(prev_left: Option, left: Option, right: Option) -> bool { + match (prev_left, left, right) { + (_, Some(left), Some(right)) + if left.is_alphanumeric() && matches!(right, '&' | '\'' | '’') => + { + false + } + (_, Some('&'), Some(right)) if right.is_alphanumeric() => false, + (prev_left, Some('\'' | '’'), Some(right)) if right.is_alphanumeric() => { + match prev_left { + None => false, + Some(ch) + if ch.is_whitespace() + || matches!(ch, '(' | '[' | '{' | '"' | ':' | ';' | '—' | '–') => + { + false + } + Some(_) => true, + } + } + (_, Some(left), Some(right)) => { + if (left.is_alphanumeric() && matches!(right, '&' | '\'' | '’')) + || (left == '&' && right.is_alphanumeric()) + { + false + } else { + !left.is_whitespace() + && !matches!(right, ',' | '.' | '!' | '?' | ':' | ';' | ')' | ']' | '}') + && !matches!(left, '(' | '[' | '{' | '/' | '\n') + } + } + _ => false, + } +} + +fn simplify_inline_code(code: &str) -> String { + let mut simplified = String::new(); + let chars: Vec = code.chars().collect(); + + for (index, ch) in chars.iter().enumerate() { + let prev = index.checked_sub(1).and_then(|idx| chars.get(idx)).copied(); + let next = chars.get(index + 1).copied(); + let adjacent_word_chars = prev.map(is_code_word_char).unwrap_or(false) + && next.map(is_code_word_char).unwrap_or(false); + + if adjacent_word_chars && matches!(ch, '_' | '-' | '/' | '\\' | '.' | ':') { + if !simplified.ends_with(' ') { + simplified.push(' '); + } + continue; + } + + simplified.push(*ch); + } + + normalize_inline_whitespace(&simplified) +} + +fn is_code_word_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() +} + +fn simplify_url_for_speech(url: &str) -> String { + let trimmed = url + .trim() + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_start_matches("mailto:"); + + let mut spoken = String::new(); + let mut previous_was_space = false; + + for ch in trimmed.chars() { + if ch.is_ascii_alphanumeric() { + spoken.push(ch); + previous_was_space = false; + continue; + } + + if matches!( + ch, + '.' | '/' | '_' | '-' | '?' | '&' | '=' | '#' | ':' | '%' + ) && !previous_was_space + { + spoken.push(' '); + previous_was_space = true; + } + } + + normalize_inline_whitespace(&spoken) +} + +fn strip_html_tags(html: &str) -> String { + let mut out = String::new(); + let mut in_tag = false; + let mut entity = String::new(); + let mut in_entity = false; + + for ch in html.chars() { + if in_entity { + if ch == ';' { + if let Some(decoded) = decode_html_entity(&entity) { + out.push_str(&decoded); + } else { + out.push('&'); + out.push_str(&entity); + out.push(';'); + } + entity.clear(); + in_entity = false; + continue; + } + + if entity.len() < 16 { + entity.push(ch); + } + continue; + } + + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + '&' if !in_tag => { + in_entity = true; + entity.clear(); + } + _ if !in_tag => out.push(ch), + _ => {} + } + } + + if in_entity { + out.push('&'); + out.push_str(&entity); + } + + normalize_inline_whitespace(&out) +} + +fn decode_html_entity(entity: &str) -> Option { + match entity { + "amp" => Some("&".to_string()), + "lt" => Some("<".to_string()), + "gt" => Some(">".to_string()), + "quot" => Some("\"".to_string()), + "apos" => Some("'".to_string()), + "nbsp" => Some(" ".to_string()), + _ => decode_numeric_html_entity(entity), + } +} + +fn decode_numeric_html_entity(entity: &str) -> Option { + let code = entity.strip_prefix('#')?; + + let (radix, digits) = match code.as_bytes() { + [b'x', rest @ ..] | [b'X', rest @ ..] => (16, std::str::from_utf8(rest).ok()?), + _ => (10, code), + }; + + if digits.is_empty() { + return None; + } + + let value = u32::from_str_radix(digits, radix).ok()?; + char::from_u32(value).map(|decoded| decoded.to_string()) +} + +#[cfg(test)] +mod tests { + use super::normalize_text_for_tts; + use std::fs; + use std::path::PathBuf; + + #[test] + fn strips_common_markdown_formatting_without_losing_flow() { + let markdown = r#" +## Heading + +This is **important** and includes a [helpful link](https://example.com/docs/getting-started). + +- First bullet +- Second bullet with `snake_case` inline code + +> Quoted thought +"#; + + let spoken = normalize_text_for_tts(markdown); + assert!(spoken.contains("Heading.")); + assert!(spoken.contains("This is important and includes a helpful link.")); + assert!(spoken.contains("First bullet.")); + assert!(spoken.contains("Second bullet with snake case inline code.")); + assert!(spoken.contains("Quote: Quoted thought.")); + assert!(!spoken.contains("##")); + assert!(!spoken.contains("**")); + assert!(!spoken.contains('`')); + } + + #[test] + fn handles_code_fences_and_fallback_links_intentionally() { + let markdown = r#" +See . + +```rust +fn main() { + println!("hello"); +} +``` +"#; + + let spoken = normalize_text_for_tts(markdown); + assert!(spoken.contains("example com docs api reference")); + assert!(spoken.contains("Code example omitted.")); + assert!(!spoken.contains("```")); + assert!(!spoken.contains("println!")); + } + + #[test] + fn preserves_ordered_list_and_task_list_structure() { + let markdown = r#" +1. Install dependencies +2. Run tests + +- [x] Ship fix +- [ ] Write follow-up +"#; + + let spoken = normalize_text_for_tts(markdown); + assert!(spoken.contains("1. Install dependencies.")); + assert!(spoken.contains("2. Run tests.")); + assert!(spoken.contains("Completed. Ship fix.")); + assert!(spoken.contains("Not completed. Write follow-up.")); + } + + #[test] + fn normalizes_real_repository_markdown_without_raw_markup_artifacts() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let readme = fs::read_to_string(manifest_dir.join("../README.md")).expect("read README"); + let spoken = normalize_text_for_tts(&readme); + + assert!(spoken.contains("Parrot")); + assert!(!spoken.contains("```")); + assert!(!spoken.contains("## ")); + assert!(!spoken.contains("**")); + assert!(spoken.len() > 1000); + } + + #[test] + fn decodes_named_and_numeric_html_entities_in_html_blocks() { + let markdown = r#" +AT&T uses smart quotes: ’hello’ and — dash. +"#; + + let spoken = normalize_text_for_tts(markdown); + assert!(spoken.contains("AT&T uses smart quotes: ’hello’ and — dash.")); + } + + #[test] + fn preserves_unknown_or_incomplete_entities_as_literal_text() { + let markdown = r#" +Keep &custom; visible and preserve dangling &entity text. +"#; + + let spoken = normalize_text_for_tts(markdown); + assert!(spoken.contains("Keep &custom; visible and preserve dangling &entity text.")); + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 97e8954..22cbaa6 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -10,7 +10,6 @@ use tauri::tray::TrayIcon; use tauri::{AppHandle, Manager, Theme}; use tauri_plugin_clipboard_manager::ClipboardExt; - #[derive(Clone, Debug, PartialEq)] pub enum AppTheme { Dark, diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 7d139d0..92e4f3d 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -29,4 +29,3 @@ pub fn cancel_current_operation(app: &AppHandle) { info!("Operation cancellation completed - returned to idle state"); } - diff --git a/src/App.css b/src/App.css index 2653488..2375372 100644 --- a/src/App.css +++ b/src/App.css @@ -3,9 +3,9 @@ @theme { --color-text: #0f0f0f; --color-background: #fbfbfb; - --color-background-ui: #4FCC73; - --color-logo-primary: #9BE870; - --color-logo-stroke: #2F6B3A; + --color-background-ui: #4fcc73; + --color-logo-primary: #9be870; + --color-logo-stroke: #2f6b3a; --color-text-stroke: #f6f6f6; --color-mid-gray: #808080; } @@ -49,8 +49,8 @@ /* Colors - Dark Theme */ --color-text: #fbfbfb; --color-background: #2c2b29; - --color-logo-primary: #9BE870; - --color-logo-stroke: #2F6B3A; + --color-logo-primary: #9be870; + --color-logo-stroke: #2f6b3a; } } @@ -68,4 +68,4 @@ fill: var(--color-logo-stroke); stroke: var(--color-logo-stroke); stroke-width: 1; -} \ No newline at end of file +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index fab629e..4a51c9a 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -34,7 +34,10 @@ const formatCompactShortcut = (binding: string, osType: OSType): string => { return binding .split("+") .map((part) => { - const key = part.trim().replace(/_(left|right)$/, "").toLowerCase(); + const key = part + .trim() + .replace(/_(left|right)$/, "") + .toLowerCase(); return MAC_SYMBOLS[key] ?? key.charAt(0).toUpperCase() + key.slice(1); }) .join(" "); @@ -125,10 +128,11 @@ export const Sidebar: React.FC = ({ return (
onSectionChange(section.id)} > diff --git a/src/components/settings/GlobalShortcutInput.tsx b/src/components/settings/GlobalShortcutInput.tsx index a0f88d6..03c66c3 100644 --- a/src/components/settings/GlobalShortcutInput.tsx +++ b/src/components/settings/GlobalShortcutInput.tsx @@ -51,7 +51,14 @@ export const GlobalShortcutInput: React.FC = ({ const handleKeyDown = async (e: KeyboardEvent) => { if (cleanup) return; if (e.repeat) return; // ignore auto-repeat - if (e.key === "Escape" && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && keyPressed.length === 0) { + if ( + e.key === "Escape" && + !e.ctrlKey && + !e.shiftKey && + !e.altKey && + !e.metaKey && + keyPressed.length === 0 + ) { // Cancel recording and restore original binding if (editingShortcutId && originalBinding) { try { @@ -282,7 +289,8 @@ export const GlobalShortcutInput: React.FC = ({ binding.description, ); - const isShortcutDisabled = allowDisable && binding.current_binding === "disabled"; + const isShortcutDisabled = + allowDisable && binding.current_binding === "disabled"; const handleToggleEnable = async () => { if (isShortcutDisabled) { @@ -327,7 +335,9 @@ export const GlobalShortcutInput: React.FC = ({
)} {allowDisable && ( -