diff --git a/package.json b/package.json index 6934fc6d7..1cb63a295 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,7 @@ "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.4", "@tauri-apps/plugin-updater": "^2.11.0", + "tauri-plugin-tts-api": "0.1.13", "@tiptap/core": "^3.31.3", "@tiptap/extension-blockquote": "^3.31.3", "@tiptap/extension-bullet-list": "^3.31.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 911c870fb..7481f5603 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -298,6 +298,9 @@ importers: sonner: specifier: ^2.0.8 version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tauri-plugin-tts-api: + specifier: 0.1.13 + version: 0.1.13 turndown: specifier: ^7.2.4 version: 7.2.4 @@ -8431,6 +8434,9 @@ packages: resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} + tauri-plugin-tts-api@0.1.13: + resolution: {integrity: sha512-i/uq68I1OxveTh8nfOi9FRp38UBJtOiMnpMR4lipwdXIbL7ALvAw/30kmtOWiF5IkdBJZDz/PxsoyBmKbbm3VQ==} + teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} @@ -18589,6 +18595,10 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + tauri-plugin-tts-api@0.1.13: + dependencies: + '@tauri-apps/api': 2.11.1 + teex@1.0.1: dependencies: streamx: 2.28.0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5270c2e4b..fb5eb8d2e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -362,6 +362,25 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bindgen" +version = "0.73.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 3.0.1", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -392,6 +411,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -617,6 +642,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfb" version = "0.7.3" @@ -691,6 +725,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -709,6 +754,20 @@ dependencies = [ "cc", ] +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + [[package]] name = "combine" version = "4.6.7" @@ -778,11 +837,22 @@ checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.0", "core-foundation 0.10.1", - "core-graphics-types", + "core-graphics-types 0.2.0", "foreign-types", "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + [[package]] name = "core-graphics-types" version = "0.2.0" @@ -1208,6 +1278,27 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clonable" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a36efbb9bfd58e1723780aa04b61aba95ace6a05d9ffabfdb0b43672552f0805" +dependencies = [ + "dyn-clonable-impl", + "dyn-clone", +] + +[[package]] +name = "dyn-clonable-impl" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8671d54058979a37a26f3511fbf8d198ba1aa35ffb202c42587d918d77213a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -2684,7 +2775,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -2713,6 +2804,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libredox" version = "0.1.17" @@ -2777,7 +2878,7 @@ dependencies = [ "itoa", "log", "md-5", - "nom", + "nom 8.0.0", "rand 0.10.2", "rangemap", "sha2 0.11.0", @@ -2806,6 +2907,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2848,6 +2958,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "minisign-verify" version = "0.2.5" @@ -2922,6 +3038,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2955,6 +3077,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -3060,6 +3192,16 @@ dependencies = [ "libc", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -3538,6 +3680,15 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3602,6 +3753,15 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "oxilangtag" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3b4eb570abd4a1dcb062c31fd37b832264d9dc7292c3e69acfe926c87b063f" +dependencies = [ + "serde", +] + [[package]] name = "pango" version = "0.18.3" @@ -3859,6 +4019,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.1", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -4968,6 +5138,26 @@ dependencies = [ "system-deps", ] +[[package]] +name = "speech-dispatcher" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727d53c474ba5ada07784ad7d203cf896a74854cfee0eb32376b00759eb2972" +dependencies = [ + "lazy_static", + "libc", + "speech-dispatcher-sys", +] + +[[package]] +name = "speech-dispatcher-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c3e8acdf2b1f4bb13f1813b40b52f3edf4cc94d8a55fe713a584f672a10388d" +dependencies = [ + "bindgen", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -5542,6 +5732,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tauri-plugin-tts" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c728b71987a905e75c01bfc4446616fcc721177efdeec66f651eb06cf5fa4b0" +dependencies = [ + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "tts", + "uuid", +] + [[package]] name = "tauri-plugin-updater" version = "2.11.0" @@ -6174,7 +6380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ "memchr", - "nom", + "nom 8.0.0", "petgraph", ] @@ -6195,6 +6401,29 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tts" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0727c46b3181e4f84e79f970e6a78d3b4054b72b6072e969ea4f07dfa4983ae2" +dependencies = [ + "cocoa-foundation", + "core-foundation 0.9.4", + "dyn-clonable", + "jni 0.21.1", + "lazy_static", + "libc", + "log", + "ndk-context", + "objc", + "oxilangtag", + "speech-dispatcher", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "windows 0.58.0", +] + [[package]] name = "tungstenite" version = "0.28.0" @@ -6462,6 +6691,7 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-store", + "tauri-plugin-tts", "tauri-plugin-updater", "tauri-plugin-window-state", "tempfile", @@ -6864,6 +7094,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -6898,6 +7138,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -6946,6 +7199,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6968,6 +7232,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -7021,6 +7296,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -7039,6 +7323,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4abafe65b..eb401805b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,6 +46,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" tauri-plugin-notification = "2" tauri-plugin-log = "2" +tauri-plugin-tts = "=0.1.13" log = "0.4" serde = { version = "1", features = ["derive"] } # WI-14: derive macro for the crate-wide `CommandError`. dtolnay's crate, the diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8494ec860..1e8099734 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -32,6 +32,7 @@ "notification:allow-is-permission-granted", "notification:allow-request-permission", "notification:allow-notify", + "tts:default", "log:default", { "identifier": "opener:allow-open-url", diff --git a/src-tauri/src/app_plugins.rs b/src-tauri/src/app_plugins.rs index d2f82daaf..d99fe6543 100644 --- a/src-tauri/src/app_plugins.rs +++ b/src-tauri/src/app_plugins.rs @@ -88,6 +88,7 @@ pub(crate) fn register(builder: tauri::Builder) -> tauri::Builder { expect(sectionIds(sections)).toEqual([ "clipboard", "selection", + "speech", "inline", "block", "link", @@ -78,28 +79,28 @@ describe("buildEditorContextMenu — sections", () => { it("hides inline, block, and link sections inside a code block", () => { const sections = buildEditorContextMenu(snapshot({ inCodeBlock: true })); - expect(sectionIds(sections)).toEqual(["clipboard", "selection"]); + expect(sectionIds(sections)).toEqual(["clipboard", "selection", "speech"]); }); it("hides formatting sections when paragraphFormatting policy is off", () => { const sections = buildEditorContextMenu( snapshot({ formatPolicy: { paragraphFormatting: false, insertBlockActions: false } }) ); - expect(sectionIds(sections)).toEqual(["clipboard", "selection"]); + expect(sectionIds(sections)).toEqual(["clipboard", "selection", "speech"]); }); it("keeps the link section when only insertBlockActions is allowed", () => { const sections = buildEditorContextMenu( snapshot({ formatPolicy: { paragraphFormatting: false, insertBlockActions: true } }) ); - expect(sectionIds(sections)).toEqual(["clipboard", "selection", "link"]); + expect(sectionIds(sections)).toEqual(["clipboard", "selection", "speech", "link"]); }); it("hides the code-block item but keeps the block section when insertBlockActions is off", () => { const sections = buildEditorContextMenu( snapshot({ formatPolicy: { paragraphFormatting: true, insertBlockActions: false } }) ); - expect(sectionIds(sections)).toEqual(["clipboard", "selection", "inline", "block"]); + expect(sectionIds(sections)).toEqual(["clipboard", "selection", "speech", "inline", "block"]); expect(findItem(sections, "codeBlock")).toBeUndefined(); }); }); @@ -126,6 +127,26 @@ describe("buildEditorContextMenu — clipboard", () => { }); }); +describe("buildEditorContextMenu — read aloud", () => { + it("always offers reading from the cursor", () => { + const sections = buildEditorContextMenu(snapshot()); + expect(action(sections, "readFromCursor").run).toEqual({ + type: "command", + command: "speech.readFromCursor", + }); + }); + + it("offers selection speech only for a non-empty selection", () => { + expect(findItem(buildEditorContextMenu(snapshot()), "readSelection")).toBeUndefined(); + expect( + action( + buildEditorContextMenu(snapshot({ selectionEmpty: false })), + "readSelection", + ).run, + ).toEqual({ type: "command", command: "speech.readSelection" }); + }); +}); + describe("buildEditorContextMenu — inline formatting", () => { it("checks active marks and leaves the rest unchecked", () => { const sections = buildEditorContextMenu( diff --git a/src/components/Editor/EditorContextMenu/menuModel.ts b/src/components/Editor/EditorContextMenu/menuModel.ts index 3e03b6679..b445b9ba2 100644 --- a/src/components/Editor/EditorContextMenu/menuModel.ts +++ b/src/components/Editor/EditorContextMenu/menuModel.ts @@ -28,7 +28,8 @@ import type { EditorContextMenuSnapshot } from "@/types/editorContextMenu"; export type EditorMenuRun = | { type: "adapter"; action: AdapterAction } | { type: "clipboard"; command: "cut" | "copy" | "paste" | "selectAll" } - | { type: "link"; command: "editLink" | "copyLink" | "removeLink" }; + | { type: "link"; command: "editLink" | "copyLink" | "removeLink" } + | { type: "command"; command: "speech.readSelection" | "speech.readFromCursor" }; export interface EditorMenuAction { kind: "action"; @@ -94,6 +95,11 @@ const SELECT_ALL_ITEM: ContextMenuItemDescriptor = { id: "selectAll", labelKey: "contextMenu.selectAll", run: { type: "clipboard", command: "selectAll" }, iconId: "selectAll", shortcutKey: "Mod-a", }; +const [READ_SELECTION_ITEM, READ_FROM_CURSOR_ITEM]: [ContextMenuItemDescriptor, ContextMenuItemDescriptor] = [ + { id: "readSelection", labelKey: "commands:speech.readSelection", run: { type: "command", command: "speech.readSelection" } }, + { id: "readFromCursor", labelKey: "commands:speech.readFromCursor", run: { type: "command", command: "speech.readFromCursor" } }, +]; + const INLINE_ITEMS: AdapterMenuDescriptor[] = [ { id: "bold", labelKey: "contextMenu.bold", run: { type: "adapter", action: "bold" }, iconId: "bold", shortcutId: "bold", checkable: true }, { id: "italic", labelKey: "contextMenu.italic", run: { type: "adapter", action: "italic" }, iconId: "italic", shortcutId: "italic", checkable: true }, @@ -143,6 +149,8 @@ const REMOVE_LINK_ITEM: ContextMenuItemDescriptor = { export const CONTEXT_MENU_DESCRIPTORS: ContextMenuItemDescriptor[] = [ ...CLIPBOARD_ITEMS, SELECT_ALL_ITEM, + READ_SELECTION_ITEM, + READ_FROM_CURSOR_ITEM, ...INLINE_ITEMS, ...HEADING_CHILDREN, ...LIST_CHILDREN, @@ -259,6 +267,7 @@ export function buildEditorContextMenu( ), }, { id: "selection", items: [toAction(SELECT_ALL_ITEM)] }, + { id: "speech", items: [...(!snapshot.selectionEmpty ? [toAction(READ_SELECTION_ITEM)] : []), toAction(READ_FROM_CURSOR_ITEM)] }, ]; const showFormatting = !snapshot.inCodeBlock && snapshot.formatPolicy.paragraphFormatting; diff --git a/src/components/Editor/EditorContextMenu/runMenuAction.test.ts b/src/components/Editor/EditorContextMenu/runMenuAction.test.ts index 5601e99e5..c86a9287b 100644 --- a/src/components/Editor/EditorContextMenu/runMenuAction.test.ts +++ b/src/components/Editor/EditorContextMenu/runMenuAction.test.ts @@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => ({ focusEditorSurface: vi.fn(), writeText: vi.fn(async () => undefined), runOrQueueCodeMirrorAction: vi.fn((_view: unknown, action: () => void) => action()), + executeCommand: vi.fn(async () => true), + resolveCommandContext: vi.fn(() => ({ windowLabel: "doc-1" })), })); vi.mock("@/plugins/toolbarActions/dispatch", () => ({ @@ -24,6 +26,13 @@ vi.mock("./clipboardBridge", () => ({ focusEditorSurface: mocks.focusEditorSurface, })); vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({ writeText: mocks.writeText })); +vi.mock("@/services/commands/CommandBus", () => ({ executeCommand: mocks.executeCommand })); +vi.mock("@/services/commands/commandContext", () => ({ + resolveCommandContext: mocks.resolveCommandContext, +})); +vi.mock("@/services/persistence/workspaceStorage", () => ({ + getCurrentWindowLabel: () => "doc-1", +})); import { runEditorMenuItem } from "./runMenuAction"; import { useEditorStore } from "@/stores/editorStore"; @@ -74,6 +83,19 @@ describe("runEditorMenuItem", () => { expect(mocks.dispatchEditorAction).not.toHaveBeenCalled(); }); + it("routes read-aloud items through the command bus with live window context", async () => { + await runEditorMenuItem( + { type: "command", command: "speech.readFromCursor" }, + snapshot(), + ); + expect(mocks.resolveCommandContext).toHaveBeenCalledWith("doc-1"); + expect(mocks.executeCommand).toHaveBeenCalledWith( + "speech.readFromCursor", + undefined, + { windowLabel: "doc-1" }, + ); + }); + it("copyLink writes the resolved href to the clipboard", async () => { await runEditorMenuItem( { type: "link", command: "copyLink" }, diff --git a/src/components/Editor/EditorContextMenu/runMenuAction.ts b/src/components/Editor/EditorContextMenu/runMenuAction.ts index 37f270dce..9bdf5b96a 100644 --- a/src/components/Editor/EditorContextMenu/runMenuAction.ts +++ b/src/components/Editor/EditorContextMenu/runMenuAction.ts @@ -21,6 +21,9 @@ import { useLinkPopupStore } from "@/stores/linkPopupStore"; import type { EditorContextMenuSnapshot } from "@/types/editorContextMenu"; import { focusEditorSurface, runClipboardCommand } from "./clipboardBridge"; import type { EditorMenuRun } from "./menuModel"; +import { executeCommand } from "@/services/commands/CommandBus"; +import { resolveCommandContext } from "@/services/commands/commandContext"; +import { getCurrentWindowLabel } from "@/services/persistence/workspaceStorage"; /** * True when [from, to) is still ONE continuous link with `href` in the @@ -128,5 +131,10 @@ export async function runEditorMenuItem( return; case "link": await runLinkCommand(run.command, snapshot); + return; + case "command": { + const label = getCurrentWindowLabel(); + await executeCommand(run.command, undefined, resolveCommandContext(label)); + } } } diff --git a/src/components/StatusBar/ReadAloudIndicator.test.tsx b/src/components/StatusBar/ReadAloudIndicator.test.tsx new file mode 100644 index 000000000..8f4c508cf --- /dev/null +++ b/src/components/StatusBar/ReadAloudIndicator.test.tsx @@ -0,0 +1,88 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useReadAloudStore } from "@/features/readAloud/readAloudStore"; + +const { pauseReadAloud, resumeReadAloud, stopReadAloud } = vi.hoisted(() => ({ + pauseReadAloud: vi.fn(async () => undefined), + resumeReadAloud: vi.fn(async () => undefined), + stopReadAloud: vi.fn(async () => undefined), +})); +vi.mock("@/features/readAloud/runtime", () => ({ + pauseReadAloud, + resumeReadAloud, + stopReadAloud, +})); + +import { ReadAloudIndicator } from "./ReadAloudIndicator"; + +beforeEach(() => { + stopReadAloud.mockClear(); + pauseReadAloud.mockClear(); + resumeReadAloud.mockClear(); + useReadAloudStore.setState({ + status: "idle", + sessionId: null, + tabId: null, + index: 0, + total: 0, + current: null, + error: null, + }); +}); + +describe("ReadAloudIndicator", () => { + it("stays absent while idle", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("announces progress and offers an accessible stop button", () => { + useReadAloudStore.setState({ + status: "playing", + sessionId: "session-1", + tabId: "tab-1", + index: 1, + total: 3, + current: { id: "s2", text: "Two", from: 4, to: 7 }, + error: null, + }); + render(); + + expect(screen.getByRole("status")).toHaveTextContent("2/3"); + fireEvent.click(document.querySelector(".status-read-aloud__pause")!); + expect(pauseReadAloud).toHaveBeenCalledTimes(1); + fireEvent.click(document.querySelector(".status-read-aloud__stop")!); + expect(stopReadAloud).toHaveBeenCalledTimes(1); + }); + + it("resumes a paused sentence", () => { + useReadAloudStore.setState({ + status: "paused", + sessionId: "session-1", + tabId: "tab-1", + index: 0, + total: 2, + current: { id: "s1", text: "One", from: 0, to: 3 }, + error: null, + }); + render(); + fireEvent.click(document.querySelector(".status-read-aloud__pause")!); + expect(resumeReadAloud).toHaveBeenCalledTimes(1); + }); + + it("shows provider failures without leaking an empty progress count", () => { + useReadAloudStore.setState({ + status: "error", + sessionId: "session-1", + tabId: "tab-1", + index: 0, + total: 1, + current: null, + error: "voice unavailable", + }); + render(); + + expect(screen.getByRole("status")).toHaveAttribute("title", "voice unavailable"); + expect(screen.getByRole("status")).not.toHaveTextContent("0/0"); + }); +}); diff --git a/src/components/StatusBar/ReadAloudIndicator.tsx b/src/components/StatusBar/ReadAloudIndicator.tsx new file mode 100644 index 000000000..64a4636c4 --- /dev/null +++ b/src/components/StatusBar/ReadAloudIndicator.tsx @@ -0,0 +1,61 @@ +import { AlertCircle, Pause, Play, Square, Volume2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import i18n from "@/i18n"; +import { useReadAloudStore } from "@/features/readAloud/readAloudStore"; +import { + pauseReadAloud, + resumeReadAloud, + stopReadAloud, +} from "@/features/readAloud/runtime"; +import { ICON_SM } from "@/utils/iconSizes"; + +export function ReadAloudIndicator() { + const { t } = useTranslation("statusbar"); + const status = useReadAloudStore((state) => state.status); + const index = useReadAloudStore((state) => state.index); + const total = useReadAloudStore((state) => state.total); + const error = useReadAloudStore((state) => state.error); + if (status === "idle") return null; + + const current = Math.min(index + 1, total); + const failed = status === "error"; + const paused = status === "paused"; + return ( + + {failed ? : } + {failed ? ( + {t("readAloudError")} + ) : ( + <> + + {t("readAloudProgress", { current, total })} + + )} + {!failed && status !== "stopping" && ( + + )} + + + ); +} diff --git a/src/components/StatusBar/StatusBar.css b/src/components/StatusBar/StatusBar.css index 37777d014..528d10f2b 100644 --- a/src/components/StatusBar/StatusBar.css +++ b/src/components/StatusBar/StatusBar.css @@ -547,6 +547,27 @@ } } +/* ======================================== + Read-aloud status + ======================================== */ + +.status-read-aloud { + display: inline-flex; + align-items: center; + gap: var(--space-1); + color: var(--accent-primary); + font-size: var(--font-size-xs); + white-space: nowrap; +} + +.status-read-aloud.error { + color: var(--error-color); +} + +.status-read-aloud__stop { + color: inherit; +} + /* ======================================== AI Status Indicator (running / error / success) ======================================== */ diff --git a/src/components/StatusBar/StatusBar.test.tsx b/src/components/StatusBar/StatusBar.test.tsx index 65a1ab022..4b29ef99b 100644 --- a/src/components/StatusBar/StatusBar.test.tsx +++ b/src/components/StatusBar/StatusBar.test.tsx @@ -82,6 +82,7 @@ import { StatusBar } from "./StatusBar"; import { useUIStore } from "@/stores/uiStore"; import { useShortcutsStore, formatKeyForDisplay } from "@/stores/settingsStore"; import { useTabStore } from "@/stores/tabStore"; +import { useReadAloudStore } from "@/features/readAloud/readAloudStore"; // Local ResizeObserver shim — `src/test/setup.ts` deliberately defines no // global one (see its note). This file renders StatusBarTabStrip, which since @@ -98,6 +99,7 @@ vi.stubGlobal( describe("StatusBar accessibility", () => { beforeEach(() => { useUIStore.setState({ sidebarVisible: false, statusBarVisible: true }); + useReadAloudStore.setState({ status: "idle" }); useShortcutsStore.setState({ customBindings: {} }); }); @@ -141,6 +143,7 @@ describe("StatusBar accessibility", () => { describe("StatusBar — browser workspace (WI-S1.3)", () => { beforeEach(() => { useUIStore.setState({ sidebarVisible: true, statusBarVisible: true }); + useReadAloudStore.setState({ status: "idle" }); useTabStore.setState({ tabs: {}, activeTabId: {}, @@ -184,4 +187,13 @@ describe("StatusBar — browser workspace (WI-S1.3)", () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); }); + + it("temporarily shows the hidden bar while read-aloud is active", () => { + useUIStore.setState({ sidebarVisible: true, statusBarVisible: false }); + useReadAloudStore.setState({ status: "playing" }); + const id = useTabStore.getState().createTab("main", null); + useTabStore.getState().setActiveTab("main", id); + render(); + expect(screen.getByTestId("status-bar-right")).toBeInTheDocument(); + }); }); diff --git a/src/components/StatusBar/StatusBar.tsx b/src/components/StatusBar/StatusBar.tsx index 91d93cfeb..7a0cdf071 100644 --- a/src/components/StatusBar/StatusBar.tsx +++ b/src/components/StatusBar/StatusBar.tsx @@ -62,8 +62,8 @@ import { useStatusToasts } from "@/hooks/useStatusToasts"; import { useStatusBarTabDrag } from "./useStatusBarTabDrag"; import { useQuitFeedback } from "./useQuitFeedback"; import { ARIA_LIVE_STYLE, preventSelectAllOnButtons } from "./statusBarHelpers"; +import { useReadAloudStore } from "@/features/readAloud/readAloudStore"; import "./StatusBar.css"; - /** Bottom bar combining tab strip, word/char counts, auto-save indicator, AI status, and mode toggle. */ export function StatusBar() { const { t } = useTranslation("statusbar"); @@ -86,11 +86,11 @@ export function StatusBar() { const aiError = useAiInvocationStore((state) => state.error); const aiShowSuccess = useAiInvocationStore((state) => state.showSuccess); const aiHasActiveStatus = useAiInvocationStore((state) => state.hasActiveStatus); + const speechHasActiveStatus = useReadAloudStore((state) => state.status !== "idle"); const { running: mcpRunning, loading: mcpLoading, error: mcpError } = useMcpServer(); const mcpClients = useMcpClients(mcpRunning); const { activeTabId, browserWorkspace } = useBrowserWorkspaceState(); const activeBrowserTabId = browserWorkspace.activeBrowserPageId; - const openMcpSettings = useCallback(() => openSettingsWindow("integrations"), []); const handleRetryAi = useCallback(() => { // Dismiss error in status bar — user can retry from the picker or resubmit @@ -187,7 +187,7 @@ export function StatusBar() { // A browser tab keeps the workspace bar even when hidden (F7): it remains // the user's route back to the Browser workspace and its page tabs. - if (!statusBarVisible && !aiHasActiveStatus && !activeBrowserTabId) return null; + if (!statusBarVisible && !aiHasActiveStatus && !speechHasActiveStatus && !activeBrowserTabId) return null; return ( <> diff --git a/src/components/StatusBar/StatusBarRight.tsx b/src/components/StatusBar/StatusBarRight.tsx index 7228a334d..57f8f0d18 100644 --- a/src/components/StatusBar/StatusBarRight.tsx +++ b/src/components/StatusBar/StatusBarRight.tsx @@ -38,6 +38,7 @@ import { StatusBarAiIndicator } from "./StatusBarAiIndicator"; import { McpHistoryButton } from "@/components/McpHistory"; import { LintBadge } from "./LintBadge"; import type { McpClient } from "@/hooks/useMcpClients"; +import { ReadAloudIndicator } from "./ReadAloudIndicator"; const UPPERCASE_WORDS = new Set(["cli", "ai", "mcp", "api", "ide"]); @@ -152,6 +153,8 @@ export function StatusBarRight({ + + {/* WI-UA11 (audit 20260901): hairline dividers split the cluster into diff --git a/src/features/readAloud/editorInput.test.ts b/src/features/readAloud/editorInput.test.ts new file mode 100644 index 000000000..cb653ec4b --- /dev/null +++ b/src/features/readAloud/editorInput.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment node + +import { EditorState as SourceState } from "@codemirror/state"; +import StarterKit from "@tiptap/starter-kit"; +import { getSchema } from "@tiptap/core"; +import { EditorState as WysiwygState, TextSelection } from "@tiptap/pm/state"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useEditorStore } from "@/stores/editorStore"; +import { useTabStore } from "@/stores/tabStore"; +import { useUIStore } from "@/stores/uiStore"; +import { resolveEditorSpeechInput } from "./editorInput"; + +function seedDocument(tabId: string): void { + useTabStore.setState({ + tabs: { + main: [ + { + id: tabId, + kind: "document", + title: "Doc", + filePath: null, + formatId: "markdown", + isPinned: false, + }, + ], + }, + activeTabId: { main: tabId }, + }); +} + +beforeEach(() => { + useTabStore.setState({ tabs: {}, activeTabId: {} }); + useEditorStore.getState().clearActiveEditors(); + useUIStore.setState({ sourceMode: false }); +}); + +describe("resolveEditorSpeechInput", () => { + it("returns null without an active document", () => { + expect(resolveEditorSpeechInput("main", "document")).toBeNull(); + }); + + it("extracts from the active source editor", () => { + seedDocument("tab-1"); + const state = SourceState.create({ + doc: "Alpha\n中文行", + selection: { anchor: 6, head: 9 }, + }); + useUIStore.setState({ sourceMode: true }); + useEditorStore.getState().setActiveSourceView({ state } as never, "tab-1"); + + expect(resolveEditorSpeechInput("main", "selection")).toEqual({ + tabId: "tab-1", + ranges: [{ text: "中文行", from: 6, to: 9 }], + }); + }); + + it("extracts from the active WYSIWYG editor", () => { + seedDocument("tab-1"); + const schema = getSchema([StarterKit]); + const doc = schema.node("doc", null, schema.node("paragraph", null, schema.text("Hello"))); + const state = WysiwygState.create({ + doc, + selection: TextSelection.create(doc, 1, 6), + }); + useEditorStore.getState().setActiveWysiwygEditor({ state, isDestroyed: false } as never, "tab-1"); + + expect(resolveEditorSpeechInput("main", "selection")).toEqual({ + tabId: "tab-1", + ranges: [{ text: "Hello", from: 1, to: 6 }], + }); + }); + + it("rejects an editor still bound to a stale tab", () => { + seedDocument("tab-current"); + const state = SourceState.create({ doc: "wrong document" }); + useUIStore.setState({ sourceMode: true }); + useEditorStore.getState().setActiveSourceView({ state } as never, "tab-old"); + + expect(resolveEditorSpeechInput("main", "document")).toBeNull(); + }); + + it("rejects a destroyed WYSIWYG editor", () => { + seedDocument("tab-1"); + useEditorStore + .getState() + .setActiveWysiwygEditor({ isDestroyed: true, state: {} } as never, "tab-1"); + expect(resolveEditorSpeechInput("main", "document")).toBeNull(); + }); +}); diff --git a/src/features/readAloud/editorInput.ts b/src/features/readAloud/editorInput.ts new file mode 100644 index 000000000..ff85413f4 --- /dev/null +++ b/src/features/readAloud/editorInput.ts @@ -0,0 +1,27 @@ +import { useEditorStore } from "@/stores/editorStore"; +import { useTabStore } from "@/stores/tabStore"; +import { isEffectiveSourceMode } from "@/services/editor/editorActionGates"; +import { extractSourceText, extractWysiwygText, type ReadAloudScope } from "./extraction"; +import type { ReadAloudInput } from "./readAloudService"; + +/** Resolve only the live editor belonging to the active document in this window. */ +export function resolveEditorSpeechInput( + windowLabel: string, + scope: ReadAloudScope, +): ReadAloudInput | null { + const tabStore = useTabStore.getState(); + const tabId = tabStore.activeTabId[windowLabel] ?? null; + const tab = tabId ? tabStore.findTabById(tabId) : null; + if (!tabId || tab?.kind !== "document") return null; + + const { active } = useEditorStore.getState(); + if (isEffectiveSourceMode(windowLabel)) { + const view = active.activeSourceView; + if (!view || active.activeSourceTabId !== tabId) return null; + return { tabId, ranges: extractSourceText(view.state, scope) }; + } + + const editor = active.activeWysiwygEditor; + if (!editor || editor.isDestroyed || active.activeWysiwygTabId !== tabId) return null; + return { tabId, ranges: extractWysiwygText(editor.state, scope) }; +} diff --git a/src/features/readAloud/extraction.test.ts b/src/features/readAloud/extraction.test.ts new file mode 100644 index 000000000..b6ded2c70 --- /dev/null +++ b/src/features/readAloud/extraction.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment node + +import { EditorSelection, EditorState as SourceState } from "@codemirror/state"; +import StarterKit from "@tiptap/starter-kit"; +import { getSchema } from "@tiptap/core"; +import { EditorState as WysiwygState, TextSelection } from "@tiptap/pm/state"; +import { describe, expect, it } from "vitest"; +import { extractSourceText, extractWysiwygText } from "./extraction"; + +describe("extractSourceText", () => { + const text = "第一行\nSecond line\n最后一行"; + + it("extracts every non-empty selection without merging gaps", () => { + const state = SourceState.create({ + doc: text, + selection: EditorSelection.create([ + EditorSelection.range(0, 3), + EditorSelection.range(4, 10), + ]), + extensions: [EditorStateAllowMultipleSelections.of(true)], + }); + + expect(extractSourceText(state, "selection")).toEqual([ + { text: "第一行", from: 0, to: 3 }, + { text: "Second", from: 4, to: 10 }, + ]); + }); + + it("returns no selection for a caret", () => { + const state = SourceState.create({ doc: text, selection: { anchor: 2 } }); + expect(extractSourceText(state, "selection")).toEqual([]); + }); + + it("extracts the current line, from caret, and whole document", () => { + const state = SourceState.create({ doc: text, selection: { anchor: 6 } }); + + expect(extractSourceText(state, "block")).toEqual([ + { text: "Second line", from: 4, to: 15 }, + ]); + expect(extractSourceText(state, "fromCursor")).toEqual([ + { text: "cond line\n最后一行", from: 6, to: text.length }, + ]); + expect(extractSourceText(state, "document")).toEqual([ + { text, from: 0, to: text.length }, + ]); + }); +}); + +const EditorStateAllowMultipleSelections = SourceState.allowMultipleSelections; + +describe("extractWysiwygText", () => { + const schema = getSchema([StarterKit]); + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, schema.text("Alpha")), + schema.node("paragraph", null, schema.text("中文 Beta")), + ]); + + it("extracts an explicit selection", () => { + const state = WysiwygState.create({ + doc, + selection: TextSelection.create(doc, 1, 6), + }); + expect(extractWysiwygText(state, "selection")).toEqual([ + { text: "Alpha", from: 1, to: 6 }, + ]); + }); + + it("returns no selection for a caret", () => { + const state = WysiwygState.create({ + doc, + selection: TextSelection.create(doc, 2), + }); + expect(extractWysiwygText(state, "selection")).toEqual([]); + }); + + it("extracts the nearest text block at the caret", () => { + const state = WysiwygState.create({ + doc, + selection: TextSelection.create(doc, 10), + }); + expect(extractWysiwygText(state, "block")).toEqual([ + { text: "中文 Beta", from: 8, to: 15 }, + ]); + }); + + it("extracts from the caret and separates blocks for speech", () => { + const state = WysiwygState.create({ + doc, + selection: TextSelection.create(doc, 3), + }); + expect(extractWysiwygText(state, "fromCursor")).toEqual([ + { text: "pha\n中文 Beta", from: 3, to: doc.content.size }, + ]); + expect(extractWysiwygText(state, "document")).toEqual([ + { text: "Alpha\n中文 Beta", from: 0, to: doc.content.size }, + ]); + }); +}); diff --git a/src/features/readAloud/extraction.ts b/src/features/readAloud/extraction.ts new file mode 100644 index 000000000..8fc221b20 --- /dev/null +++ b/src/features/readAloud/extraction.ts @@ -0,0 +1,67 @@ +import type { EditorState as SourceState } from "@codemirror/state"; +import type { EditorState as WysiwygState } from "@tiptap/pm/state"; + +export type ReadAloudScope = "selection" | "block" | "fromCursor" | "document"; + +export interface SpeechTextRange { + text: string; + from: number; + to: number; +} + +function sourceRange(state: SourceState, from: number, to: number): SpeechTextRange[] { + if (from === to) return []; + return [{ text: state.doc.sliceString(from, to), from, to }]; +} + +export function extractSourceText( + state: SourceState, + scope: ReadAloudScope, +): SpeechTextRange[] { + const { main } = state.selection; + switch (scope) { + case "selection": + return state.selection.ranges.flatMap(({ from, to }) => sourceRange(state, from, to)); + case "block": { + const line = state.doc.lineAt(main.head); + return sourceRange(state, line.from, line.to); + } + case "fromCursor": + return sourceRange(state, main.head, state.doc.length); + case "document": + return sourceRange(state, 0, state.doc.length); + } +} + +function wysiwygRange( + state: WysiwygState, + from: number, + to: number, +): SpeechTextRange[] { + if (from === to) return []; + return [{ text: state.doc.textBetween(from, to, "\n", " "), from, to }]; +} + +export function extractWysiwygText( + state: WysiwygState, + scope: ReadAloudScope, +): SpeechTextRange[] { + const { selection } = state; + switch (scope) { + case "selection": + return selection.empty ? [] : wysiwygRange(state, selection.from, selection.to); + case "block": { + const { $head } = selection; + for (let depth = $head.depth; depth > 0; depth -= 1) { + if ($head.node(depth).isTextblock) { + return wysiwygRange(state, $head.start(depth), $head.end(depth)); + } + } + return []; + } + case "fromCursor": + return wysiwygRange(state, selection.head, state.doc.content.size); + case "document": + return wysiwygRange(state, 0, state.doc.content.size); + } +} diff --git a/src/features/readAloud/nativeSpeechProvider.test.ts b/src/features/readAloud/nativeSpeechProvider.test.ts new file mode 100644 index 000000000..ade53ae12 --- /dev/null +++ b/src/features/readAloud/nativeSpeechProvider.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import { + NativeSpeechProvider, + type NativeSpeechEvent, + type NativeTtsApi, +} from "./nativeSpeechProvider"; +import { SpeechPlaybackCancelledError } from "./sessionController"; + +function deferred() { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function apiHarness() { + const listeners = new Map void>(); + const started = deferred(); + const api: NativeTtsApi = { + speak: vi.fn(() => started.promise), + stop: vi.fn(async () => undefined), + onSpeechEvent: vi.fn(async (type, callback) => { + listeners.set(type, callback); + return vi.fn(); + }), + }; + return { api, listeners, started }; +} + +describe("NativeSpeechProvider", () => { + it("waits for finish instead of resolving when native playback starts", async () => { + const { api, listeners, started } = apiHarness(); + const provider = new NativeSpeechProvider(api); + let settled = false; + + const speech = provider + .speak("你好", { voiceId: "voice-1", language: "zh-CN", rate: 1.2 }) + .then(() => { + settled = true; + }); + await vi.waitFor(() => expect(api.speak).toHaveBeenCalledTimes(1)); + expect(api.speak).toHaveBeenCalledWith({ + text: "你好", + voiceId: "voice-1", + language: "zh-CN", + rate: 1.2, + pitch: null, + volume: null, + queueMode: "flush", + }); + started.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + + listeners.get("speech:finish")?.({}); + await speech; + expect(settled).toBe(true); + }); + + it("terminates the queue when another native utterance cancels this one", async () => { + const { api, listeners, started } = apiHarness(); + const provider = new NativeSpeechProvider(api); + const speech = provider.speak("text"); + await vi.waitFor(() => expect(api.speak).toHaveBeenCalledTimes(1)); + started.resolve(); + listeners.get("speech:cancel")?.({ interrupted: true }); + await expect(speech).rejects.toBeInstanceOf(SpeechPlaybackCancelledError); + }); + + it("surfaces native speech errors with their message", async () => { + const { api, listeners, started } = apiHarness(); + const provider = new NativeSpeechProvider(api); + const speech = provider.speak("text"); + await vi.waitFor(() => expect(api.speak).toHaveBeenCalledTimes(1)); + started.resolve(); + listeners.get("speech:error")?.({ error: "voice unavailable" }); + await expect(speech).rejects.toThrow("voice unavailable"); + }); + + it("settles pending speech even if stop emits no cancellation event", async () => { + const { api, started } = apiHarness(); + const provider = new NativeSpeechProvider(api); + const speech = provider.speak("text"); + await vi.waitFor(() => expect(api.speak).toHaveBeenCalledTimes(1)); + started.resolve(); + + await provider.stop(); + await expect(speech).resolves.toBeUndefined(); + expect(api.stop).toHaveBeenCalledTimes(1); + }); + + it("rejects an overlapping direct provider call", async () => { + const { api } = apiHarness(); + const provider = new NativeSpeechProvider(api); + void provider.speak("first"); + await vi.waitFor(() => expect(api.speak).toHaveBeenCalledTimes(1)); + + await expect(provider.speak("second")).rejects.toThrow("already active"); + }); + + it("ignores broadcast events belonging to another window utterance", async () => { + const { api, listeners, started } = apiHarness(); + const provider = new NativeSpeechProvider(api); + let settled = false; + const speech = provider.speak("new window speech").then(() => { + settled = true; + }); + await vi.waitFor(() => expect(api.speak).toHaveBeenCalledTimes(1)); + + listeners.get("speech:cancel")?.({ id: "old-window" }); + listeners.get("speech:start")?.({ id: "this-window" }); + started.resolve(); + listeners.get("speech:finish")?.({ id: "other-window" }); + for (let index = 0; index < 5; index += 1) await Promise.resolve(); + expect(settled).toBe(false); + + listeners.get("speech:finish")?.({ id: "this-window" }); + await speech; + expect(settled).toBe(true); + }); +}); diff --git a/src/features/readAloud/nativeSpeechProvider.ts b/src/features/readAloud/nativeSpeechProvider.ts new file mode 100644 index 000000000..6b143e05b --- /dev/null +++ b/src/features/readAloud/nativeSpeechProvider.ts @@ -0,0 +1,130 @@ +import { + onSpeechEvent, + speak as nativeSpeak, + stop as nativeStop, + type SpeechEvent, + type SpeechEventType, + type SpeakOptions, +} from "tauri-plugin-tts-api"; +import { + SpeechPlaybackCancelledError, + type SpeechPlaybackOptions, + type SpeechPlaybackProvider, +} from "./sessionController"; + +export type NativeSpeechEvent = SpeechEvent; + +export interface NativeTtsApi { + speak(options: SpeakOptions): Promise; + stop(): Promise; + onSpeechEvent( + type: SpeechEventType, + callback: (event: NativeSpeechEvent) => void, + ): Promise<() => void>; +} + +const defaultApi: NativeTtsApi = { + speak: nativeSpeak, + stop: nativeStop, + onSpeechEvent, +}; + +interface PendingSpeech { + id?: string; + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; +} + +function pendingSpeech(): PendingSpeech { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { promise, resolve, reject }; +} + +/** Converts the native plugin's event-based lifecycle into one awaitable utterance. */ +export class NativeSpeechProvider implements SpeechPlaybackProvider { + private pending: PendingSpeech | null = null; + private readonly listenersReady: Promise; + + constructor(private readonly api: NativeTtsApi = defaultApi) { + this.listenersReady = this.registerListeners(); + } + + private async registerListeners(): Promise { + await Promise.all([ + this.api.onSpeechEvent("speech:start", (event) => { + if (this.pending && event.id) this.pending.id = event.id; + }), + this.api.onSpeechEvent("speech:finish", (event) => this.finish(event)), + this.api.onSpeechEvent("speech:cancel", (event) => this.cancel(event)), + this.api.onSpeechEvent("speech:error", (event) => this.fail(event, event.error)), + this.api.onSpeechEvent("speech:interrupted", (event) => + this.fail(event, event.error ?? event.reason), + ), + ]); + } + + private eventBelongsToPending(event: NativeSpeechEvent): boolean { + return !event.id || this.pending?.id === event.id; + } + + private finish(event: NativeSpeechEvent): void { + if (!this.eventBelongsToPending(event)) return; + const pending = this.pending; + this.pending = null; + pending?.resolve(); + } + + private fail(event: NativeSpeechEvent, message?: string): void { + if (!this.eventBelongsToPending(event)) return; + const pending = this.pending; + this.pending = null; + pending?.reject(new Error(message || "Native speech playback failed")); + } + + private cancel(event: NativeSpeechEvent): void { + if (!this.eventBelongsToPending(event)) return; + const pending = this.pending; + this.pending = null; + pending?.reject(new SpeechPlaybackCancelledError()); + } + + async speak(text: string, options: SpeechPlaybackOptions = {}): Promise { + if (this.pending) throw new Error("A native speech utterance is already active"); + await this.listenersReady; + if (this.pending) throw new Error("A native speech utterance is already active"); + + const pending = pendingSpeech(); + this.pending = pending; + try { + await this.api.speak({ + text, + voiceId: options.voiceId ?? null, + language: options.language ?? null, + rate: options.rate ?? null, + pitch: options.pitch ?? null, + volume: options.volume ?? null, + queueMode: "flush", + }); + } catch (error) { + if (this.pending === pending) this.pending = null; + throw error; + } + return pending.promise; + } + + async stop(): Promise { + const pending = this.pending; + try { + await this.api.stop(); + } finally { + if (this.pending === pending) this.pending = null; + pending?.resolve(); + } + } +} diff --git a/src/features/readAloud/readAloudService.test.ts b/src/features/readAloud/readAloudService.test.ts new file mode 100644 index 000000000..96ffe15b2 --- /dev/null +++ b/src/features/readAloud/readAloudService.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import type { SpeechPlaybackProvider } from "./sessionController"; +import { ReadAloudService, type ReadAloudInputResolver } from "./readAloudService"; +import { useSettingsStore } from "@/stores/settingsStore"; + +function createHarness(input: ReturnType) { + const provider: SpeechPlaybackProvider = { + speak: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + }; + const resolver: ReadAloudInputResolver = vi.fn(() => input); + return { provider, resolver, service: new ReadAloudService(provider, resolver) }; +} + +describe("ReadAloudService", () => { + it("does not start when read aloud is disabled in integrations settings", async () => { + useSettingsStore.setState({ speech: { enabled: false } }); + const { provider, service } = createHarness({ tabId: "tab-1", ranges: [{ text: "Hello.", from: 0, to: 6 }] }); + await expect(service.start("main", "document")).resolves.toBe(false); + expect(provider.speak).not.toHaveBeenCalled(); + useSettingsStore.setState({ speech: { enabled: true } }); + }); + it("does not start playback without an active readable range", async () => { + const { provider, service } = createHarness(null); + await expect(service.start("main", "selection")).resolves.toBe(false); + expect(provider.speak).not.toHaveBeenCalled(); + }); + + it("filters whitespace-only and punctuation-only ranges", async () => { + const { provider, service } = createHarness({ + tabId: "tab-1", + ranges: [ + { text: " ", from: 0, to: 2 }, + { text: "……!?", from: 3, to: 7 }, + ], + }); + await expect(service.start("main", "document")).resolves.toBe(false); + expect(provider.speak).not.toHaveBeenCalled(); + }); + + it("segments multiple ranges and plays them in reading order", async () => { + const { provider, resolver, service } = createHarness({ + tabId: "tab-1", + ranges: [ + { text: "第一句。第二句!", from: 10, to: 18 }, + { text: "Last.", from: 30, to: 35 }, + ], + }); + + await expect(service.start("doc-window", "selection", { rate: 1.25 })).resolves.toBe(true); + + expect(resolver).toHaveBeenCalledWith("doc-window", "selection"); + expect(provider.speak).toHaveBeenNthCalledWith(1, "第一句。", { rate: 1.25 }); + expect(provider.speak).toHaveBeenNthCalledWith(2, "第二句!", { rate: 1.25 }); + expect(provider.speak).toHaveBeenNthCalledWith(3, "Last.", { rate: 1.25 }); + }); + + it("exposes stop without resolving editor input again", async () => { + const { provider, resolver, service } = createHarness(null); + await service.stop(); + expect(provider.stop).not.toHaveBeenCalled(); + expect(resolver).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/readAloud/readAloudService.ts b/src/features/readAloud/readAloudService.ts new file mode 100644 index 000000000..6a430a094 --- /dev/null +++ b/src/features/readAloud/readAloudService.ts @@ -0,0 +1,78 @@ +import { buildSpeechSegments } from "./segmenter"; +import { + ReadAloudSessionController, + type SpeechPlaybackOptions, + type SpeechPlaybackProvider, +} from "./sessionController"; +import type { ReadAloudScope, SpeechTextRange } from "./extraction"; +import type { ReadAloudState, SpeechSegment } from "./types"; +import { useSettingsStore } from "@/stores/settingsStore"; + +export interface ReadAloudInput { + tabId: string; + ranges: readonly SpeechTextRange[]; +} + +export type ReadAloudInputResolver = ( + windowLabel: string, + scope: ReadAloudScope, +) => ReadAloudInput | null; + +function segmentRanges(ranges: readonly SpeechTextRange[]): SpeechSegment[] { + let nextId = 0; + return ranges.flatMap((range) => + buildSpeechSegments(range.text).map((segment) => ({ + ...segment, + id: `speech-${nextId++}`, + from: range.from + segment.from, + to: range.from + segment.to, + })), + ); +} + +/** Application-level orchestration independent from editor and native API details. */ +export class ReadAloudService { + private readonly controller: ReadAloudSessionController; + + constructor( + provider: SpeechPlaybackProvider, + private readonly resolveInput: ReadAloudInputResolver, + onStateChange?: (state: ReadAloudState) => void, + ) { + this.controller = new ReadAloudSessionController(provider, onStateChange); + } + + getState(): ReadAloudState { + return this.controller.getState(); + } + + async start( + windowLabel: string, + scope: ReadAloudScope, + options?: SpeechPlaybackOptions, + ): Promise { + if (!useSettingsStore.getState().speech.enabled) return false; + const input = this.resolveInput(windowLabel, scope); + if (!input) return false; + const segments = segmentRanges(input.ranges); + if (segments.length === 0) return false; + await this.controller.start({ + tabId: input.tabId, + segments, + ...(options ? { options } : {}), + }); + return true; + } + + stop(): Promise { + return this.controller.stop(); + } + + pause(): Promise { + return this.controller.pause(); + } + + resume(): Promise { + return this.controller.resume(); + } +} diff --git a/src/features/readAloud/readAloudStore.ts b/src/features/readAloud/readAloudStore.ts new file mode 100644 index 000000000..98a33be0d --- /dev/null +++ b/src/features/readAloud/readAloudStore.ts @@ -0,0 +1,17 @@ +import { create } from "zustand"; +import type { ReadAloudState } from "./types"; + +interface ReadAloudStore extends ReadAloudState { + applyState: (state: ReadAloudState) => void; +} + +export const useReadAloudStore = create((set) => ({ + status: "idle", + sessionId: null, + tabId: null, + index: 0, + total: 0, + current: null, + error: null, + applyState: (state) => set(state), +})); diff --git a/src/features/readAloud/runtime.ts b/src/features/readAloud/runtime.ts new file mode 100644 index 000000000..1e4a07e68 --- /dev/null +++ b/src/features/readAloud/runtime.ts @@ -0,0 +1,35 @@ +import { NativeSpeechProvider } from "./nativeSpeechProvider"; +import { ReadAloudService } from "./readAloudService"; +import { resolveEditorSpeechInput } from "./editorInput"; +import { useReadAloudStore } from "./readAloudStore"; +import type { ReadAloudScope } from "./extraction"; + +let service: ReadAloudService | null = null; + +function getService(): ReadAloudService { + service ??= new ReadAloudService( + new NativeSpeechProvider(), + resolveEditorSpeechInput, + (state) => useReadAloudStore.getState().applyState(state), + ); + return service; +} + +export function startReadAloud( + windowLabel: string, + scope: ReadAloudScope, +): Promise { + return getService().start(windowLabel, scope); +} + +export function stopReadAloud(): Promise { + return service?.stop() ?? Promise.resolve(); +} + +export function pauseReadAloud(): Promise { + return service?.pause() ?? Promise.resolve(); +} + +export function resumeReadAloud(): Promise { + return service?.resume() ?? Promise.resolve(); +} diff --git a/src/features/readAloud/segmenter.test.ts b/src/features/readAloud/segmenter.test.ts new file mode 100644 index 000000000..794c13d5c --- /dev/null +++ b/src/features/readAloud/segmenter.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildSpeechSegments } from "./segmenter"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("buildSpeechSegments", () => { + it("returns no segments for empty, whitespace-only, or punctuation-only input", () => { + expect(buildSpeechSegments("", { locale: "zh-CN" })).toEqual([]); + expect(buildSpeechSegments(" \n\t ", { locale: "zh-CN" })).toEqual([]); + expect(buildSpeechSegments("……!?", { locale: "zh-CN" })).toEqual([]); + }); + + it("splits mixed CJK and Latin prose at sentence boundaries", () => { + const segments = buildSpeechSegments( + "第一句。第二句! Hello world. Last one?", + { locale: "zh-CN" }, + ); + + expect(segments.map((segment) => segment.text)).toEqual([ + "第一句。", + "第二句!", + "Hello world.", + "Last one?", + ]); + }); + + it("preserves source offsets after trimming surrounding whitespace", () => { + const input = " Alpha.\n\n Beta。 "; + const segments = buildSpeechSegments(input, { locale: "en" }); + + expect(segments).toMatchObject([ + { text: "Alpha.", from: 2, to: 8 }, + { text: "Beta。", from: 12, to: 17 }, + ]); + expect(input.slice(segments[1].from, segments[1].to)).toBe("Beta。"); + }); + + it("never cuts an emoji grapheme while enforcing the UTF-8 byte limit", () => { + const input = "你好👩🏽‍💻世界".repeat(20); + const segments = buildSpeechSegments(input, { + locale: "zh-CN", + maxBytes: 48, + }); + + expect(segments.length).toBeGreaterThan(1); + expect(segments.every((segment) => new TextEncoder().encode(segment.text).length <= 48)).toBe(true); + expect(segments.map((segment) => segment.text).join("")).toBe(input); + expect(segments.every((segment) => !segment.text.includes("�"))).toBe(true); + }); + + it("keeps decimals together in the fallback segmenter", () => { + const segments = buildSpeechSegments("Version 1.25 is ready. Next.", { + locale: "en", + forceFallback: true, + }); + + expect(segments.map((segment) => segment.text)).toEqual([ + "Version 1.25 is ready.", + "Next.", + ]); + }); + + it("keeps joined emoji intact when Intl.Segmenter is unavailable", () => { + vi.stubGlobal("Intl", { ...Intl, Segmenter: undefined }); + const segments = buildSpeechSegments("A👩🏽‍💻B", { + maxBytes: 15, + forceFallback: true, + }); + + expect(segments.map((segment) => segment.text)).toEqual(["A", "👩🏽‍💻", "B"]); + }); +}); diff --git a/src/features/readAloud/segmenter.ts b/src/features/readAloud/segmenter.ts new file mode 100644 index 000000000..4b7e9a4e9 --- /dev/null +++ b/src/features/readAloud/segmenter.ts @@ -0,0 +1,145 @@ +import type { SpeechSegment } from "./types"; + +const DEFAULT_MAX_BYTES = 2_400; +const READABLE_CHARACTER = /[\p{L}\p{N}\p{Extended_Pictographic}]/u; +const SENTENCE_END = /[。!?!?;;]/u; +const MARK = /\p{Mark}/u; +const EMOJI_MODIFIER = /\p{Emoji_Modifier}/u; +const VARIATION_SELECTOR = /[\uFE00-\uFE0F]/u; +const SUPPLEMENTARY_VARIATION_SELECTOR = /[\u{E0100}-\u{E01EF}]/u; +const encoder = new TextEncoder(); + +interface SegmentOptions { + locale?: string; + maxBytes?: number; + /** Deterministic seam for testing runtimes without Intl.Segmenter. */ + forceFallback?: boolean; +} + +interface TextRange { + from: number; + to: number; +} + +function trimRange(input: string, range: TextRange): TextRange | null { + let { from, to } = range; + while (from < to && /\s/u.test(input[from])) from += 1; + while (to > from && /\s/u.test(input[to - 1])) to -= 1; + return from < to ? { from, to } : null; +} + +function fallbackSentences(input: string): TextRange[] { + const ranges: TextRange[] = []; + let start = 0; + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + const decimalPoint = + char === "." && /\d/u.test(input[index - 1] ?? "") && /\d/u.test(input[index + 1] ?? ""); + if (decimalPoint || !(char === "." || SENTENCE_END.test(char))) continue; + let end = index + 1; + while (end < input.length && (SENTENCE_END.test(input[end]) || input[end] === ".")) end += 1; + ranges.push({ from: start, to: end }); + start = end; + } + if (start < input.length) ranges.push({ from: start, to: input.length }); + return ranges; +} + +function sentenceRanges(input: string, options: SegmentOptions): TextRange[] { + if (!options.forceFallback && typeof Intl.Segmenter === "function") { + const segmenter = new Intl.Segmenter(options.locale, { granularity: "sentence" }); + return [...segmenter.segment(input)].map(({ index, segment }) => ({ + from: index, + to: index + segment.length, + })); + } + return fallbackSentences(input); +} + +function graphemeRanges(input: string, range: TextRange, locale?: string): TextRange[] { + if (typeof Intl.Segmenter !== "function") { + const ranges: TextRange[] = []; + let cursor = range.from; + while (cursor < range.to) { + const from = cursor; + const first = String.fromCodePoint(input.codePointAt(cursor)!); + cursor += first.length; + + // Regional indicators form flag pairs. + if (/\p{Regional_Indicator}/u.test(first) && cursor < range.to) { + const next = String.fromCodePoint(input.codePointAt(cursor)!); + if (/\p{Regional_Indicator}/u.test(next)) cursor += next.length; + } + + while (cursor < range.to) { + const next = String.fromCodePoint(input.codePointAt(cursor)!); + if ( + MARK.test(next) || + EMOJI_MODIFIER.test(next) || + VARIATION_SELECTOR.test(next) || + SUPPLEMENTARY_VARIATION_SELECTOR.test(next) + ) { + cursor += next.length; + continue; + } + if (next === "\u200D") { + cursor += next.length; + if (cursor < range.to) { + cursor += String.fromCodePoint(input.codePointAt(cursor)!).length; + } + continue; + } + break; + } + ranges.push({ from, to: cursor }); + } + return ranges; + } + const segmenter = new Intl.Segmenter(locale, { granularity: "grapheme" }); + return [...segmenter.segment(input.slice(range.from, range.to))].map(({ index, segment }) => ({ + from: range.from + index, + to: range.from + index + segment.length, + })); +} + +function splitToByteLimit( + input: string, + range: TextRange, + maxBytes: number, + locale?: string, +): TextRange[] { + if (encoder.encode(input.slice(range.from, range.to)).length <= maxBytes) return [range]; + const result: TextRange[] = []; + let chunkStart = range.from; + let chunkEnd = range.from; + let bytes = 0; + for (const grapheme of graphemeRanges(input, range, locale)) { + const nextBytes = encoder.encode(input.slice(grapheme.from, grapheme.to)).length; + if (bytes > 0 && bytes + nextBytes > maxBytes) { + result.push({ from: chunkStart, to: chunkEnd }); + chunkStart = grapheme.from; + bytes = 0; + } + chunkEnd = grapheme.to; + bytes += nextBytes; + } + if (chunkEnd > chunkStart) result.push({ from: chunkStart, to: chunkEnd }); + return result; +} + +/** Split readable text into offset-preserving, provider-safe utterances. */ +export function buildSpeechSegments( + input: string, + options: SegmentOptions = {}, +): SpeechSegment[] { + const maxBytes = Math.max(4, options.maxBytes ?? DEFAULT_MAX_BYTES); + const ranges = sentenceRanges(input, options) + .map((range) => trimRange(input, range)) + .filter((range): range is TextRange => range !== null) + .flatMap((range) => splitToByteLimit(input, range, maxBytes, options.locale)); + + return ranges + .map((range) => ({ ...range, text: input.slice(range.from, range.to) })) + .filter((segment) => READABLE_CHARACTER.test(segment.text)) + .map((segment, index) => ({ ...segment, id: `speech-${index}` })); +} diff --git a/src/features/readAloud/sessionController.test.ts b/src/features/readAloud/sessionController.test.ts new file mode 100644 index 000000000..ad29ce190 --- /dev/null +++ b/src/features/readAloud/sessionController.test.ts @@ -0,0 +1,188 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import { + ReadAloudSessionController, + SpeechPlaybackCancelledError, + type SpeechPlaybackProvider, +} from "./sessionController"; + +function deferred() { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function providerHarness() { + const utterances: ReturnType[] = []; + const provider: SpeechPlaybackProvider = { + speak: vi.fn(() => { + const utterance = deferred(); + utterances.push(utterance); + return utterance.promise; + }), + stop: vi.fn(async () => undefined), + }; + return { provider, utterances }; +} + +const segments = [ + { id: "s1", text: "One.", from: 0, to: 4 }, + { id: "s2", text: "Two.", from: 5, to: 9 }, +]; + +describe("ReadAloudSessionController", () => { + it("plays segments sequentially and reports progress", async () => { + const { provider, utterances } = providerHarness(); + const states: string[] = []; + const controller = new ReadAloudSessionController(provider, (state) => { + states.push(`${state.status}:${state.index}`); + }); + + const run = controller.start({ tabId: "tab-1", segments }); + await vi.waitFor(() => expect(provider.speak).toHaveBeenCalledTimes(1)); + utterances[0].resolve(); + await vi.waitFor(() => expect(provider.speak).toHaveBeenCalledTimes(2)); + utterances[1].resolve(); + await run; + + expect(states).toContain("playing:0"); + expect(states).toContain("playing:1"); + expect(controller.getState()).toMatchObject({ status: "idle", index: 0 }); + }); + + it("stops the provider and ignores a late completion", async () => { + const { provider, utterances } = providerHarness(); + const controller = new ReadAloudSessionController(provider); + + const run = controller.start({ tabId: "tab-1", segments }); + await vi.waitFor(() => expect(provider.speak).toHaveBeenCalledTimes(1)); + await controller.stop(); + utterances[0].resolve(); + await run; + + expect(provider.stop).toHaveBeenCalledTimes(1); + expect(provider.speak).toHaveBeenCalledTimes(1); + expect(controller.getState().status).toBe("idle"); + }); + + it("atomically replaces a running session", async () => { + const { provider, utterances } = providerHarness(); + const controller = new ReadAloudSessionController(provider); + + const first = controller.start({ tabId: "old", segments }); + await vi.waitFor(() => expect(provider.speak).toHaveBeenCalledTimes(1)); + const second = controller.start({ + tabId: "new", + segments: [{ id: "new", text: "New.", from: 0, to: 4 }], + }); + await vi.waitFor(() => expect(provider.speak).toHaveBeenCalledTimes(2)); + + utterances[0].resolve(); + utterances[1].resolve(); + await Promise.all([first, second]); + + expect(provider.stop).toHaveBeenCalledTimes(1); + expect(controller.getState().status).toBe("idle"); + }); + + it("surfaces provider errors without attempting the next segment", async () => { + const { provider, utterances } = providerHarness(); + const controller = new ReadAloudSessionController(provider); + + const run = controller.start({ tabId: "tab-1", segments }); + await vi.waitFor(() => expect(provider.speak).toHaveBeenCalledTimes(1)); + utterances[0].reject(new Error("engine unavailable")); + await run; + + expect(provider.speak).toHaveBeenCalledTimes(1); + expect(controller.getState()).toMatchObject({ + status: "error", + error: "engine unavailable", + }); + }); + + it("ends quietly when native playback is displaced by another window", async () => { + const provider: SpeechPlaybackProvider = { + speak: vi.fn(async () => { + throw new SpeechPlaybackCancelledError(); + }), + stop: vi.fn(async () => undefined), + }; + const controller = new ReadAloudSessionController(provider); + + await controller.start({ tabId: "tab-1", segments }); + + expect(provider.speak).toHaveBeenCalledTimes(1); + expect(controller.getState()).toMatchObject({ status: "idle", error: null }); + }); + + it("preserves messages from structured native errors", async () => { + const provider: SpeechPlaybackProvider = { + speak: vi.fn(async () => { + throw { code: "TTS_ENGINE_ERROR", message: "No speech engine" }; + }), + stop: vi.fn(async () => undefined), + }; + const controller = new ReadAloudSessionController(provider); + await controller.start({ tabId: "tab-1", segments }); + expect(controller.getState()).toMatchObject({ + status: "error", + error: "No speech engine", + }); + }); + + it("pauses by stopping the current sentence and resumes from that sentence", async () => { + const { provider, utterances } = providerHarness(); + const controller = new ReadAloudSessionController(provider); + + const firstRun = controller.start({ tabId: "tab-1", segments }); + await vi.waitFor(() => expect(provider.speak).toHaveBeenCalledTimes(1)); + await controller.pause(); + expect(controller.getState()).toMatchObject({ status: "paused", index: 0 }); + expect(provider.stop).toHaveBeenCalledTimes(1); + utterances[0].resolve(); + await firstRun; + + const resumed = controller.resume(); + await vi.waitFor(() => expect(provider.speak).toHaveBeenCalledTimes(2)); + expect(provider.speak).toHaveBeenNthCalledWith(2, "One.", undefined); + utterances[1].resolve(); + await vi.waitFor(() => expect(provider.speak).toHaveBeenCalledTimes(3)); + utterances[2].resolve(); + await resumed; + + expect(controller.getState().status).toBe("idle"); + }); + + it("ignores pause and resume while idle", async () => { + const { provider } = providerHarness(); + const controller = new ReadAloudSessionController(provider); + await controller.pause(); + await controller.resume(); + expect(provider.stop).not.toHaveBeenCalled(); + expect(provider.speak).not.toHaveBeenCalled(); + }); + + it("does not clear a newer session when an older stop completes late", async () => { + const { provider, utterances } = providerHarness(); + const stopping = deferred(); + vi.mocked(provider.stop).mockImplementationOnce(() => stopping.promise); + const controller = new ReadAloudSessionController(provider); + const first = controller.start({ tabId: "old", segments }); + const stop = controller.stop(); + const second = controller.start({ tabId: "new", segments: segments.slice(0, 1) }); + await vi.waitFor(() => expect(provider.speak).toHaveBeenCalledTimes(2)); + + stopping.resolve(); + await stop; + expect(controller.getState()).toMatchObject({ status: "playing", tabId: "new" }); + + utterances.forEach((utterance) => utterance.resolve()); + await Promise.all([first, second]); + }); +}); diff --git a/src/features/readAloud/sessionController.ts b/src/features/readAloud/sessionController.ts new file mode 100644 index 000000000..08e583d4c --- /dev/null +++ b/src/features/readAloud/sessionController.ts @@ -0,0 +1,159 @@ +import type { ReadAloudState, SpeechSegment } from "./types"; + +export interface SpeechPlaybackOptions { + voiceId?: string; + language?: string; + rate?: number; + pitch?: number; + volume?: number; +} + +export interface SpeechPlaybackProvider { + /** Resolves when the utterance finishes, rejects on synthesis/playback failure. */ + speak(text: string, options?: SpeechPlaybackOptions): Promise; + stop(): Promise; +} + +/** Control-flow signal emitted when native playback is displaced externally. */ +export class SpeechPlaybackCancelledError extends Error { + constructor() { + super("Native speech playback was cancelled"); + this.name = "SpeechPlaybackCancelledError"; + } +} + +interface StartRequest { + tabId: string; + segments: readonly SpeechSegment[]; + options?: SpeechPlaybackOptions; +} + +const IDLE_STATE: ReadAloudState = { + status: "idle", + sessionId: null, + tabId: null, + index: 0, + total: 0, + current: null, + error: null, +}; + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ) { + return error.message; + } + return String(error); +} + +/** Owns one application read-aloud queue and rejects stale async completions. */ +export class ReadAloudSessionController { + private generation = 0; + private state: ReadAloudState = IDLE_STATE; + private activeRequest: StartRequest | null = null; + + constructor( + private readonly provider: SpeechPlaybackProvider, + private readonly onStateChange: (state: ReadAloudState) => void = () => undefined, + ) {} + + getState(): ReadAloudState { + return this.state; + } + + private publish(state: ReadAloudState): void { + this.state = state; + this.onStateChange(state); + } + + async start(request: StartRequest): Promise { + const replacing = this.state.status !== "idle" && this.state.status !== "error"; + const generation = ++this.generation; + if (replacing) await this.provider.stop(); + if (generation !== this.generation) return; + + if (request.segments.length === 0) { + this.activeRequest = null; + this.publish(IDLE_STATE); + return; + } + + const sessionId = globalThis.crypto?.randomUUID?.() ?? `speech-${generation}`; + this.activeRequest = request; + this.publish({ + status: "preparing", + sessionId, + tabId: request.tabId, + index: 0, + total: request.segments.length, + current: request.segments[0] ?? null, + error: null, + }); + + await this.play(request, 0, generation); + } + + private async play(request: StartRequest, startIndex: number, generation: number): Promise { + for (let index = startIndex; index < request.segments.length; index += 1) { + if (generation !== this.generation) return; + const current = request.segments[index]; + this.publish({ ...this.state, status: "playing", index, current }); + try { + await this.provider.speak(current.text, request.options); + } catch (error) { + if (generation !== this.generation) return; + this.activeRequest = null; + if (error instanceof SpeechPlaybackCancelledError) { + this.publish(IDLE_STATE); + return; + } + this.publish({ ...this.state, status: "error", error: errorMessage(error) }); + return; + } + } + + if (generation === this.generation) { + this.activeRequest = null; + this.publish(IDLE_STATE); + } + } + + async pause(): Promise { + if (this.state.status !== "playing" && this.state.status !== "preparing") return; + const generation = ++this.generation; + this.publish({ ...this.state, status: "paused" }); + try { + await this.provider.stop(); + } catch (error) { + if (generation !== this.generation) return; + this.activeRequest = null; + this.publish({ ...this.state, status: "error", error: errorMessage(error) }); + } + } + + async resume(): Promise { + const request = this.activeRequest; + if (this.state.status !== "paused" || !request) return; + const generation = ++this.generation; + this.publish({ ...this.state, status: "playing", error: null }); + await this.play(request, this.state.index, generation); + } + + async stop(): Promise { + const wasActive = this.state.status !== "idle"; + const generation = ++this.generation; + if (!wasActive) return; + this.activeRequest = null; + this.publish({ ...this.state, status: "stopping" }); + try { + await this.provider.stop(); + } finally { + if (generation === this.generation) this.publish(IDLE_STATE); + } + } +} diff --git a/src/features/readAloud/types.ts b/src/features/readAloud/types.ts new file mode 100644 index 000000000..9675e8906 --- /dev/null +++ b/src/features/readAloud/types.ts @@ -0,0 +1,24 @@ +export interface SpeechSegment { + id: string; + text: string; + from: number; + to: number; +} + +type ReadAloudStatus = + | "idle" + | "preparing" + | "playing" + | "paused" + | "stopping" + | "error"; + +export interface ReadAloudState { + status: ReadAloudStatus; + sessionId: string | null; + tabId: string | null; + index: number; + total: number; + current: SpeechSegment | null; + error: string | null; +} diff --git a/src/locales/de/commands.json b/src/locales/de/commands.json index 08d22d1cd..4c77eb03a 100644 --- a/src/locales/de/commands.json +++ b/src/locales/de/commands.json @@ -70,6 +70,13 @@ "commandPalette.empty": "Keine passenden Befehle. Lösche die Suche, um alle Befehle zu sehen.", "aria.commandPalette": "Befehlspalette", "browser.newTab": "Neuer Browser-Tab", + "speech.readSelection": "Auswahl vorlesen", + "speech.readBlock": "Aktuellen Block vorlesen", + "speech.readFromCursor": "Ab Cursor vorlesen", + "speech.readDocument": "Dokument vorlesen", + "speech.stop": "Vorlesen beenden", + "speech.pause": "Vorlesen pausieren", + "speech.resume": "Vorlesen fortsetzen", "claims.extractFromSelection": "Aussage aus Auswahl extrahieren", "category.app": "Anwendung", "category.file": "Datei", diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index 6819f06e7..e902803fd 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -331,6 +331,9 @@ "files.pandoc.installHint": "Pandoc installieren, um nach DOCX, EPUB, LaTeX und mehr zu exportieren.", "files.pandoc.installGuide": "Installationsanleitung", "files.pandoc.path": "Pfad", + "integrations.group.readAloud": "Vorlesen", + "integrations.readAloud.label": "Vorlesen aktivieren", + "integrations.readAloud.description": "Dokumenttext mit der Systemstimme vorlesen", "integrations.group.mcp": "MCP-Server", "integrations.enableMcp.label": "MCP-Server aktivieren", "integrations.enableMcp.description": "KI-Assistenten erlauben, den VMark-Editor zu steuern", diff --git a/src/locales/de/statusbar.json b/src/locales/de/statusbar.json index cba511694..4d07c0c4c 100644 --- a/src/locales/de/statusbar.json +++ b/src/locales/de/statusbar.json @@ -7,6 +7,8 @@ "mcpStatus": "MCP-Status", "mcpError": "MCP-Fehler: {{error}}", "mcpStarting": "MCP wird gestartet...", + "readAloudProgress": "Vorlesen, Satz {{current}} von {{total}}", + "readAloudError": "Fehler beim Vorlesen", "mcpStopped": "MCP gestoppt · Klicken zum Starten", "mcpNoClients": "MCP bereit · Keine KI verbunden", "mcpConnected": "Verbunden: {{clients}}", diff --git a/src/locales/en/commands.json b/src/locales/en/commands.json index 966f7c8f1..a15dc2a12 100644 --- a/src/locales/en/commands.json +++ b/src/locales/en/commands.json @@ -70,6 +70,13 @@ "commandPalette.empty": "No matching commands. Clear the search to see every command.", "aria.commandPalette": "Command palette", "browser.newTab": "New Browser Tab", + "speech.readSelection": "Read Selection Aloud", + "speech.readBlock": "Read Current Block Aloud", + "speech.readFromCursor": "Read Aloud from Cursor", + "speech.readDocument": "Read Document Aloud", + "speech.stop": "Stop Reading Aloud", + "speech.pause": "Pause Reading Aloud", + "speech.resume": "Resume Reading Aloud", "claims.extractFromSelection": "Extract Claim from Selection", "category.app": "Application", "category.file": "File", diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 37f010bbf..1361bbd0a 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -331,6 +331,9 @@ "files.pandoc.installHint": "Install Pandoc to export to DOCX, EPUB, LaTeX, and more.", "files.pandoc.installGuide": "Installation guide", "files.pandoc.path": "Path", + "integrations.group.readAloud": "Read Aloud", + "integrations.readAloud.label": "Enable read aloud", + "integrations.readAloud.description": "Allow VMark to speak document text using the system voice", "integrations.group.mcp": "MCP Server", "integrations.enableMcp.label": "Enable MCP Server", "integrations.enableMcp.description": "Allow AI assistants to control VMark editor", diff --git a/src/locales/en/statusbar.json b/src/locales/en/statusbar.json index 3977b0483..d8bfc9e0c 100644 --- a/src/locales/en/statusbar.json +++ b/src/locales/en/statusbar.json @@ -7,6 +7,8 @@ "mcpStatus": "MCP Status", "mcpError": "MCP error: {{error}}", "mcpStarting": "MCP starting…", + "readAloudProgress": "Reading aloud, sentence {{current}} of {{total}}", + "readAloudError": "Read-aloud error", "mcpStopped": "MCP stopped · Click to start", "mcpNoClients": "MCP ready · No AI connected", "mcpConnected": "Connected: {{clients}}", diff --git a/src/locales/es/commands.json b/src/locales/es/commands.json index 73b6fbdf7..41a6da4a9 100644 --- a/src/locales/es/commands.json +++ b/src/locales/es/commands.json @@ -70,6 +70,13 @@ "commandPalette.empty": "No hay comandos coincidentes. Borra la búsqueda para verlos todos.", "aria.commandPalette": "Paleta de comandos", "browser.newTab": "Nueva pestaña del navegador", + "speech.readSelection": "Leer selección en voz alta", + "speech.readBlock": "Leer bloque actual en voz alta", + "speech.readFromCursor": "Leer en voz alta desde el cursor", + "speech.readDocument": "Leer documento en voz alta", + "speech.stop": "Detener lectura en voz alta", + "speech.pause": "Pausar lectura en voz alta", + "speech.resume": "Reanudar lectura en voz alta", "claims.extractFromSelection": "Extraer afirmación de la selección", "category.app": "Aplicación", "category.file": "Archivo", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index ca140d450..b6287e896 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -331,6 +331,9 @@ "files.pandoc.installHint": "Instala Pandoc para exportar a DOCX, EPUB, LaTeX y más.", "files.pandoc.installGuide": "Guía de instalación", "files.pandoc.path": "Ruta", + "integrations.group.readAloud": "Lectura en voz alta", + "integrations.readAloud.label": "Activar lectura en voz alta", + "integrations.readAloud.description": "Leer el texto del documento con la voz del sistema", "integrations.group.mcp": "Servidor MCP", "integrations.enableMcp.label": "Activar servidor MCP", "integrations.enableMcp.description": "Permitir que los asistentes de IA controlen el editor VMark", diff --git a/src/locales/es/statusbar.json b/src/locales/es/statusbar.json index 33c20f17c..911e84e47 100644 --- a/src/locales/es/statusbar.json +++ b/src/locales/es/statusbar.json @@ -7,6 +7,8 @@ "mcpStatus": "Estado de MCP", "mcpError": "Error de MCP: {{error}}", "mcpStarting": "Iniciando MCP...", + "readAloudProgress": "Leyendo en voz alta, frase {{current}} de {{total}}", + "readAloudError": "Error de lectura en voz alta", "mcpStopped": "MCP detenido · Haz clic para iniciar", "mcpNoClients": "MCP listo · Sin IA conectada", "mcpConnected": "Conectado: {{clients}}", diff --git a/src/locales/fr/commands.json b/src/locales/fr/commands.json index 40cf463c1..97262898e 100644 --- a/src/locales/fr/commands.json +++ b/src/locales/fr/commands.json @@ -70,6 +70,13 @@ "commandPalette.empty": "Aucune commande correspondante. Effacez la recherche pour tout afficher.", "aria.commandPalette": "Palette de commandes", "browser.newTab": "Nouvel onglet de navigateur", + "speech.readSelection": "Lire la sélection à voix haute", + "speech.readBlock": "Lire le bloc actuel à voix haute", + "speech.readFromCursor": "Lire à voix haute depuis le curseur", + "speech.readDocument": "Lire le document à voix haute", + "speech.stop": "Arrêter la lecture à voix haute", + "speech.pause": "Suspendre la lecture à voix haute", + "speech.resume": "Reprendre la lecture à voix haute", "claims.extractFromSelection": "Extraire une affirmation de la sélection", "category.app": "Application", "category.file": "Fichier", diff --git a/src/locales/fr/settings.json b/src/locales/fr/settings.json index 4b47a9c79..198f4c219 100644 --- a/src/locales/fr/settings.json +++ b/src/locales/fr/settings.json @@ -331,6 +331,9 @@ "files.pandoc.installHint": "Installez Pandoc pour exporter en DOCX, EPUB, LaTeX et plus.", "files.pandoc.installGuide": "Guide d'installation", "files.pandoc.path": "Chemin", + "integrations.group.readAloud": "Lecture à voix haute", + "integrations.readAloud.label": "Activer la lecture à voix haute", + "integrations.readAloud.description": "Lire le texte du document avec la voix du système", "integrations.group.mcp": "Serveur MCP", "integrations.enableMcp.label": "Activer le serveur MCP", "integrations.enableMcp.description": "Permettre aux assistants IA de contrôler l'éditeur VMark", diff --git a/src/locales/fr/statusbar.json b/src/locales/fr/statusbar.json index 382557515..6fc2ce295 100644 --- a/src/locales/fr/statusbar.json +++ b/src/locales/fr/statusbar.json @@ -7,6 +7,8 @@ "mcpStatus": "Statut MCP", "mcpError": "Erreur MCP : {{error}}", "mcpStarting": "MCP en démarrage...", + "readAloudProgress": "Lecture à voix haute, phrase {{current}} sur {{total}}", + "readAloudError": "Erreur de lecture à voix haute", "mcpStopped": "MCP arrêté · Cliquez pour démarrer", "mcpNoClients": "MCP prêt · Aucune IA connectée", "mcpConnected": "Connecté : {{clients}}", diff --git a/src/locales/it/commands.json b/src/locales/it/commands.json index 025a3902e..fdf8d28a3 100644 --- a/src/locales/it/commands.json +++ b/src/locales/it/commands.json @@ -70,6 +70,13 @@ "commandPalette.empty": "Nessun comando corrispondente. Cancella la ricerca per vederli tutti.", "aria.commandPalette": "Tavolozza comandi", "browser.newTab": "Nuova scheda del browser", + "speech.readSelection": "Leggi la selezione ad alta voce", + "speech.readBlock": "Leggi il blocco corrente ad alta voce", + "speech.readFromCursor": "Leggi ad alta voce dal cursore", + "speech.readDocument": "Leggi il documento ad alta voce", + "speech.stop": "Interrompi lettura ad alta voce", + "speech.pause": "Metti in pausa la lettura ad alta voce", + "speech.resume": "Riprendi la lettura ad alta voce", "claims.extractFromSelection": "Estrai affermazione dalla selezione", "category.app": "Applicazione", "category.file": "File", diff --git a/src/locales/it/settings.json b/src/locales/it/settings.json index 9f3935d97..3a1b57d41 100644 --- a/src/locales/it/settings.json +++ b/src/locales/it/settings.json @@ -331,6 +331,9 @@ "files.pandoc.installHint": "Installa Pandoc per esportare in DOCX, EPUB, LaTeX e altri formati.", "files.pandoc.installGuide": "Guida all'installazione", "files.pandoc.path": "Percorso", + "integrations.group.readAloud": "Lettura ad alta voce", + "integrations.readAloud.label": "Abilita lettura ad alta voce", + "integrations.readAloud.description": "Leggi il testo del documento con la voce di sistema", "integrations.group.mcp": "Server MCP", "integrations.enableMcp.label": "Abilita server MCP", "integrations.enableMcp.description": "Consenti agli assistenti IA di controllare l'editor VMark", diff --git a/src/locales/it/statusbar.json b/src/locales/it/statusbar.json index d2b87c37d..8b6aee347 100644 --- a/src/locales/it/statusbar.json +++ b/src/locales/it/statusbar.json @@ -7,6 +7,8 @@ "mcpStatus": "Stato MCP", "mcpError": "Errore MCP: {{error}}", "mcpStarting": "MCP in avvio...", + "readAloudProgress": "Lettura ad alta voce, frase {{current}} di {{total}}", + "readAloudError": "Errore di lettura ad alta voce", "mcpStopped": "MCP arrestato · Clicca per avviare", "mcpNoClients": "MCP pronto · Nessuna IA connessa", "mcpConnected": "Connesso: {{clients}}", diff --git a/src/locales/ja/commands.json b/src/locales/ja/commands.json index 525e2e13d..6b1054420 100644 --- a/src/locales/ja/commands.json +++ b/src/locales/ja/commands.json @@ -70,6 +70,13 @@ "commandPalette.empty": "一致するコマンドはありません。検索を消去するとすべて表示されます。", "aria.commandPalette": "コマンドパレット", "browser.newTab": "新しいブラウザタブ", + "speech.readSelection": "選択範囲を読み上げ", + "speech.readBlock": "現在のブロックを読み上げ", + "speech.readFromCursor": "カーソル位置から読み上げ", + "speech.readDocument": "文書を読み上げ", + "speech.stop": "読み上げを停止", + "speech.pause": "読み上げを一時停止", + "speech.resume": "読み上げを再開", "claims.extractFromSelection": "選択範囲から設定を抽出", "category.app": "アプリケーション", "category.file": "ファイル", diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index de9b99ac9..fa49587b7 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -331,6 +331,9 @@ "files.pandoc.installHint": "Pandocをインストールして、DOCX、EPUB、LaTeXなどへのエクスポートを有効にしてください。", "files.pandoc.installGuide": "インストールガイド", "files.pandoc.path": "パス", + "integrations.group.readAloud": "読み上げ", + "integrations.readAloud.label": "読み上げを有効にする", + "integrations.readAloud.description": "システム音声で文書を読み上げます", "integrations.group.mcp": "MCPサーバー", "integrations.enableMcp.label": "MCPサーバーを有効にする", "integrations.enableMcp.description": "AIアシスタントがVMarkエディターを操作できるようにする", diff --git a/src/locales/ja/statusbar.json b/src/locales/ja/statusbar.json index fcc7129d6..51cf4a225 100644 --- a/src/locales/ja/statusbar.json +++ b/src/locales/ja/statusbar.json @@ -7,6 +7,8 @@ "mcpStatus": "MCPステータス", "mcpError": "MCPエラー: {{error}}", "mcpStarting": "MCP起動中...", + "readAloudProgress": "読み上げ中、{{total}} 文中 {{current}} 文目", + "readAloudError": "読み上げエラー", "mcpStopped": "MCP停止中 · クリックして起動", "mcpNoClients": "MCP準備完了 · AI未接続", "mcpConnected": "接続中: {{clients}}", diff --git a/src/locales/ko/commands.json b/src/locales/ko/commands.json index 6f85c2928..534a8cfe2 100644 --- a/src/locales/ko/commands.json +++ b/src/locales/ko/commands.json @@ -70,6 +70,13 @@ "commandPalette.empty": "일치하는 명령이 없습니다. 검색어를 지우면 모든 명령이 표시됩니다.", "aria.commandPalette": "명령 팔레트", "browser.newTab": "새 브라우저 탭", + "speech.readSelection": "선택 영역 소리 내어 읽기", + "speech.readBlock": "현재 블록 소리 내어 읽기", + "speech.readFromCursor": "커서부터 소리 내어 읽기", + "speech.readDocument": "문서 소리 내어 읽기", + "speech.stop": "소리 내어 읽기 중지", + "speech.pause": "소리 내어 읽기 일시 중지", + "speech.resume": "소리 내어 읽기 계속", "claims.extractFromSelection": "선택 영역에서 설정 추출", "category.app": "애플리케이션", "category.file": "파일", diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 65e442ebf..94691b6de 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -331,6 +331,9 @@ "files.pandoc.installHint": "DOCX, EPUB, LaTeX 등으로 내보내려면 Pandoc을 설치하세요.", "files.pandoc.installGuide": "설치 안내", "files.pandoc.path": "경로", + "integrations.group.readAloud": "소리 내어 읽기", + "integrations.readAloud.label": "소리 내어 읽기 활성화", + "integrations.readAloud.description": "시스템 음성으로 문서 텍스트를 읽습니다", "integrations.group.mcp": "MCP 서버", "integrations.enableMcp.label": "MCP 서버 사용", "integrations.enableMcp.description": "AI 어시스턴트가 VMark 편집기를 제어할 수 있도록 허용", diff --git a/src/locales/ko/statusbar.json b/src/locales/ko/statusbar.json index fff68924d..da7e9245e 100644 --- a/src/locales/ko/statusbar.json +++ b/src/locales/ko/statusbar.json @@ -7,6 +7,8 @@ "mcpStatus": "MCP 상태", "mcpError": "MCP 오류: {{error}}", "mcpStarting": "MCP 시작 중...", + "readAloudProgress": "소리 내어 읽는 중, {{total}}문장 중 {{current}}번째", + "readAloudError": "소리 내어 읽기 오류", "mcpStopped": "MCP 중지됨 · 클릭하여 시작", "mcpNoClients": "MCP 준비됨 · AI 미연결", "mcpConnected": "연결됨: {{clients}}", diff --git a/src/locales/pt-BR/commands.json b/src/locales/pt-BR/commands.json index 26cb84402..c8cf6b461 100644 --- a/src/locales/pt-BR/commands.json +++ b/src/locales/pt-BR/commands.json @@ -70,6 +70,13 @@ "commandPalette.empty": "Nenhum comando corresponde. Limpe a busca para ver todos.", "aria.commandPalette": "Paleta de comandos", "browser.newTab": "Nova aba do navegador", + "speech.readSelection": "Ler seleção em voz alta", + "speech.readBlock": "Ler bloco atual em voz alta", + "speech.readFromCursor": "Ler em voz alta a partir do cursor", + "speech.readDocument": "Ler documento em voz alta", + "speech.stop": "Parar leitura em voz alta", + "speech.pause": "Pausar leitura em voz alta", + "speech.resume": "Retomar leitura em voz alta", "claims.extractFromSelection": "Extrair afirmação da seleção", "category.app": "Aplicativo", "category.file": "Arquivo", diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index ed7b1b166..1f76e4422 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -331,6 +331,9 @@ "files.pandoc.installHint": "Instale o Pandoc para exportar para DOCX, EPUB, LaTeX e mais.", "files.pandoc.installGuide": "Guia de instalação", "files.pandoc.path": "Caminho", + "integrations.group.readAloud": "Leitura em voz alta", + "integrations.readAloud.label": "Ativar leitura em voz alta", + "integrations.readAloud.description": "Ler o texto do documento com a voz do sistema", "integrations.group.mcp": "Servidor MCP", "integrations.enableMcp.label": "Ativar servidor MCP", "integrations.enableMcp.description": "Permitir que assistentes de IA controlem o editor VMark", diff --git a/src/locales/pt-BR/statusbar.json b/src/locales/pt-BR/statusbar.json index f3e7efbe0..002499cdb 100644 --- a/src/locales/pt-BR/statusbar.json +++ b/src/locales/pt-BR/statusbar.json @@ -7,6 +7,8 @@ "mcpStatus": "Status do MCP", "mcpError": "Erro no MCP: {{error}}", "mcpStarting": "MCP iniciando...", + "readAloudProgress": "Lendo em voz alta, frase {{current}} de {{total}}", + "readAloudError": "Erro de leitura em voz alta", "mcpStopped": "MCP parado · Clique para iniciar", "mcpNoClients": "MCP pronto · Nenhuma IA conectada", "mcpConnected": "Conectado: {{clients}}", diff --git a/src/locales/zh-CN/commands.json b/src/locales/zh-CN/commands.json index f6d742d6c..f086a4e78 100644 --- a/src/locales/zh-CN/commands.json +++ b/src/locales/zh-CN/commands.json @@ -70,6 +70,13 @@ "commandPalette.empty": "没有匹配的命令。清除搜索可查看全部命令。", "aria.commandPalette": "命令面板", "browser.newTab": "新建浏览器标签页", + "speech.readSelection": "朗读选中内容", + "speech.readBlock": "朗读当前块", + "speech.readFromCursor": "从光标处开始朗读", + "speech.readDocument": "朗读全文", + "speech.stop": "停止朗读", + "speech.pause": "暂停朗读", + "speech.resume": "继续朗读", "claims.extractFromSelection": "从选中内容提取设定", "category.app": "应用", "category.file": "文件", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index ebe7c3609..b9a36bedd 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -331,6 +331,9 @@ "files.pandoc.installHint": "安装 Pandoc 以导出为 DOCX、EPUB、LaTeX 等。", "files.pandoc.installGuide": "安装指南", "files.pandoc.path": "路径", + "integrations.group.readAloud": "朗读", + "integrations.readAloud.label": "启用朗读", + "integrations.readAloud.description": "允许 VMark 使用系统语音朗读文档内容", "integrations.group.mcp": "MCP 服务器", "integrations.enableMcp.label": "启用 MCP 服务器", "integrations.enableMcp.description": "允许 AI 助手控制 VMark 编辑器", diff --git a/src/locales/zh-CN/statusbar.json b/src/locales/zh-CN/statusbar.json index 4b1cb2b59..5cc0413fc 100644 --- a/src/locales/zh-CN/statusbar.json +++ b/src/locales/zh-CN/statusbar.json @@ -7,6 +7,8 @@ "mcpStatus": "MCP 状态", "mcpError": "MCP 错误:{{error}}", "mcpStarting": "MCP 正在启动...", + "readAloudProgress": "正在朗读,第 {{current}} / {{total}} 句", + "readAloudError": "朗读出错", "mcpStopped": "MCP 已停止 · 点击启动", "mcpNoClients": "MCP 就绪 · 未连接 AI", "mcpConnected": "已连接:{{clients}}", diff --git a/src/locales/zh-TW/commands.json b/src/locales/zh-TW/commands.json index 058935cf7..6cbcc7a2b 100644 --- a/src/locales/zh-TW/commands.json +++ b/src/locales/zh-TW/commands.json @@ -70,6 +70,13 @@ "commandPalette.empty": "沒有相符的命令。清除搜尋可查看全部命令。", "aria.commandPalette": "指令面板", "browser.newTab": "新增瀏覽器分頁", + "speech.readSelection": "朗讀選取內容", + "speech.readBlock": "朗讀目前區塊", + "speech.readFromCursor": "從游標處開始朗讀", + "speech.readDocument": "朗讀全文", + "speech.stop": "停止朗讀", + "speech.pause": "暫停朗讀", + "speech.resume": "繼續朗讀", "claims.extractFromSelection": "從選取內容擷取設定", "category.app": "應用程式", "category.file": "檔案", diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index 0da5d9d0c..3fcc06a6b 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -331,6 +331,9 @@ "files.pandoc.installHint": "安裝 Pandoc 以匯出至 DOCX、EPUB、LaTeX 等格式。", "files.pandoc.installGuide": "安裝指南", "files.pandoc.path": "路徑", + "integrations.group.readAloud": "朗讀", + "integrations.readAloud.label": "啟用朗讀", + "integrations.readAloud.description": "允許 VMark 使用系統語音朗讀文件內容", "integrations.group.mcp": "MCP 伺服器", "integrations.enableMcp.label": "啟用 MCP 伺服器", "integrations.enableMcp.description": "允許 AI 助理控制 VMark 編輯器", diff --git a/src/locales/zh-TW/statusbar.json b/src/locales/zh-TW/statusbar.json index f2ab523f5..9f84c43f6 100644 --- a/src/locales/zh-TW/statusbar.json +++ b/src/locales/zh-TW/statusbar.json @@ -7,6 +7,8 @@ "mcpStatus": "MCP 狀態", "mcpError": "MCP 錯誤:{{error}}", "mcpStarting": "MCP 啟動中...", + "readAloudProgress": "正在朗讀,第 {{current}} / {{total}} 句", + "readAloudError": "朗讀發生錯誤", "mcpStopped": "MCP 已停止 · 點擊以啟動", "mcpNoClients": "MCP 已就緒 · 無 AI 連線", "mcpConnected": "已連線:{{clients}}", diff --git a/src/pages/settings/IntegrationsSettings.tsx b/src/pages/settings/IntegrationsSettings.tsx index 332c54ec5..ae47c5ff7 100644 --- a/src/pages/settings/IntegrationsSettings.tsx +++ b/src/pages/settings/IntegrationsSettings.tsx @@ -18,6 +18,7 @@ import { RefreshCw, Users, ExternalLink } from "lucide-react"; import type { ProviderType } from "@/types/aiGenies"; import { RestProviderConfigFields } from "./RestProviderConfigFields"; import { ProviderRadio } from "./ProviderRadio"; +import { stopReadAloud } from "@/features/readAloud/runtime"; function StatusBadge({ running, loading }: { running: boolean; loading: boolean }) { const { t } = useTranslation("settings"); @@ -52,6 +53,8 @@ export function IntegrationsSettings() { const { t } = useTranslation("settings"); const mcpSettings = useSettingsStore((state) => state.advanced.mcpServer); const updateAdvancedSetting = useSettingsStore((state) => state.updateAdvancedSetting); + const readAloudEnabled = useSettingsStore((state) => state.speech.enabled); + const updateSpeechSetting = useSettingsStore((state) => state.updateSpeechSetting); const { running, port, loading, error, start, stop } = useMcpServer(); const { runHealthCheck, isChecking, version, toolCount, resourceCount } = useMcpHealthCheck(); @@ -122,8 +125,19 @@ export function IntegrationsSettings() { } }; + const handleReadAloudChange = (enabled: boolean) => { + updateSpeechSetting("enabled", enabled); + if (!enabled) void stopReadAloud(); + }; + return (
+ + + + + + ({ import { resolveCommandContext } from "./commandContext"; -const VIEW = {} as object; +const VIEW = { state: { selection: { empty: true, ranges: [{ empty: true }] } } }; function docTab(id: string, formatId = "markdown"): Tab { return { id, title: id, kind: "document", filePath: `/${id}.md`, formatId } as unknown as Tab; @@ -116,6 +116,28 @@ describe("resolveCommandContext — read-only (Phase 2b)", () => { }); describe("resolveCommandContext", () => { + it.each([true, false])("reads the live WYSIWYG selection when cached selection is %s", (cached) => { + ed.tiptapContext = { hasSelection: cached }; + ed.wysiwyg = { view: { state: { selection: { empty: cached } } } }; + expect(resolveCommandContext("main").hasSelection).toBe(!cached); + }); + + it.each([true, false])("reads live Source ranges when cached selection is %s", (cached) => { + ui.sourceMode = true; + ed.sourceContext = { hasSelection: cached }; + ed.source = { state: { selection: { ranges: [{ empty: true }, { empty: cached }] } } }; + expect(resolveCommandContext("main").hasSelection).toBe(!cached); + }); + + it.each([true, false])("does not retain selection after unmount in source mode %s", (sourceMode) => { + ui.sourceMode = sourceMode; + ed.source = null; + ed.wysiwyg = null; + ed.sourceContext = { hasSelection: true }; + ed.tiptapContext = { hasSelection: true }; + expect(resolveCommandContext("main").hasSelection).toBe(false); + }); + it("reports a live document tab + its format", () => { const c = resolveCommandContext("main"); expect(c.isDocument).toBe(true); @@ -159,6 +181,7 @@ describe("resolveCommandContext", () => { }); it("normalises EVERY axis of the WYSIWYG cursor context (optional-truthy shape)", () => { + ed.wysiwyg = { view: { state: { selection: { empty: false } } } }; ed.tiptapContext = { hasSelection: true, inTable: { row: 0 }, diff --git a/src/services/commands/commandContext.ts b/src/services/commands/commandContext.ts index 8c062f01c..672e2cdd0 100644 --- a/src/services/commands/commandContext.ts +++ b/src/services/commands/commandContext.ts @@ -119,17 +119,20 @@ export function resolveCommandContext(windowLabel: string): CommandContextResolv const editorState = useEditorStore.getState(); let editorAvailable: boolean; + let hasSelection: boolean; let cursor: CursorAxes; let multiCtx: MultiSelectionContext | null; if (mode === "source") { const view = editorState.active.activeSourceView; editorAvailable = !!view; + hasSelection = view?.state?.selection?.ranges.some((range) => !range.empty) ?? false; cursor = editorState.source.context; multiCtx = view ? getSourceMultiSelectionContext(view, editorState.source.context) : null; } else { const editor = editorState.active.activeWysiwygEditor; const view = editor?.view ?? null; editorAvailable = !!view; + hasSelection = view?.state?.selection?.empty === false; cursor = editorState.tiptap.context; multiCtx = view ? getWysiwygMultiSelectionContext(view, null) : null; } @@ -144,7 +147,9 @@ export function resolveCommandContext(windowLabel: string): CommandContextResolv formatId, editorAvailable, readOnly, - hasSelection: cursor?.hasSelection ?? false, + // Cursor decorations can lag behind a transaction or a context-menu focus change. + // Command availability must follow the live selection, including its removal. + hasSelection, multiSelection, inTable: !!cursor?.inTable, inLink: !!cursor?.inLink, diff --git a/src/services/commands/readAloudCommands.test.ts b/src/services/commands/readAloudCommands.test.ts new file mode 100644 index 000000000..c663394e2 --- /dev/null +++ b/src/services/commands/readAloudCommands.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + _resetCommandBus, + executeCommand, + hasCommand, +} from "./CommandBus"; +import { + registerReadAloudCommands, + type ReadAloudCommandActions, +} from "./readAloudCommands"; + +function context(overrides: Record = {}) { + return { + windowLabel: "doc-1", + isDocument: true, + editorAvailable: true, + hasSelection: true, + ...overrides, + }; +} + +let actions: ReadAloudCommandActions; + +beforeEach(() => { + _resetCommandBus(); + actions = { + start: vi.fn(async () => true), + stop: vi.fn(async () => undefined), + }; + registerReadAloudCommands(actions); +}); + +describe("read-aloud commands", () => { + it("registers the built-in reading scopes and stop", () => { + expect(hasCommand("speech.readSelection")).toBe(true); + expect(hasCommand("speech.readBlock")).toBe(true); + expect(hasCommand("speech.readFromCursor")).toBe(true); + expect(hasCommand("speech.readDocument")).toBe(true); + expect(hasCommand("speech.stop")).toBe(true); + }); + + it("replaces the owned command batch on re-bootstrap", async () => { + const replacement: ReadAloudCommandActions = { + start: vi.fn(async () => true), + stop: vi.fn(async () => undefined), + }; + + registerReadAloudCommands(replacement); + await executeCommand("speech.readDocument", undefined, context()); + + expect(actions.start).not.toHaveBeenCalled(); + expect(replacement.start).toHaveBeenCalledWith("doc-1", "document"); + }); + + it("routes each scope with the invoking window label", async () => { + await executeCommand("speech.readSelection", undefined, context()); + await executeCommand("speech.readBlock", undefined, context()); + await executeCommand("speech.readFromCursor", undefined, context()); + await executeCommand("speech.readDocument", undefined, context()); + + expect(actions.start).toHaveBeenNthCalledWith(1, "doc-1", "selection"); + expect(actions.start).toHaveBeenNthCalledWith(2, "doc-1", "block"); + expect(actions.start).toHaveBeenNthCalledWith(3, "doc-1", "fromCursor"); + expect(actions.start).toHaveBeenNthCalledWith(4, "doc-1", "document"); + }); + + it("hides selection speech for an empty selection", async () => { + await expect( + executeCommand("speech.readSelection", undefined, context({ hasSelection: false })), + ).resolves.toBe(false); + expect(actions.start).not.toHaveBeenCalled(); + }); + + it("hides reading commands without a live document editor", async () => { + await expect( + executeCommand("speech.readDocument", undefined, context({ editorAvailable: false })), + ).resolves.toBe(false); + await expect( + executeCommand("speech.readBlock", undefined, context({ isDocument: false })), + ).resolves.toBe(false); + expect(actions.start).not.toHaveBeenCalled(); + }); + + it("stops independently of editor availability", async () => { + await executeCommand("speech.stop", undefined, context({ editorAvailable: false })); + expect(actions.stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/services/commands/readAloudCommands.ts b/src/services/commands/readAloudCommands.ts new file mode 100644 index 000000000..48a83240a --- /dev/null +++ b/src/services/commands/readAloudCommands.ts @@ -0,0 +1,67 @@ +import i18n from "@/i18n"; +import type { ReadAloudScope } from "@/features/readAloud/extraction"; +import { startReadAloud, stopReadAloud } from "@/features/readAloud/runtime"; +import { + registerCommands, + type CommandContext, + type CommandDefinition, +} from "./CommandBus"; +import { useSettingsStore } from "@/stores/settingsStore"; + +export interface ReadAloudCommandActions { + start: (windowLabel: string, scope: ReadAloudScope) => Promise; + stop: () => Promise; +} + +const runtimeActions: ReadAloudCommandActions = { + start: startReadAloud, + stop: stopReadAloud, +}; + +const READ_ALOUD_COMMANDS_OWNER = "read-aloud-commands"; + +function canRead(context: CommandContext): boolean { + return context.isDocument === true && context.editorAvailable === true && useSettingsStore.getState().speech.enabled; +} + +function windowLabel(context: CommandContext): string { + return typeof context.windowLabel === "string" ? context.windowLabel : "main"; +} + +function buildReadAloudCommandSpecs( + actions: ReadAloudCommandActions, +): CommandDefinition[] { + const scopes: readonly [string, string, ReadAloudScope][] = [ + ["speech.readSelection", "commands:speech.readSelection", "selection"], + ["speech.readBlock", "commands:speech.readBlock", "block"], + ["speech.readFromCursor", "commands:speech.readFromCursor", "fromCursor"], + ["speech.readDocument", "commands:speech.readDocument", "document"], + ]; + const commands = scopes.map(([id, titleKey, scope]) => ({ + id, + title: () => i18n.t(titleKey), + category: "other", + when: (context) => + canRead(context) && (scope !== "selection" || context.hasSelection === true), + run: async (_args, context) => { + await actions.start(windowLabel(context), scope); + }, + })); + + commands.push({ + id: "speech.stop", + title: () => i18n.t("commands:speech.stop"), + category: "other", + run: async () => actions.stop(), + }); + return commands; +} + +export function registerReadAloudCommands( + actions: ReadAloudCommandActions = runtimeActions, +): void { + registerCommands( + READ_ALOUD_COMMANDS_OWNER, + buildReadAloudCommandSpecs(actions), + ); +} diff --git a/src/services/commands/registerAllCommands.ts b/src/services/commands/registerAllCommands.ts index fe2fbc53c..853e6b704 100644 --- a/src/services/commands/registerAllCommands.ts +++ b/src/services/commands/registerAllCommands.ts @@ -43,6 +43,7 @@ import { registerEditorCommands } from "./editorCommandBridge"; import { registerTabCommands } from "./tabCommands"; import { registerFileCommands } from "./fileCommands"; import { registerGenieCommands } from "./genieCommands"; +import { registerReadAloudCommands } from "./readAloudCommands"; import { registerWindowCommands } from "./windowCommands"; import { restoreCommandRegistry, snapshotCommandRegistry } from "./CommandBus"; @@ -64,6 +65,7 @@ export function registerAllCommands(): () => void { registerTabCommands(); registerFileCommands(); registerGenieCommands(); + registerReadAloudCommands(); // Lift every editor ActionId into the bus so the palette can find them // (WI-3.4). Owner-based batch registration is HMR-safe (replace-own). return registerEditorCommands(); diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index cd45f2ea2..69c082ac8 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -83,6 +83,7 @@ export type { UpdateSettings, LargeFileSettings, BrowserSettings, + SpeechSettings, SettingsState, SettingsActions, } from "./settingsTypes"; @@ -123,6 +124,7 @@ export const useSettingsStore = create()( updateLargeFileSetting: createSectionUpdater(set, "largeFile"), updateFormatsSetting: createSectionUpdater(set, "formats"), updateBrowserSetting: createSectionUpdater(set, "browser"), + updateSpeechSetting: createSectionUpdater(set, "speech"), toggleDevSection: () => set((state) => ({ showDevSection: !state.showDevSection })), resetSettings: () => set(structuredClone(initialState)), }), diff --git a/src/stores/settingsStore/defaults.test.ts b/src/stores/settingsStore/defaults.test.ts index a958c85fc..261261821 100644 --- a/src/stores/settingsStore/defaults.test.ts +++ b/src/stores/settingsStore/defaults.test.ts @@ -35,4 +35,8 @@ describe("first-install defaults are non-destructive", () => { it("shows file extensions", () => { expect(defaultSettings.general.showFileExtensions).toBe(true); }); + + it("enables native read aloud by default", () => { + expect(defaultSettings.speech.enabled).toBe(true); + }); }); diff --git a/src/stores/settingsStore/defaults.ts b/src/stores/settingsStore/defaults.ts index b99f89e9d..1ee0c9c14 100644 --- a/src/stores/settingsStore/defaults.ts +++ b/src/stores/settingsStore/defaults.ts @@ -174,6 +174,7 @@ export const initialState: SettingsState = { aiSession: "sandbox", aiAllowLoopback: false, }, + speech: { enabled: true }, // Advanced is visible by default, because it now hosts the OFF switch for a // default-on feature. Truly developer-only content inside it (Experimental, // Hot Exit dev tools) stays behind the Developer-mode toggle, so this reveals diff --git a/src/stores/settingsTypes.ts b/src/stores/settingsTypes.ts index 3a97d1222..8db71da3b 100644 --- a/src/stores/settingsTypes.ts +++ b/src/stores/settingsTypes.ts @@ -45,6 +45,7 @@ export type { FormatsSettings, LargeFileSettings, BrowserSettings, + SpeechSettings, GeneralSettings, UpdateCheckFrequency, UpdateSettings, diff --git a/src/stores/settingsTypes/state.ts b/src/stores/settingsTypes/state.ts index d07b82361..e132cd4bf 100644 --- a/src/stores/settingsTypes/state.ts +++ b/src/stores/settingsTypes/state.ts @@ -13,6 +13,7 @@ import type { ImageSettings, MarkdownSettings } from "./content"; import type { AdvancedSettingsState, TerminalSettings } from "./system"; import type { BrowserSettings, + SpeechSettings, FormatsSettings, GeneralSettings, LargeFileSettings, @@ -36,6 +37,7 @@ export interface SettingsState { largeFile: LargeFileSettings; formats: FormatsSettings; browser: BrowserSettings; + speech: SpeechSettings; // UI state showDevSection: boolean; } @@ -56,6 +58,7 @@ export interface SettingsActions { updateLargeFileSetting: SettingUpdater; updateFormatsSetting: SettingUpdater; updateBrowserSetting: SettingUpdater; + updateSpeechSetting: SettingUpdater; toggleDevSection: () => void; resetSettings: () => void; } diff --git a/src/stores/settingsTypes/workspace.ts b/src/stores/settingsTypes/workspace.ts index 493151edc..e8a96383c 100644 --- a/src/stores/settingsTypes/workspace.ts +++ b/src/stores/settingsTypes/workspace.ts @@ -89,6 +89,10 @@ export interface BrowserSettings { aiAllowLoopback: boolean; } +export interface SpeechSettings { + enabled: boolean; +} + /** General settings — auto-save, document history, tab size, line endings, and quit behavior. */ export interface GeneralSettings { // Auto-save