├── .gitignore ├── types.d.ts ├── src ├── config.ts ├── ui │ ├── dom-selectors.ts │ ├── context-menu.ts │ └── mic-button.ts ├── asr │ ├── instance.ts │ ├── manager.ts │ └── worker.ts ├── audio │ └── processing.ts ├── types.ts ├── utils │ └── hotkey.ts ├── styles │ └── styles.css └── main.ts ├── package.json ├── LICENSE ├── tsconfig.json ├── README.md ├── bun.lock └── dist └── yap-for-cursor.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "worker:*" { 2 | const inlineWorker: string; 3 | export default inlineWorker; 4 | } 5 | 6 | declare module "*.css" { 7 | const content: string; 8 | export default content; 9 | } 10 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const HUGGING_FACE_TRANSFORMERS_VERSION = "3.5.0"; // Or latest compatible 2 | export const TARGET_SAMPLE_RATE = 16000; // Whisper expects 16kHz 3 | export const MAX_NEW_TOKENS = 128; // Max tokens for ASR output 4 | export const HOTKEYS = { 5 | TOGGLE_RECORDING: "cmd+shift+y", 6 | } as const; 7 | -------------------------------------------------------------------------------- /src/ui/dom-selectors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Centralized DOM selectors for ASR UI components. 3 | * Update these in one place if the DOM structure changes. 4 | */ 5 | export const DOM_SELECTORS = { 6 | micButton: ".mic-btn[data-asr-init]", 7 | chatInputContentEditable: ".aislash-editor-input[contenteditable='true']", 8 | fullInputBox: ".full-input-box", 9 | buttonContainer: ".button-container.composer-button-area", 10 | }; 11 | -------------------------------------------------------------------------------- /src/asr/instance.ts: -------------------------------------------------------------------------------- 1 | import type { AsrInstance } from "../types"; 2 | 3 | declare global { 4 | interface Window { 5 | _currentAsrInstance?: AsrInstance | null; 6 | } 7 | } 8 | 9 | /** 10 | * Sets the current global ASR instance. 11 | */ 12 | export function setCurrentAsrInstance(instance: AsrInstance | null) { 13 | window._currentAsrInstance = instance; 14 | } 15 | 16 | /** 17 | * Gets the current global ASR instance. 18 | */ 19 | export function getCurrentAsrInstance(): AsrInstance | null { 20 | return window._currentAsrInstance ?? null; 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yap-for-cursor", 3 | "module": "src/main.ts", 4 | "type": "module", 5 | "scripts": { 6 | "build": "bun build.ts", 7 | "dev": "nodemon --exec bun build.ts" 8 | }, 9 | "devDependencies": { 10 | "@aidenlx/esbuild-plugin-inline-worker": "^1.0.1", 11 | "@types/bun": "latest", 12 | "@types/web": "^0.0.222", 13 | "esbuild": "^0.25.2", 14 | "typescript": "^5.8.3" 15 | }, 16 | "peerDependencies": { 17 | "typescript": "^5.0.0" 18 | }, 19 | "dependencies": { 20 | "@huggingface/transformers": "3.5.0", 21 | "esbuild-plugin-inline-import": "^1.1.0", 22 | "nodemon": "^3.1.9" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 Avarayr 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "DOM", 6 | "ESNext", 7 | "esnext.asynciterable", 8 | "DOM.Iterable", 9 | "WebWorker", 10 | "WebWorker.Iterable" 11 | ], 12 | "skipLibCheck": true, 13 | "sourceMap": true, 14 | "outDir": "./dist", 15 | "moduleResolution": "bundler", 16 | "removeComments": true, 17 | "noImplicitAny": true, 18 | "strictNullChecks": true, 19 | "strictFunctionTypes": true, 20 | "noImplicitThis": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noImplicitReturns": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "allowSyntheticDefaultImports": true, 26 | "esModuleInterop": true, 27 | "emitDecoratorMetadata": true, 28 | "experimentalDecorators": true, 29 | "resolveJsonModule": true, 30 | "baseUrl": ".", 31 | "allowJs": true, 32 | "checkJs": true, 33 | "module": "ESNext", 34 | "moduleDetection": "force", 35 | "allowImportingTsExtensions": true, 36 | "noEmit": true, 37 | "composite": true, 38 | "strict": true, 39 | "downlevelIteration": true, 40 | "jsx": "react-jsx", 41 | "forceConsistentCasingInFileNames": true, 42 | "types": ["bun", "web"] 43 | }, 44 | "exclude": ["node_modules"], 45 | "include": ["**/*.ts", "**/*.js"] 46 | } 47 | -------------------------------------------------------------------------------- /src/audio/processing.ts: -------------------------------------------------------------------------------- 1 | import { TARGET_SAMPLE_RATE } from "../config"; 2 | 3 | /** 4 | * Processes an audio Blob, decodes it, and resamples it to the target sample rate if necessary. 5 | * Returns a mono Float32Array suitable for the ASR model. 6 | * 7 | * @param blob The input audio Blob. 8 | * @param targetSr The target sample rate (default: TARGET_SAMPLE_RATE). 9 | * @returns A Promise that resolves to a Float32Array or null if processing fails. 10 | */ 11 | export async function processAudioBlob( 12 | blob: Blob | null, 13 | targetSr: number = TARGET_SAMPLE_RATE 14 | ): Promise { 15 | if (!blob || blob.size === 0) return null; 16 | 17 | // Correctly reference AudioContext 18 | const AudioContext = window.AudioContext; 19 | if (!AudioContext) { 20 | console.error("Browser does not support AudioContext."); 21 | return null; 22 | } 23 | const audioContext = new AudioContext(); 24 | 25 | try { 26 | const arrayBuffer = await blob.arrayBuffer(); 27 | const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); 28 | 29 | if (audioBuffer.sampleRate === targetSr) { 30 | // No resampling needed 31 | await audioContext.close(); // Close context when done 32 | // Ensure mono by taking the first channel 33 | return audioBuffer.getChannelData(0); 34 | } 35 | 36 | // Resampling needed 37 | console.log(`Resampling from ${audioBuffer.sampleRate}Hz to ${targetSr}Hz`); 38 | const duration = audioBuffer.duration; 39 | const offlineContext = new OfflineAudioContext( 40 | 1, // Mono 41 | Math.ceil(duration * targetSr), // Calculate output buffer size correctly 42 | targetSr 43 | ); 44 | const bufferSource = offlineContext.createBufferSource(); 45 | bufferSource.buffer = audioBuffer; 46 | bufferSource.connect(offlineContext.destination); 47 | bufferSource.start(); 48 | 49 | const resampledBuffer = await offlineContext.startRendering(); 50 | await audioContext.close(); // Close original context 51 | return resampledBuffer.getChannelData(0); // Return Float32Array of the first (only) channel 52 | } catch (error) { 53 | console.error("Audio processing failed:", error); 54 | if (audioContext && audioContext.state !== "closed") { 55 | await audioContext.close(); 56 | } 57 | return null; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // Define a type for the transformers library object on the window 2 | declare global { 3 | interface Window { 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | transformers?: any; // Use a more specific type if possible, like typeof HFTransformers 6 | _asrGlobalHandlerAttached?: boolean; 7 | } 8 | } 9 | 10 | // Define navigator with gpu property 11 | export interface NavigatorWithMaybeGPU extends Navigator { 12 | gpu?: { 13 | requestAdapter: () => Promise; 14 | }; 15 | } 16 | 17 | export interface GPUAdapter { 18 | requestDevice: () => Promise; 19 | } 20 | 21 | export interface GPUDevice { 22 | createCommandEncoder: () => GPUCommandEncoder; 23 | } 24 | 25 | export interface GPUCommandEncoder { 26 | finish: () => GPUCommandBuffer; 27 | } 28 | 29 | interface GPUCommandBuffer { 30 | // Add any necessary properties/methods 31 | } 32 | 33 | // Custom event detail types 34 | export interface AsrStatusUpdateDetail { 35 | state: AsrManagerState; 36 | message?: string; 37 | } 38 | 39 | export interface AsrResultDetail { 40 | status: "update" | "complete" | "error" | "transcribing_start"; 41 | output?: string; 42 | tps?: number; 43 | numTokens?: number; 44 | data?: string; // Primarily for error messages 45 | } 46 | 47 | // Type for the state of the mic button 48 | export type MicButtonState = "idle" | "recording" | "transcribing" | "disabled"; 49 | 50 | // New: Type for the overall ASR Manager state 51 | export type AsrManagerState = 52 | | "uninitialized" 53 | | "initializing" // Worker being created, initial load message sent 54 | | "loading_model" // Worker loading model files 55 | | "warming_up" // Worker warming up model (optional distinction) 56 | | "ready" // Worker loaded and ready for transcription 57 | // | "transcribing" // We can track this via mic button state or add here if needed globally 58 | | "error"; 59 | 60 | // Extend HTMLElement to include custom properties used on the mic button 61 | export interface MicButtonElement extends HTMLDivElement { 62 | asrState?: MicButtonState; 63 | } 64 | 65 | // Map of ASR instances 66 | export interface AsrInstance { 67 | mic: MicButtonElement; 68 | chatInputContentEditable: HTMLDivElement; // contentEditable div 69 | } 70 | 71 | // Define a type for the message sent to the worker 72 | export interface WorkerGenerateData { 73 | audio: Float32Array; 74 | language: string; 75 | } 76 | 77 | export interface WorkerMessage { 78 | type: "load" | "generate" | "stop"; 79 | data?: WorkerGenerateData; // Only present for 'generate' type 80 | } 81 | 82 | // Define a type for the message received from the worker 83 | export interface WorkerResponse { 84 | status: 85 | | "loading" 86 | | "ready" 87 | | "error" 88 | | "update" 89 | | "complete" 90 | | "transcribing_start"; 91 | data?: string; // Used for loading messages and error details 92 | output?: string; // Used for transcription updates and completion 93 | tps?: number; 94 | numTokens?: number; 95 | } 96 | -------------------------------------------------------------------------------- /src/utils/hotkey.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Register a global hotkey for the given key combination. 3 | * DOM compatible for modern browsers (primarily Chrome). 4 | * If a hotkey with the same key and target is already registered, 5 | * the old callback will be replaced with the new one. 6 | * 7 | * @param key The key or key combination to listen for (e.g., 'a', 'Shift+A', 'Control+Alt+Delete') 8 | * @param callback The function to execute when the hotkey is triggered 9 | * @param options Optional configuration for the hotkey behavior 10 | * @returns A function that can be called to unregister the hotkey 11 | */ 12 | 13 | // Store registered hotkeys to prevent duplicates and allow replacement 14 | interface HotkeyRegistration { 15 | target: HTMLElement | Document; 16 | handler: EventListener; 17 | unregister: () => void; 18 | } 19 | const registeredHotkeys: Map = new Map(); 20 | 21 | // Generate a unique identifier for a hotkey configuration 22 | function getHotkeyId(key: string, target: HTMLElement | Document): string { 23 | return `${key}:${target === document ? "document" : "element"}`; 24 | } 25 | 26 | export function registerHotkey( 27 | key: string, 28 | callback: (event: KeyboardEvent) => void, 29 | options: { 30 | target?: HTMLElement | Document; 31 | preventDefault?: boolean; 32 | stopPropagation?: boolean; 33 | allowInInputs?: boolean; 34 | } = {} 35 | ): () => void { 36 | const { 37 | target = document, 38 | preventDefault = true, 39 | stopPropagation = true, 40 | allowInInputs = true, 41 | } = options; 42 | 43 | // Parse the key combination 44 | const keys = key.split("+").map((k) => k.trim().toLowerCase()); 45 | const mainKey = keys[keys.length - 1]; 46 | const modifiers = { 47 | ctrl: keys.includes("ctrl") || keys.includes("control"), 48 | alt: keys.includes("alt"), 49 | shift: keys.includes("shift"), 50 | meta: 51 | keys.includes("meta") || keys.includes("command") || keys.includes("cmd"), 52 | }; 53 | 54 | // Generate a unique ID for this hotkey configuration 55 | const hotkeyId = getHotkeyId(key, target); 56 | 57 | // If this exact hotkey (key + target) is already registered, unregister the old one first. 58 | if (registeredHotkeys.has(hotkeyId)) { 59 | const existingRegistration = registeredHotkeys.get(hotkeyId)!; 60 | existingRegistration.unregister(); // This removes the old listener and cleans the map entry 61 | } 62 | 63 | // Define the event handler function for this registration 64 | // This closure captures the *new* callback 65 | const handler = (event: KeyboardEvent) => { 66 | // Skip if we're in an input and that's not allowed 67 | if (!allowInInputs && isInputElement(event.target as HTMLElement)) { 68 | return; 69 | } 70 | 71 | // Check if the event matches our hotkey 72 | const keyMatch = event.key.toLowerCase() === mainKey.toLowerCase(); 73 | const ctrlMatch = event.ctrlKey === modifiers.ctrl; 74 | const altMatch = event.altKey === modifiers.alt; 75 | const shiftMatch = event.shiftKey === modifiers.shift; 76 | const metaMatch = event.metaKey === modifiers.meta; 77 | 78 | if (keyMatch && ctrlMatch && altMatch && shiftMatch && metaMatch) { 79 | if (preventDefault) { 80 | event.preventDefault(); 81 | } 82 | if (stopPropagation) { 83 | event.stopPropagation(); 84 | } 85 | // Execute the latest registered callback 86 | callback(event); 87 | } 88 | }; 89 | 90 | // Define the unregister function for *this specific* registration 91 | const unregister = () => { 92 | target.removeEventListener("keydown", handler as EventListener, { 93 | capture: true, 94 | }); 95 | // Remove this registration from the map 96 | registeredHotkeys.delete(hotkeyId); 97 | }; 98 | 99 | // Register the new event listener 100 | target.addEventListener("keydown", handler as EventListener, { 101 | capture: true, 102 | }); 103 | 104 | // Store the details of this registration, including its unregister function 105 | registeredHotkeys.set(hotkeyId, { 106 | target, 107 | handler: handler as EventListener, 108 | unregister, 109 | }); 110 | 111 | // Return the unregister function for external use 112 | return unregister; 113 | } 114 | 115 | /** 116 | * Checks if the element is an input element where hotkeys should be ignored by default 117 | */ 118 | function isInputElement(element: HTMLElement | null): boolean { 119 | if (!element) return false; 120 | 121 | const tagName = element.tagName.toLowerCase(); 122 | const isContentEditable = element.getAttribute("contenteditable") === "true"; 123 | 124 | return ( 125 | tagName === "input" || 126 | tagName === "textarea" || 127 | tagName === "select" || 128 | isContentEditable 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #

Speaking Head
Yap For Cursor

2 | 3 | 🗣️ Local, WebGPU-powered voice-to-text capabilities directly into the Cursor editor using the power of Hugging Face Transformers. 4 | 5 | [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/avarayr) 6 | 7 | https://github.com/user-attachments/assets/ea641fb8-00c8-4da8-b0b8-b6e11aac6478 8 | 9 | ## ✨ Features 10 | 11 | - 🎙️ **LOCAL VOICE TRANSCRIPTION:** Transcribe your speech directly into the Cursor chat input using the power of Hugging Face Transformers (Whisper model). 12 | - 🔒 **IN-BROWSER PROCESSING:** All transcription happens _locally_ in your editor. No data sent to external servers (besides downloading the model initially). 13 | - 🖱️ **SEAMLESS INTEGRATION:** Adds a microphone button directly to the Cursor UI. 14 | 15 | ## 🚀 Installation Guide 16 | 17 | This project injects custom JavaScript into Cursor via the `Custom CSS and JS Loader` extension. 18 | 19 | 1. **Clone the Repository File Folder** 20 | 21 | ```bash 22 | git clone https://github.com/avarayr/yap-for-cursor.git 23 | ``` 24 | 25 | 2. **Install [Custom CSS and JS Loader](https://marketplace.visualstudio.com/items?itemName=be5invis.vscode-custom-css) Extension** 26 | 27 | You need to install the [Custom CSS and JS Loader](https://marketplace.visualstudio.com/items?itemName=be5invis.vscode-custom-css) Extension. 28 | 29 | 3. **Cursor > Open User Settings (JSON) Gear** 30 | 31 | - Open your User Settings (Command Palette: `Preferences: Open User Settings (JSON)`). 32 | 33 | - Add the following: 34 | 35 | **Important:** Use the `file:///` prefix and **forward slashes `/`** for the path, even on Windows. 36 | 37 | ```json 38 | "vscode_custom_css.imports": [ 39 | "file:///path/to/your/clone/of/yap-for-cursor/dist/yap-for-cursor.js" 40 | ], 41 | ``` 42 | 43 |
44 | Click for Path Examples 45 | 46 | - _macOS/Linux Example:_ `"file:///Users/yourname/yap-for-cursor/dist/yap-for-cursor.js"` 47 | - _Windows Example:_ `"file:\\C:\\Users\\yourname\\yap-for-cursor\\dist\\yap-for-cursor.js"` 48 |
49 |
50 | 51 | 4. **Enable Custom Code Check Mark Button** 52 | 53 | Open the Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`) and run: 54 | 55 | ``` 56 | Enable Custom CSS and JS 57 | ``` 58 | 59 | 5. **Restart Cursor** 60 | 61 | Close and fully reopen Cursor to apply the changes. 62 | 63 | ## **🚨 Important:** You **MUST** re-run the `Enable Custom CSS and JS` command after EVERY Cursor update. 64 | 65 | ## 🛠️ How to Use 66 | 67 | 1. 🖱️ Click the **microphone icon** that appears near the chat input. 68 | 2. ⏳ The first time (or after clearing cache), the ASR model needs to download. Please wait a moment. 69 | 3. 🔴 Once ready, click the icon again to **start recording**. 70 | 4. 🗣️ Speak clearly. 71 | 5. ⏹️ Click the icon again to **stop recording**. 72 | 6. ⌨️ Your transcribed text will appear in the chat input box! 73 | 74 | ## 🖥️ Compatibility 75 | 76 | - ✅ Cursor version: 0.49.0+ 77 | 78 | - ✅ **macOS**: (Apple Silicon & Intel) with WebGPU support. 79 | - ⚠️ **Windows/Linux:** Might work, but **requires WebGPU support** in the underlying browser/Electron version used by Cursor. Functionality isn't guaranteed. _Testing and feedback welcome!_ 80 | 81 | --- 82 | 83 | ## Hotkeys: 84 | 85 | - `Cmd+Shift+Y` to toggle the transcription. (work in progress) 86 | 87 | ## Language Support 88 | 89 | Right click the microphone 90 | 91 | image 92 | 93 | 94 | 95 | 96 | --- 97 | 98 | [![Star History Chart](https://api.star-history.com/svg?repos=avarayr/yap-for-cursor&type=Date)](https://www.star-history.com/#avarayr/yap-for-cursor&Date) 99 | 100 | --- 101 | 102 | ## ❤️ Support The Project 103 | 104 | If you find `yap-for-cursor` helpful, consider supporting the developer! 105 | 106 | [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/avarayr) 107 | -------------------------------------------------------------------------------- /src/styles/styles.css: -------------------------------------------------------------------------------- 1 | .sv-wrap { 2 | width: 0; 3 | height: 24px; 4 | opacity: 0; 5 | overflow: hidden; 6 | transition: width 0.3s ease, opacity 0.3s ease; 7 | margin-right: 2px; 8 | border-radius: 4px; 9 | vertical-align: middle; 10 | display: inline-block; 11 | position: relative; 12 | mask-image: linear-gradient( 13 | to right, 14 | transparent 0, 15 | black 10px, 16 | black calc(100% - 10px), 17 | transparent 100% 18 | ); 19 | } 20 | .mic-btn { 21 | cursor: pointer; 22 | padding: 4px; 23 | border-radius: 10px; 24 | transition: background 0.2s, color 0.2s; 25 | display: inline-flex; 26 | align-items: center; 27 | justify-content: center; 28 | vertical-align: middle; 29 | position: relative; 30 | color: #888; 31 | } 32 | .mic-btn:hover { 33 | background: rgba(0, 0, 0, 0.05); 34 | color: #555; 35 | } 36 | .mic-btn.active { 37 | color: #e66; 38 | background: rgba(255, 100, 100, 0.1); 39 | } 40 | .mic-btn.transcribing { 41 | color: #0cf; 42 | background: rgba(0, 200, 255, 0.1); 43 | } 44 | .mic-btn.disabled { 45 | cursor: not-allowed; 46 | color: #bbb; 47 | background: transparent !important; 48 | } 49 | @keyframes sv-spin { 50 | from { 51 | transform: rotate(0); 52 | } 53 | to { 54 | transform: rotate(360deg); 55 | } 56 | } 57 | .mic-spinner { 58 | width: 12px; 59 | height: 12px; 60 | border: 2px solid rgba(0, 0, 0, 0.2); 61 | border-top-color: #0cf; 62 | border-radius: 10px; 63 | animation: sv-spin 1s linear infinite; 64 | } 65 | .mic-btn.disabled .mic-spinner { 66 | border-top-color: #ccc; 67 | } 68 | .mic-btn.transcribing .mic-spinner { 69 | border-top-color: #0cf; 70 | } 71 | .mic-btn .status-tooltip { 72 | visibility: hidden; 73 | width: 120px; 74 | background-color: #555; 75 | color: #fff; 76 | text-align: center; 77 | border-radius: 6px; 78 | padding: 5px 3px; 79 | position: absolute; 80 | z-index: 1; 81 | bottom: 125%; 82 | left: 50%; 83 | margin-left: -60px; 84 | opacity: 0; 85 | transition: opacity 0.3s; 86 | font-size: 10px; 87 | white-space: nowrap; 88 | overflow: hidden; 89 | text-overflow: ellipsis; 90 | max-width: 120px; 91 | } 92 | .mic-btn .status-tooltip::after { 93 | content: ""; 94 | position: absolute; 95 | top: 100%; 96 | left: 50%; 97 | margin-left: -5px; 98 | border-width: 5px; 99 | border-style: solid; 100 | border-color: #555 transparent transparent transparent; 101 | } 102 | .mic-btn:hover .status-tooltip, 103 | .mic-btn.disabled .status-tooltip { 104 | visibility: visible; 105 | opacity: 1; 106 | } 107 | /* Styles for the cancel button - mimicking mic-btn but red */ 108 | .sv-cancel-btn { 109 | cursor: pointer; 110 | padding: 4px; 111 | border-radius: 50%; 112 | transition: background 0.2s, color 0.2s; 113 | display: inline-flex; 114 | align-items: center; 115 | justify-content: center; 116 | vertical-align: middle; 117 | color: #e66; 118 | margin-right: 2px; 119 | } 120 | .sv-cancel-btn:hover { 121 | background: rgba(255, 100, 100, 0.1); 122 | color: #c33; /* Darker red on hover */ 123 | } 124 | /* Styles for transcribing state controls */ 125 | .transcribe-controls { 126 | display: inline-flex; 127 | align-items: center; 128 | justify-content: center; 129 | gap: 4px; 130 | } 131 | .stop-btn-style { 132 | color: #e66; 133 | cursor: pointer; 134 | font-size: 10px; 135 | } 136 | 137 | /* --- Context Menu Styles --- */ 138 | .asr-context-menu { 139 | position: absolute; /* Ensure position is set */ 140 | z-index: 10000; /* Ensure it's on top */ 141 | background-color: var(--vscode-menu-background, #252526); 142 | border: 1px solid var(--vscode-menu-border, #3c3c3c); 143 | color: var(--vscode-menu-foreground, #cccccc); 144 | min-width: 150px; 145 | max-width: 250px; /* Optional: Prevent excessive width */ 146 | max-height: 40vh; /* Limit height to 40% of viewport height */ 147 | overflow-y: auto; /* Enable vertical scrolling */ 148 | overflow-x: hidden; /* Prevent horizontal scrolling */ 149 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); 150 | padding: 4px 0; 151 | border-radius: 4px; 152 | font-family: var(--vscode-font-family, Arial, sans-serif); 153 | font-size: var(--vscode-font-size, 13px); 154 | } 155 | .asr-context-menu-title { 156 | padding: 4px 8px; 157 | font-weight: bold; 158 | opacity: 0.7; 159 | border-bottom: 1px solid var(--vscode-menu-separatorBackground, #454545); 160 | margin-bottom: 4px; 161 | pointer-events: none; /* Don't intercept clicks */ 162 | } 163 | .asr-context-menu-item { 164 | padding: 4px 12px; 165 | cursor: pointer; 166 | white-space: nowrap; 167 | } 168 | .asr-context-menu-item:hover { 169 | background-color: var(--vscode-menu-selectionBackground, #04395e); 170 | color: var(--vscode-menu-selectionForeground, #ffffff); 171 | } 172 | .asr-context-menu-item.selected { 173 | font-weight: bold; 174 | /* Optional: Add a checkmark or other indicator */ 175 | /* Example: Use a ::before pseudo-element */ 176 | } 177 | .asr-context-menu-item.selected::before { 178 | content: "✓ "; 179 | margin-right: 4px; 180 | } 181 | 182 | /* --- Custom Scrollbar for Context Menu --- */ 183 | .asr-context-menu::-webkit-scrollbar { 184 | width: 6px; /* Thinner scrollbar */ 185 | } 186 | 187 | .asr-context-menu::-webkit-scrollbar-track { 188 | background: var( 189 | --vscode-menu-background, 190 | #252526 191 | ); /* Match menu background */ 192 | border-radius: 3px; 193 | } 194 | 195 | .asr-context-menu::-webkit-scrollbar-thumb { 196 | background-color: var( 197 | --vscode-scrollbarSlider-background, 198 | #4d4d4d 199 | ); /* Subtle thumb color */ 200 | border-radius: 3px; 201 | border: 1px solid var(--vscode-menu-background, #252526); /* Creates a small border effect */ 202 | } 203 | 204 | .asr-context-menu::-webkit-scrollbar-thumb:hover { 205 | background-color: var( 206 | --vscode-scrollbarSlider-hoverBackground, 207 | #6b6b6b 208 | ); /* Darker on hover */ 209 | } 210 | 211 | /* Firefox scrollbar styling */ 212 | .asr-context-menu { 213 | scrollbar-width: thin; /* Use thin scrollbar */ 214 | scrollbar-color: var(--vscode-scrollbarSlider-background, #4d4d4d) 215 | var(--vscode-menu-background, #252526); /* thumb track */ 216 | } 217 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentAsrInstance, setCurrentAsrInstance } from "./asr/instance"; 2 | import { initializeASRSystem } from "./asr/manager"; 3 | import * as CONFIG from "./config"; 4 | import type { 5 | AsrResultDetail, 6 | AsrStatusUpdateDetail, 7 | MicButtonElement, 8 | MicButtonState, 9 | AsrManagerState, 10 | } from "./types"; 11 | import { DOM_SELECTORS } from "./ui/dom-selectors"; 12 | import { setupMicButtonObserver, updateMicButtonState } from "./ui/mic-button"; 13 | 14 | // Define navigator with gpu property 15 | interface NavigatorWithGPU extends Navigator { 16 | gpu?: unknown; 17 | } 18 | declare const navigator: NavigatorWithGPU; 19 | 20 | (function () { 21 | "use strict"; 22 | 23 | setCurrentAsrInstance(null); 24 | 25 | // --- Check WebGPU Support --- 26 | if (!navigator.gpu) { 27 | console.warn("WebGPU not supported on this browser. ASR will not work."); 28 | } 29 | 30 | // --- Load Transformers.js dynamically --- 31 | let transformersLibLoaded = typeof window.transformers !== "undefined"; 32 | 33 | if (!transformersLibLoaded && typeof require !== "undefined") { 34 | const scriptId = "hf-transformers-script"; 35 | if (!document.getElementById(scriptId)) { 36 | console.log("Loading Hugging Face Transformers library..."); 37 | const script = document.createElement("script"); 38 | script.id = scriptId; 39 | script.type = "module"; 40 | script.textContent = ` 41 | console.log('[ASR] Injected script block executing...'); 42 | console.log('[ASR] Attempting to load Transformers library...'); 43 | try { 44 | const { ${[ 45 | "AutoTokenizer", 46 | "AutoProcessor", 47 | "WhisperForConditionalGeneration", 48 | "TextStreamer", 49 | "full", 50 | "env", 51 | ].join( 52 | "," 53 | )} } = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@${ 54 | CONFIG.HUGGING_FACE_TRANSFORMERS_VERSION 55 | }'); 56 | console.log('[ASR] Transformers library imported successfully.'); 57 | window.transformers = { AutoTokenizer, AutoProcessor, WhisperForConditionalGeneration, TextStreamer, full, env }; 58 | window.transformers.env.backends.onnx.logLevel = 'info'; 59 | console.log('[ASR] Transformers library loaded and configured.'); 60 | document.dispatchEvent(new CustomEvent('transformersLoaded')); 61 | } catch (error) { 62 | console.error("[ASR] Failed to load Hugging Face Transformers library:", error); 63 | } 64 | `; 65 | document.head.appendChild(script); 66 | } 67 | } else if (transformersLibLoaded && window.transformers) { 68 | window.transformers.env.backends.onnx.logLevel = "info"; 69 | } 70 | 71 | console.log("Initializing ASR system..."); 72 | initializeASRSystem(); 73 | console.log("ASR system initialized"); 74 | 75 | // --- Global ASR Status Listener (Updated) --- 76 | document.addEventListener("asrStatusUpdate", (e: Event) => { 77 | const event = e as CustomEvent; 78 | const managerState = event.detail.state; 79 | const message = event.detail.message; 80 | console.log( 81 | `[ASR] Received asrStatusUpdate: State=${managerState}, Msg=${message}` 82 | ); 83 | 84 | let targetMicState: MicButtonState; 85 | switch (managerState) { 86 | case "uninitialized": 87 | case "ready": 88 | case "error": 89 | targetMicState = "idle"; 90 | break; 91 | case "initializing": 92 | case "loading_model": 93 | case "warming_up": 94 | targetMicState = "disabled"; 95 | break; 96 | default: 97 | console.warn( 98 | "[ASR] Unhandled manager state in status listener:", 99 | managerState 100 | ); 101 | targetMicState = "idle"; 102 | } 103 | 104 | if (managerState === "error") { 105 | console.error( 106 | "[ASR System Error]:", 107 | message || "Unknown ASR system error" 108 | ); 109 | } 110 | 111 | document 112 | .querySelectorAll(".mic-btn[data-asr-init]") 113 | .forEach((btn) => { 114 | if (btn.asrState !== targetMicState) { 115 | updateMicButtonState(btn, targetMicState); 116 | } 117 | }); 118 | }); 119 | 120 | // --- Global ASR Result Handler --- 121 | if (!window._asrGlobalHandlerAttached) { 122 | let buffer = ""; 123 | function globalAsrResultHandler(e: Event) { 124 | const event = e as CustomEvent; 125 | const { status, output = "", data } = event.detail; 126 | 127 | const asrInstance = getCurrentAsrInstance(); 128 | if (!asrInstance) return; 129 | const { mic, chatInputContentEditable } = asrInstance; 130 | const currentMicState = mic.asrState; 131 | 132 | if (status === "transcribing_start") { 133 | updateMicButtonState(mic, "transcribing"); 134 | } else if (status === "update") { 135 | buffer += output; 136 | if (currentMicState !== "transcribing") { 137 | updateMicButtonState(mic, "transcribing"); 138 | } 139 | } else if (status === "complete") { 140 | updateReactInput(chatInputContentEditable, buffer, false); 141 | buffer = ""; 142 | updateMicButtonState(mic, "idle"); 143 | chatInputContentEditable.focus(); 144 | } else if (status === "error") { 145 | console.error("Transcription error:", data); 146 | updateMicButtonState( 147 | mic, 148 | "idle", 149 | `Error: ${data || "Unknown transcription error"}` 150 | ); 151 | } 152 | } 153 | document.addEventListener("asrResult", globalAsrResultHandler); 154 | window._asrGlobalHandlerAttached = true; 155 | } 156 | 157 | // --- Setup UI --- 158 | setupMicButtonObserver(); 159 | 160 | const mic = document.querySelector(DOM_SELECTORS.micButton); 161 | const chatInputContentEditable = document.querySelector( 162 | DOM_SELECTORS.fullInputBox 163 | ); 164 | if (mic && chatInputContentEditable) { 165 | setCurrentAsrInstance({ mic, chatInputContentEditable }); 166 | } 167 | 168 | /** 169 | * Updates a React-controlled input by either replacing or appending text 170 | * @param element The input element to update 171 | * @param text The text to set/append 172 | * @param shouldReplace Whether to replace existing text (true) or append (false) 173 | */ 174 | function updateReactInput( 175 | element: HTMLElement, 176 | text: string, 177 | shouldReplace: boolean = false 178 | ) { 179 | if (text === "") { 180 | return; 181 | } 182 | 183 | element.focus(); 184 | 185 | if (shouldReplace) { 186 | if (element.textContent === text) { 187 | return; 188 | } 189 | 190 | const selection = window.getSelection(); 191 | const range = document.createRange(); 192 | 193 | range.selectNodeContents(element); 194 | selection?.removeAllRanges(); 195 | selection?.addRange(range); 196 | 197 | document.execCommand("insertText", false, text); 198 | } else { 199 | const currentContent = element.textContent?.trim() || ""; 200 | let textToAppend = text; 201 | 202 | if (currentContent.length > 0 && !text.startsWith(" ")) { 203 | textToAppend = " " + text; 204 | } 205 | 206 | const selection = window.getSelection(); 207 | const range = document.createRange(); 208 | 209 | range.selectNodeContents(element); 210 | range.collapse(false); 211 | selection?.removeAllRanges(); 212 | selection?.addRange(range); 213 | 214 | document.execCommand("insertText", false, textToAppend); 215 | } 216 | 217 | const inputEvent = new Event("input", { bubbles: true, cancelable: true }); 218 | element.dispatchEvent(inputEvent); 219 | } 220 | })(); 221 | -------------------------------------------------------------------------------- /src/asr/manager.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AsrStatusUpdateDetail, 3 | WorkerMessage, 4 | WorkerResponse, 5 | AsrResultDetail, 6 | AsrManagerState, 7 | NavigatorWithMaybeGPU, 8 | } from "../types"; 9 | import { fromScriptText } from "@aidenlx/esbuild-plugin-inline-worker/utils"; 10 | import WorkerCode from "worker:./worker.ts"; 11 | 12 | declare const navigator: NavigatorWithMaybeGPU; 13 | 14 | // --- Refactored ASR State Management --- 15 | let managerState: AsrManagerState = "uninitialized"; 16 | let managerMessage: string = "Click mic to initialize"; 17 | let worker: Worker | null = null; 18 | let currentWorkerUrl: string | null = null; 19 | 20 | /** 21 | * Updates the manager state and dispatches a global ASR status update event. 22 | */ 23 | function setManagerState(state: AsrManagerState, message?: string) { 24 | // Avoid redundant updates if state and message are the same 25 | if (state === managerState && message === managerMessage) { 26 | return; 27 | } 28 | console.log( 29 | `[ASR Manager] State changing: ${managerState} -> ${state}`, 30 | message ? `(${message})` : "" 31 | ); 32 | managerState = state; 33 | 34 | // Determine user-facing message based on state 35 | switch (state) { 36 | case "uninitialized": 37 | managerMessage = message || "Click mic to initialize"; 38 | break; 39 | case "initializing": 40 | managerMessage = message || "Initializing ASR..."; 41 | break; 42 | case "loading_model": 43 | managerMessage = message || "Loading ASR model..."; 44 | break; 45 | case "warming_up": 46 | managerMessage = message || "Preparing model..."; 47 | break; 48 | case "ready": 49 | managerMessage = message || "ASR Ready"; 50 | break; 51 | case "error": 52 | managerMessage = message || "ASR Error: Unknown"; 53 | break; 54 | default: 55 | console.warn( 56 | "[ASR Manager] setManagerState called with unknown state:", 57 | state 58 | ); 59 | managerMessage = "ASR Status Unknown"; 60 | } 61 | 62 | // Dispatch the manager state directly 63 | console.log( 64 | `[ASR Manager] Dispatching asrStatusUpdate: { state: ${state}, message: ${managerMessage} }` 65 | ); 66 | const detail: AsrStatusUpdateDetail = { 67 | state: state, // Use the AsrManagerState directly 68 | message: managerMessage, 69 | }; 70 | document.dispatchEvent( 71 | new CustomEvent("asrStatusUpdate", { detail }) 72 | ); 73 | } 74 | 75 | /** 76 | * Terminates the worker and resets the state. 77 | * @param errorMessage Optional error message to set. 78 | */ 79 | function cleanupWorker(errorMessage?: string) { 80 | console.warn( 81 | `[ASR Manager] Cleaning up worker. Error: ${errorMessage || "None"}` 82 | ); 83 | if (worker) { 84 | worker.terminate(); 85 | worker = null; 86 | } 87 | if (currentWorkerUrl) { 88 | URL.revokeObjectURL(currentWorkerUrl); 89 | currentWorkerUrl = null; 90 | } 91 | // Ensure state update happens *after* potential termination 92 | setManagerState(errorMessage ? "error" : "uninitialized", errorMessage); 93 | } 94 | 95 | /** 96 | * Creates the ASR worker instance and sets up listeners. 97 | * Should only be called internally. 98 | */ 99 | function createWorker(args?: { onReady?: () => void }): boolean { 100 | const { onReady } = args || {}; 101 | 102 | console.log("[ASR Manager] createWorker called."); 103 | if (worker) { 104 | console.warn( 105 | "[ASR Manager] createWorker called when worker already exists." 106 | ); 107 | return true; 108 | } 109 | if (managerState !== "uninitialized" && managerState !== "error") { 110 | console.warn( 111 | `[ASR Manager] createWorker called in unexpected state: ${managerState}` 112 | ); 113 | return false; 114 | } 115 | 116 | if (!navigator.gpu) { 117 | console.error("[ASR Manager] createWorker: WebGPU not supported."); 118 | setManagerState("error", "WebGPU not supported"); 119 | return false; 120 | } 121 | 122 | setManagerState("initializing", "Creating ASR Worker..."); 123 | 124 | try { 125 | if (currentWorkerUrl) { 126 | URL.revokeObjectURL(currentWorkerUrl); 127 | currentWorkerUrl = null; 128 | } 129 | worker = fromScriptText(WorkerCode, {}); 130 | currentWorkerUrl = (worker as any).objectURL; 131 | 132 | worker.onmessage = (e: MessageEvent) => { 133 | const { status, data, ...rest } = e.data; 134 | console.log("[ASR Manager] Received message from worker:", e.data); 135 | 136 | switch (status) { 137 | case "loading": 138 | // Worker sends progress messages during model download 139 | setManagerState("loading_model", data || "Loading model..."); 140 | break; 141 | case "ready": 142 | // Worker signals model is loaded and warmed up 143 | setManagerState("ready"); 144 | onReady?.(); 145 | break; 146 | case "error": 147 | console.error( 148 | "[ASR Manager] Received error status from Worker:", 149 | data 150 | ); 151 | cleanupWorker(data || "Unknown worker error"); 152 | break; 153 | case "transcribing_start": 154 | case "update": 155 | case "complete": 156 | // Result-related statuses are dispatched via asrResult 157 | document.dispatchEvent( 158 | new CustomEvent("asrResult", { 159 | detail: { status, ...rest, data }, 160 | }) 161 | ); 162 | break; 163 | default: 164 | console.warn( 165 | "[ASR Manager] Received unknown status from worker:", 166 | status 167 | ); 168 | break; 169 | } 170 | }; 171 | 172 | worker.onerror = (err: ErrorEvent) => { 173 | console.error( 174 | "[ASR Manager] Unhandled Worker Error event:", 175 | err.message, 176 | err 177 | ); 178 | cleanupWorker(err.message || "Unhandled worker error"); 179 | }; 180 | 181 | console.log( 182 | "[ASR Manager] Worker instance created, sending initial load message." 183 | ); 184 | const initialMessage: WorkerMessage = { type: "load" }; 185 | worker.postMessage(initialMessage); 186 | // State is already 'initializing', worker onmessage will update state further 187 | return true; 188 | } catch (error: any) { 189 | console.error("[ASR Manager] Failed to instantiate worker:", error); 190 | cleanupWorker(`Failed to create worker: ${error.message || error}`); 191 | return false; 192 | } 193 | } 194 | 195 | /** 196 | * Checks WebGPU support and sets initial state. Does not load the worker. 197 | */ 198 | export function initializeASRSystem(): void { 199 | console.log( 200 | "[ASR Manager] initializeASRSystem called (passive initialization)." 201 | ); 202 | if (managerState !== "uninitialized") { 203 | console.log("[ASR Manager] Already initialized or initializing."); 204 | return; 205 | } 206 | 207 | if (!navigator.gpu) { 208 | console.warn("[ASR Manager] WebGPU not supported. ASR will be disabled."); 209 | setManagerState("error", "WebGPU not supported"); 210 | } else { 211 | console.log( 212 | "[ASR Manager] WebGPU supported. ASR state remains 'uninitialized'." 213 | ); 214 | // Explicitly set state (even if it's the same) to ensure event dispatch if needed 215 | // setManagerState("uninitialized"); // This might be redundant if default is handled 216 | } 217 | } 218 | 219 | /** 220 | * Called by UI elements to trigger the actual ASR worker creation 221 | * and model loading if it hasn't happened yet. 222 | */ 223 | export function triggerASRInitialization(args?: { 224 | onReady?: () => void; 225 | }): void { 226 | console.log( 227 | "[ASR Manager] triggerASRInitialization called. Current state:", 228 | managerState 229 | ); 230 | if (managerState === "uninitialized" || managerState === "error") { 231 | console.log("[ASR Manager] Triggering worker creation..."); 232 | createWorker(args); 233 | } else { 234 | console.log( 235 | "[ASR Manager] Initialization trigger ignored, state is:", 236 | managerState 237 | ); 238 | } 239 | } 240 | 241 | /** 242 | * Sends audio data to the worker for transcription if the worker is ready. 243 | * @param audioData The Float32Array containing the audio samples. 244 | * @param language The target language for transcription. 245 | */ 246 | export function requestTranscription( 247 | audioData: Float32Array, 248 | language: string 249 | ): void { 250 | console.log( 251 | "[ASR Manager] requestTranscription called. Current state:", 252 | managerState 253 | ); 254 | if (managerState === "ready" && worker) { 255 | console.log("[ASR Manager] Worker is ready, posting generate message."); 256 | const message: WorkerMessage = { 257 | type: "generate", 258 | data: { 259 | audio: audioData, 260 | language: language, 261 | }, 262 | }; 263 | worker.postMessage(message); 264 | } else { 265 | console.warn( 266 | `[ASR Manager] Transcription requested but manager state is '${managerState}'. Ignoring.` 267 | ); 268 | if (!worker) { 269 | console.error( 270 | "[ASR Manager] Worker instance is null, cannot transcribe." 271 | ); 272 | } 273 | } 274 | } 275 | 276 | /** Checks if the ASR manager is in a ready state for transcription tasks. */ 277 | export function isWorkerReady(): boolean { 278 | return managerState === "ready"; 279 | } 280 | 281 | /** Gets the current manager state enum value. */ 282 | export function getManagerState(): AsrManagerState { 283 | return managerState; 284 | } 285 | 286 | /** Gets the current manager state message. */ 287 | export function getManagerMessage(): string { 288 | return managerMessage; 289 | } 290 | 291 | /** 292 | * Sends a message to the worker to stop the current transcription. 293 | */ 294 | export function stopWorkerTranscription(): void { 295 | console.log( 296 | "[ASR Manager] stopWorkerTranscription called. Current state:", 297 | managerState 298 | ); 299 | if (worker) { 300 | console.log("[ASR Manager] Sending stop message to worker."); 301 | const stopMessage: WorkerMessage = { type: "stop" }; 302 | worker.postMessage(stopMessage); 303 | } else { 304 | console.warn( 305 | "[ASR Manager] Cannot send stop message: Worker does not exist." 306 | ); 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/ui/context-menu.ts: -------------------------------------------------------------------------------- 1 | import * as CONFIG from "../config"; 2 | 3 | // --- Constants --- 4 | const LOCAL_STORAGE_KEY = "asr_selected_language"; 5 | const CONTEXT_MENU_ID = "asr-language-context-menu"; 6 | 7 | // --- Supported Languages --- 8 | // Updated based on the provided list 9 | // https://github.com/openai/whisper/blob/248b6cb124225dd263bb9bd32d060b6517e067f8/whisper/tokenizer.py#L79 10 | export const SUPPORTED_LANGUAGES = [ 11 | { code: "en", name: "English" }, 12 | { code: "zh", name: "Chinese" }, 13 | { code: "de", name: "German" }, 14 | { code: "es", name: "Spanish" }, 15 | { code: "ru", name: "Russian" }, 16 | { code: "ko", name: "Korean" }, 17 | { code: "fr", name: "French" }, 18 | { code: "ja", name: "Japanese" }, 19 | { code: "pt", name: "Portuguese" }, 20 | { code: "tr", name: "Turkish" }, 21 | { code: "pl", name: "Polish" }, 22 | { code: "ca", name: "Catalan" }, 23 | { code: "nl", name: "Dutch" }, 24 | { code: "ar", name: "Arabic" }, 25 | { code: "sv", name: "Swedish" }, 26 | { code: "it", name: "Italian" }, 27 | { code: "id", name: "Indonesian" }, 28 | { code: "hi", name: "Hindi" }, 29 | { code: "fi", name: "Finnish" }, 30 | { code: "vi", name: "Vietnamese" }, 31 | { code: "he", name: "Hebrew" }, 32 | { code: "uk", name: "Ukrainian" }, 33 | { code: "el", name: "Greek" }, 34 | { code: "ms", name: "Malay" }, 35 | { code: "cs", name: "Czech" }, 36 | { code: "ro", name: "Romanian" }, 37 | { code: "da", name: "Danish" }, 38 | { code: "hu", name: "Hungarian" }, 39 | { code: "ta", name: "Tamil" }, 40 | { code: "no", name: "Norwegian" }, 41 | { code: "th", name: "Thai" }, 42 | { code: "ur", name: "Urdu" }, 43 | { code: "hr", name: "Croatian" }, 44 | { code: "bg", name: "Bulgarian" }, 45 | { code: "lt", name: "Lithuanian" }, 46 | { code: "la", name: "Latin" }, 47 | { code: "mi", name: "Maori" }, 48 | { code: "ml", name: "Malayalam" }, 49 | { code: "cy", name: "Welsh" }, 50 | { code: "sk", name: "Slovak" }, 51 | { code: "te", name: "Telugu" }, 52 | { code: "fa", name: "Persian" }, 53 | { code: "lv", name: "Latvian" }, 54 | { code: "bn", name: "Bengali" }, 55 | { code: "sr", name: "Serbian" }, 56 | { code: "az", name: "Azerbaijani" }, 57 | { code: "sl", name: "Slovenian" }, 58 | { code: "kn", name: "Kannada" }, 59 | { code: "et", name: "Estonian" }, 60 | { code: "mk", name: "Macedonian" }, 61 | { code: "br", name: "Breton" }, 62 | { code: "eu", name: "Basque" }, 63 | { code: "is", name: "Icelandic" }, 64 | { code: "hy", name: "Armenian" }, 65 | { code: "ne", name: "Nepali" }, 66 | { code: "mn", name: "Mongolian" }, 67 | { code: "bs", name: "Bosnian" }, 68 | { code: "kk", name: "Kazakh" }, 69 | { code: "sq", name: "Albanian" }, 70 | { code: "sw", name: "Swahili" }, 71 | { code: "gl", name: "Galician" }, 72 | { code: "mr", name: "Marathi" }, 73 | { code: "pa", name: "Punjabi" }, 74 | { code: "si", name: "Sinhala" }, 75 | { code: "km", name: "Khmer" }, 76 | { code: "sn", name: "Shona" }, 77 | { code: "yo", name: "Yoruba" }, 78 | { code: "so", name: "Somali" }, 79 | { code: "af", name: "Afrikaans" }, 80 | { code: "oc", name: "Occitan" }, 81 | { code: "ka", name: "Georgian" }, 82 | { code: "be", name: "Belarusian" }, 83 | { code: "tg", name: "Tajik" }, 84 | { code: "sd", name: "Sindhi" }, 85 | { code: "gu", name: "Gujarati" }, 86 | { code: "am", name: "Amharic" }, 87 | { code: "yi", name: "Yiddish" }, 88 | { code: "lo", name: "Lao" }, 89 | { code: "uz", name: "Uzbek" }, 90 | { code: "fo", name: "Faroese" }, 91 | { code: "ht", name: "Haitian Creole" }, 92 | { code: "ps", name: "Pashto" }, 93 | { code: "tk", name: "Turkmen" }, 94 | { code: "nn", name: "Nynorsk" }, 95 | { code: "mt", name: "Maltese" }, 96 | { code: "sa", name: "Sanskrit" }, 97 | { code: "lb", name: "Luxembourgish" }, 98 | { code: "my", name: "Myanmar" }, 99 | { code: "bo", name: "Tibetan" }, 100 | { code: "tl", name: "Tagalog" }, 101 | { code: "mg", name: "Malagasy" }, 102 | { code: "as", name: "Assamese" }, 103 | { code: "tt", name: "Tatar" }, 104 | { code: "haw", name: "Hawaiian" }, 105 | { code: "ln", name: "Lingala" }, 106 | { code: "ha", name: "Hausa" }, 107 | { code: "ba", name: "Bashkir" }, 108 | { code: "jw", name: "Javanese" }, 109 | { code: "su", name: "Sundanese" }, 110 | ].toSorted((a, b) => a.name.localeCompare(b.name)); 111 | 112 | export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]["code"]; 113 | 114 | // --- Language Persistence --- 115 | 116 | /** 117 | * Retrieves the currently selected ASR language from localStorage. 118 | * Falls back to the default language specified in config.ts if none is set. 119 | * 120 | * @returns The selected language code. 121 | */ 122 | export function getSelectedLanguage(): LanguageCode { 123 | try { 124 | const storedLanguage = localStorage.getItem(LOCAL_STORAGE_KEY); 125 | // Validate if the stored language is one of the supported ones 126 | if ( 127 | storedLanguage && 128 | SUPPORTED_LANGUAGES.some((lang) => lang.code === storedLanguage) 129 | ) { 130 | return storedLanguage as LanguageCode; 131 | } 132 | } catch (error) { 133 | console.error("Error reading language from localStorage:", error); 134 | } 135 | // Fallback to english 136 | return "en"; 137 | } 138 | 139 | /** 140 | * Stores the selected ASR language in localStorage. 141 | * 142 | * @param languageCode The language code to store. 143 | */ 144 | export function setSelectedLanguage(languageCode: LanguageCode): void { 145 | try { 146 | // Ensure the language is supported before storing 147 | if (SUPPORTED_LANGUAGES.some((lang) => lang.code === languageCode)) { 148 | localStorage.setItem(LOCAL_STORAGE_KEY, languageCode); 149 | console.log(`ASR language set to: ${languageCode}`); 150 | } else { 151 | console.warn(`Attempted to set unsupported language: ${languageCode}`); 152 | } 153 | } catch (error) { 154 | console.error("Error writing language to localStorage:", error); 155 | } 156 | } 157 | 158 | // --- Context Menu Creation and Handling --- 159 | 160 | /** 161 | * Removes any existing context menu from the DOM. 162 | */ 163 | function removeExistingContextMenu(): void { 164 | const existingMenu = document.getElementById(CONTEXT_MENU_ID); 165 | existingMenu?.remove(); 166 | // Remove the global click listener when the menu is removed 167 | document.removeEventListener("click", handleOutsideClick, true); 168 | } 169 | 170 | /** 171 | * Handles clicks outside the context menu to close it. 172 | * Must be captured phase (`true`) to preempt clicks on menu items. 173 | */ 174 | function handleOutsideClick(event: MouseEvent): void { 175 | const menu = document.getElementById(CONTEXT_MENU_ID); 176 | if (menu && !menu.contains(event.target as Node)) { 177 | removeExistingContextMenu(); 178 | } 179 | } 180 | 181 | /** 182 | * Creates and displays the language selection context menu. 183 | * 184 | * @param targetElement The HTML element (e.g., the mic button) to position the menu relative to. 185 | * @param onSelect Callback function triggered when a language is selected. Receives the language code. 186 | */ 187 | export function createLanguageContextMenu( 188 | targetElement: HTMLElement, 189 | onSelect: (languageCode: LanguageCode) => void 190 | ): void { 191 | // Remove any previous menu first 192 | removeExistingContextMenu(); 193 | 194 | const currentLanguage = getSelectedLanguage(); 195 | 196 | const menu = document.createElement("div"); 197 | menu.id = CONTEXT_MENU_ID; 198 | menu.className = "asr-context-menu"; // Add class for styling 199 | menu.style.position = "absolute"; 200 | // Position off-screen initially to measure 201 | menu.style.visibility = "hidden"; 202 | menu.style.top = "-10000px"; 203 | menu.style.left = "-10000px"; 204 | menu.style.zIndex = "10000"; // Ensure it's on top 205 | 206 | const title = document.createElement("div"); 207 | title.className = "asr-context-menu-title"; 208 | title.textContent = "Select Language"; 209 | menu.appendChild(title); 210 | 211 | SUPPORTED_LANGUAGES.forEach((lang) => { 212 | const item = document.createElement("div"); 213 | item.className = "asr-context-menu-item"; 214 | item.textContent = lang.name; 215 | item.dataset.langCode = lang.code; // Store code for easy access 216 | 217 | if (lang.code === currentLanguage) { 218 | item.classList.add("selected"); // Highlight current selection 219 | } 220 | 221 | item.addEventListener("click", (e) => { 222 | e.stopPropagation(); // Prevent outside click handler 223 | const selectedCode = (e.target as HTMLElement).dataset 224 | .langCode as LanguageCode; 225 | if (selectedCode) { 226 | onSelect(selectedCode); 227 | } 228 | removeExistingContextMenu(); // Close menu after selection 229 | }); 230 | 231 | menu.appendChild(item); 232 | }); 233 | 234 | // Append to body to measure dimensions 235 | document.body.appendChild(menu); 236 | 237 | // Get dimensions after rendering (while hidden) 238 | const menuWidth = menu.offsetWidth; 239 | const menuHeight = menu.offsetHeight; 240 | const targetRect = targetElement.getBoundingClientRect(); 241 | const viewportHeight = window.innerHeight; 242 | const scrollY = window.scrollY; 243 | const scrollX = window.scrollX; 244 | 245 | // Calculate potential positions 246 | const verticalOffset = 5; // Small gap 247 | const potentialTop = targetRect.top - menuHeight - verticalOffset; 248 | const potentialBottom = targetRect.bottom + verticalOffset; 249 | const finalLeft = targetRect.left + targetRect.width / 2 - menuWidth / 2; 250 | 251 | let finalTop: number; 252 | 253 | // Decide position: prefer top, but use bottom if top doesn't fit 254 | if (potentialTop >= 0) { 255 | // Fits above 256 | finalTop = potentialTop; 257 | } else if (potentialBottom + menuHeight <= viewportHeight) { 258 | // Doesn't fit above, but fits below 259 | finalTop = potentialBottom; 260 | } else { 261 | // Doesn't fit well either way, default to top (or could choose bottom) 262 | // Let's choose bottom as it's less likely to be cut off at the very top 263 | finalTop = potentialBottom; 264 | // Optional: Add logic here to adjust if it still goes off-screen bottom 265 | // e.g., finalTop = viewportHeight - menuHeight - verticalOffset; 266 | } 267 | 268 | // Apply calculated position and make visible 269 | // Adjust for scroll position 270 | menu.style.top = `${finalTop + scrollY}px`; 271 | menu.style.left = `${finalLeft + scrollX}px`; 272 | menu.style.visibility = "visible"; 273 | 274 | // Add a listener to close the menu when clicking outside 275 | // Use capture phase to catch clicks before they bubble up 276 | // Use setTimeout to allow the current event cycle to complete before adding listener 277 | setTimeout(() => { 278 | document.addEventListener("click", handleOutsideClick, true); 279 | }, 0); 280 | } 281 | 282 | // --- Basic Styling (Inject into the main styles later if preferred) --- 283 | // Removed injectContextMenuStyles function as styles are now in styles.css 284 | -------------------------------------------------------------------------------- /src/asr/worker.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AutoTokenizer as TAutoTokenizer, 3 | AutoProcessor as TAutoProcessor, 4 | WhisperForConditionalGeneration as TWhisperForConditionalGeneration, 5 | TextStreamer as TTextStreamer, 6 | full as TFull, 7 | env as TEnv, 8 | PreTrainedTokenizer, 9 | Processor, 10 | ProgressCallback, 11 | } from "@huggingface/transformers"; 12 | import type { WorkerMessage, WorkerResponse } from "./../types"; 13 | // --- Worker Code Start --- 14 | console.log("[Voice Worker] Code execution started."); 15 | // This code runs in a separate worker thread. 16 | 17 | let cancelRequested = false; // Restore global flag 18 | 19 | // declare self.postMessage 20 | declare const self: { 21 | postMessage: (message: WorkerResponse) => void; 22 | addEventListener: ( 23 | event: string, 24 | callback: (e: MessageEvent) => void 25 | ) => void; 26 | }; 27 | 28 | // --- Module-level Model/Tokenizer/Processor Management --- 29 | let modelId: string = "onnx-community/whisper-base"; // TODO: Make configurable 30 | let tokenizer: PreTrainedTokenizer | null = null; 31 | let processor: Processor | null = null; 32 | let model: TWhisperForConditionalGeneration | null = null; 33 | let modelPromise: Promise | null = null; // Promise resolves when loading/warmup is done 34 | let isModelReady: boolean = false; 35 | let isWarmedUp: boolean = false; 36 | 37 | // Dynamically import transformers library within the worker 38 | let AutoTokenizer: typeof TAutoTokenizer, 39 | AutoProcessor: typeof TAutoProcessor, 40 | WhisperForConditionalGeneration: typeof TWhisperForConditionalGeneration, 41 | TextStreamer: typeof TTextStreamer, 42 | full: typeof TFull, 43 | env: typeof TEnv; 44 | 45 | async function initializeTransformers() { 46 | console.log("[Voice Worker][Init] Initializing Transformers library..."); 47 | // Use the version from config 48 | try { 49 | // TODO: Use config version instead of hardcoding 50 | const module = await import( 51 | // @ts-ignore we are using a CDN 52 | "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.0" 53 | ); 54 | console.log( 55 | "[Voice Worker][Init] Transformers library imported successfully." 56 | ); 57 | ({ 58 | AutoTokenizer, 59 | AutoProcessor, 60 | WhisperForConditionalGeneration, 61 | TextStreamer, 62 | full, 63 | env, 64 | } = module); 65 | // Worker environment settings 66 | env.allowLocalModels = false; 67 | env.backends.onnx.logLevel = "info"; 68 | } catch (error) { 69 | console.error( 70 | "[Voice Worker][Init] Failed to import Transformers library:", 71 | error 72 | ); 73 | throw error; // Re-throw error to ensure it propagates 74 | } 75 | } 76 | 77 | type GenerateParams = { 78 | audio: Float32Array; 79 | language: string; 80 | }; 81 | 82 | // Define a minimal type for the warmup input - Simplified 83 | interface WarmupParams { 84 | input_features: any; // Use any for the dummy input features type 85 | max_new_tokens: number; 86 | generation_config?: any; // Add generation_config as optional any 87 | } 88 | 89 | /** 90 | * Loads the model, tokenizer, and processor. 91 | */ 92 | async function loadModel(progress_callback: ProgressCallback): Promise { 93 | console.log("[Voice Worker][Load] Loading model components..."); 94 | if (!AutoTokenizer) await initializeTransformers(); 95 | 96 | isModelReady = false; 97 | isWarmedUp = false; 98 | 99 | try { 100 | const loadTasks = [ 101 | AutoTokenizer.from_pretrained(modelId, { progress_callback }), 102 | AutoProcessor.from_pretrained(modelId, { progress_callback }), 103 | WhisperForConditionalGeneration.from_pretrained(modelId, { 104 | dtype: { encoder_model: "fp32", decoder_model_merged: "q4" }, 105 | device: "webgpu", 106 | progress_callback, 107 | }), 108 | ]; 109 | const results = await Promise.all(loadTasks); 110 | console.log("[Voice Worker][Load] All model components loaded."); 111 | 112 | tokenizer = results[0] as PreTrainedTokenizer; 113 | processor = results[1] as Processor; 114 | model = results[2] as TWhisperForConditionalGeneration; 115 | 116 | if (!tokenizer || !processor || !model) { 117 | throw new Error( 118 | "[Voice Worker][Load] Model components not assigned correctly after load." 119 | ); 120 | } 121 | 122 | // Model components are loaded, now warmup 123 | await warmupModel(); 124 | 125 | isModelReady = true; // Set ready flag *after* successful warmup 126 | console.log("[Voice Worker][Load] Model is loaded and warmed up."); 127 | } catch (error) { 128 | console.error( 129 | "[Voice Worker][Load] Model loading or warmup failed:", 130 | error 131 | ); 132 | // Reset state 133 | tokenizer = null; 134 | processor = null; 135 | model = null; 136 | isModelReady = false; 137 | isWarmedUp = false; 138 | modelPromise = null; // Allow retry 139 | throw error; // Re-throw to be caught by the message handler 140 | } 141 | } 142 | 143 | /** 144 | * Warms up the loaded model. 145 | */ 146 | async function warmupModel(): Promise { 147 | if (!model || !full) { 148 | console.warn("[Voice Worker][Warmup] Cannot warmup model: Not loaded yet."); 149 | return; 150 | } 151 | if (isWarmedUp) { 152 | console.log("[Voice Worker][Warmup] Model already warmed up."); 153 | return; 154 | } 155 | 156 | console.log("[Voice Worker][Warmup] Warming up model..."); 157 | try { 158 | // Create a dummy input for warmup 159 | const dummyInputFeatures = full([1, 80, 3000], 0.0); // Omit TFull type annotation 160 | // Create minimal generation config for warmup 161 | const dummyGenerationConfig = { 162 | // Add any minimally required fields if known, otherwise empty or default 163 | }; 164 | 165 | const warmupParams: WarmupParams = { 166 | input_features: dummyInputFeatures, 167 | max_new_tokens: 1, 168 | generation_config: dummyGenerationConfig, // Pass the config 169 | }; 170 | // @ts-ignore - Still might need ignore if exact type matching is difficult for warmup 171 | await model.generate(warmupParams); 172 | isWarmedUp = true; 173 | console.log("[Voice Worker][Warmup] Model warmup successful."); 174 | } catch (warmupError) { 175 | console.warn("[Voice Worker][Warmup] Model warmup failed:", warmupError); 176 | isWarmedUp = false; 177 | } 178 | } 179 | 180 | let processing = false; 181 | async function generate({ audio, language }: GenerateParams) { 182 | // --- GUARD CLAUSES --- (Immediate checks) 183 | if (processing) { 184 | console.warn("[Voice Worker][Generate] Already processing audio."); 185 | self.postMessage({ status: "error", data: "Already processing audio." }); 186 | return; 187 | } 188 | if (!audio || audio.length === 0) { 189 | console.warn("[Voice Worker][Generate] No audio data received."); 190 | self.postMessage({ status: "error", data: "No audio data received." }); 191 | return; 192 | } 193 | if (!isModelReady || !tokenizer || !processor || !model) { 194 | console.error( 195 | "[Voice Worker][Generate] Model not ready for transcription." 196 | ); 197 | self.postMessage({ status: "error", data: "Model not ready." }); 198 | return; 199 | } 200 | 201 | // --- START PROCESSING --- (Set flags and notify UI immediately) 202 | processing = true; 203 | cancelRequested = false; // Reset cancellation flag for this run 204 | console.log("[Voice Worker][Generate] Starting transcription process..."); 205 | self.postMessage({ status: "transcribing_start" }); // <<< MOVED HERE 206 | 207 | try { 208 | // --- PROCESS AUDIO --- (Potentially time-consuming) 209 | console.log("[Voice Worker][Generate] Processing audio input..."); 210 | const inputs = await processor(audio); 211 | console.log("[Voice Worker][Generate] Audio processed."); 212 | 213 | // --- SETUP STREAMER --- (Quick) 214 | let startTime: number | null = null; 215 | let numTokens = 0; 216 | let fullOutput = ""; 217 | const callback_function = (output: string) => { 218 | if (cancelRequested) { 219 | console.log("[Voice Worker][Generate] Streamer callback cancelled."); 220 | // How to stop the streamer itself? Requires AbortSignal support. 221 | return; 222 | } 223 | startTime ??= performance.now(); 224 | fullOutput = output; 225 | let tps = 0; 226 | if (numTokens++ > 0 && startTime) { 227 | tps = (numTokens / (performance.now() - startTime)) * 1000; 228 | } 229 | self.postMessage({ 230 | status: "update", 231 | output: fullOutput, 232 | tps: tps ? parseFloat(tps.toFixed(1)) : 0, 233 | numTokens, 234 | }); 235 | }; 236 | console.log("[Voice Worker][Generate] Creating text streamer..."); 237 | const streamer = new TextStreamer(tokenizer, { 238 | skip_prompt: true, 239 | skip_special_tokens: true, 240 | callback_function, 241 | }); 242 | console.log("[Voice Worker][Generate] Text streamer created."); 243 | 244 | // --- GENERATE TRANSCRIPTION --- (The core work) 245 | console.log("[Voice Worker][Generate] Starting model generation..."); 246 | await model.generate({ 247 | ...inputs, 248 | language: language, 249 | streamer, 250 | // TODO: Add abortSignal when available in transformers.js 251 | // signal: abortController.signal // Example 252 | }); 253 | console.log("[Voice Worker][Generate] Model generation finished."); 254 | 255 | // --- HANDLE COMPLETION/CANCELLATION --- (Final checks) 256 | if (cancelRequested) { 257 | console.log( 258 | "[Voice Worker][Generate] Transcription cancelled post-generation. Discarding result." 259 | ); 260 | // No 'complete' message needed if cancelled 261 | } else { 262 | console.log( 263 | "[Voice Worker][Generate] Transcription complete. Sending final message." 264 | ); 265 | self.postMessage({ 266 | status: "complete", 267 | output: fullOutput, // Send the final accumulated output 268 | }); 269 | } 270 | } catch (error: unknown) { 271 | console.error("[Voice Worker][Generate] Transcription failed:", error); 272 | self.postMessage({ 273 | status: "error", 274 | // Improve error reporting 275 | data: `Transcription failed: ${ 276 | error instanceof Error ? error.message : String(error) 277 | }`, 278 | }); 279 | } finally { 280 | console.log("[Voice Worker][Generate] Cleaning up transcription process."); 281 | processing = false; // Ensure processing flag is always reset 282 | } 283 | } 284 | 285 | console.log("[Voice Worker] Setting up message listener."); 286 | self.addEventListener("message", async (e: MessageEvent) => { 287 | console.log("[Voice Worker][Handler] Received message:", e.data); 288 | if (!e.data || typeof e.data !== "object" || !("type" in e.data)) { 289 | console.warn( 290 | "[Voice Worker][Handler] Received invalid message format:", 291 | e.data 292 | ); 293 | return; 294 | } 295 | 296 | const { type, data } = e.data as WorkerMessage; 297 | 298 | switch (type) { 299 | case "load": 300 | console.log("[Voice Worker][Handler] Handling 'load' message."); 301 | if (modelPromise) { 302 | console.log( 303 | "[Voice Worker][Handler] Model loading already in progress or completed." 304 | ); 305 | // Optionally re-send ready if already loaded? For now, just await. 306 | try { 307 | await modelPromise; 308 | // If it resolves successfully and wasn't already ready, send ready 309 | if (isModelReady) { 310 | self.postMessage({ status: "ready" }); 311 | } 312 | } catch (error) { 313 | // Error handled within loadModel, state should be reset 314 | console.error( 315 | "[Voice Worker][Handler] Previous load attempt failed." 316 | ); 317 | // Error message already sent by loadModel's catch block? 318 | // Let's ensure an error state is sent back if we reach here after failure 319 | if (!isModelReady) { 320 | self.postMessage({ 321 | status: "error", 322 | data: `Model initialization failed: ${ 323 | error instanceof Error ? error.message : String(error) 324 | }`, 325 | }); 326 | } 327 | } 328 | return; // Prevent starting a new load if one is/was active 329 | } 330 | 331 | // Start the loading process 332 | modelPromise = loadModel((progressInfo) => { 333 | // Send progress updates back to the main thread 334 | if (progressInfo.status === "progress") { 335 | self.postMessage({ 336 | status: "loading", 337 | data: `Loading: ${ 338 | progressInfo.file 339 | } (${progressInfo.progress.toFixed(0)}%)`, 340 | }); 341 | } else { 342 | // console.debug("[Voice Worker][Handler] Load progress:", progressInfo.status); 343 | } 344 | }); 345 | 346 | try { 347 | await modelPromise; // Await the completion of loading and warmup 348 | // loadModel sets isModelReady and sends console logs 349 | self.postMessage({ status: "ready" }); // Signal ready *after* promise resolves successfully 350 | } catch (error) { 351 | console.error( 352 | "[Voice Worker][Handler] loadModel promise rejected:", 353 | error 354 | ); 355 | // Error should have been posted by loadModel's catch block 356 | // Ensure modelPromise is cleared so retry is possible 357 | modelPromise = null; 358 | if (!isModelReady) { 359 | // Double-check if error was already sent 360 | self.postMessage({ 361 | status: "error", 362 | data: `Model initialization failed: ${ 363 | error instanceof Error ? error.message : String(error) 364 | }`, 365 | }); 366 | } 367 | } 368 | break; 369 | 370 | case "generate": 371 | if (data) { 372 | console.log("[Voice Worker][Handler] Handling 'generate' message."); 373 | // Don't await here, let it run in the background 374 | generate(data); 375 | } else { 376 | console.warn( 377 | "[Voice Worker][Handler] 'generate' message received without data." 378 | ); 379 | self.postMessage({ 380 | status: "error", 381 | data: "Generate request missing audio data.", 382 | }); 383 | } 384 | break; 385 | 386 | case "stop": 387 | console.log("[Voice Worker][Handler] Handling 'stop' message."); 388 | cancelRequested = true; 389 | // TODO: If using AbortController, call abort() here. 390 | // abortController?.abort(); 391 | console.log("[Voice Worker][Handler] Cancellation requested flag set."); 392 | // Note: This stops *sending* updates but doesn't necessarily stop the underlying model.generate() 393 | break; 394 | 395 | default: 396 | console.warn( 397 | "[Voice Worker][Handler] Received unknown message type:", 398 | type 399 | ); 400 | break; 401 | } 402 | }); 403 | 404 | console.log( 405 | "[Voice Worker] Message listener set up. Initial script execution complete." 406 | ); 407 | // --- Worker Code End --- 408 | -------------------------------------------------------------------------------- /src/ui/mic-button.ts: -------------------------------------------------------------------------------- 1 | import styles from "inline:../styles/styles.css"; 2 | import { getCurrentAsrInstance, setCurrentAsrInstance } from "../asr/instance"; 3 | import { 4 | getManagerMessage, 5 | getManagerState, 6 | isWorkerReady, 7 | requestTranscription, 8 | triggerASRInitialization, 9 | } from "../asr/manager"; 10 | import { processAudioBlob } from "../audio/processing"; 11 | import type { 12 | AsrStatusUpdateDetail, 13 | MicButtonElement, 14 | MicButtonState, 15 | } from "../types"; 16 | import { 17 | createLanguageContextMenu, 18 | getSelectedLanguage, 19 | setSelectedLanguage, 20 | } from "./context-menu"; 21 | import { DOM_SELECTORS } from "./dom-selectors"; 22 | import { registerHotkey } from "src/utils/hotkey"; 23 | import { HOTKEYS } from "src/config"; 24 | 25 | // --- Shared CSS Injection --- 26 | const styleId = "fadein-width-bar-wave-styles"; 27 | function injectGlobalStyles(): void { 28 | if (!document.getElementById(styleId)) { 29 | const s = document.createElement("style"); 30 | s.id = styleId; 31 | s.textContent = styles; 32 | document.head.appendChild(s); 33 | } 34 | } 35 | 36 | // --- Mic Button State Update --- 37 | export function updateMicButtonState( 38 | button: MicButtonElement | null, 39 | newState: MicButtonState, 40 | message: string = "" 41 | ): void { 42 | if (!button) return; 43 | 44 | // Read the *global* ASR state and message from the manager 45 | const actualAsrState = getManagerState(); 46 | const actualAsrMessage = getManagerMessage(); 47 | 48 | // Determine the effective state for the UI based on global state 49 | let effectiveState: MicButtonState | "loading" | "uninitialized" = newState; 50 | let displayMessage = message; // Message specifically passed to this function 51 | 52 | // --- State Overrides based on Manager State --- 53 | switch (actualAsrState) { 54 | case "uninitialized": 55 | effectiveState = "uninitialized"; 56 | displayMessage = actualAsrMessage || "Click to initialize"; 57 | break; 58 | case "initializing": 59 | case "loading_model": 60 | case "warming_up": 61 | effectiveState = "loading"; // Treat all loading stages as 'loading' visually 62 | displayMessage = actualAsrMessage; 63 | break; 64 | case "error": 65 | effectiveState = "disabled"; 66 | displayMessage = `Error: ${actualAsrMessage}`; 67 | break; 68 | case "ready": 69 | // If manager is ready, allow the requested newState unless it forces disabled 70 | if (newState === "recording") { 71 | effectiveState = "recording"; 72 | displayMessage = message || "Recording..."; 73 | } else if (newState === "transcribing") { 74 | effectiveState = "transcribing"; 75 | displayMessage = message || "Transcribing..."; 76 | } else if (newState === "disabled") { 77 | effectiveState = "disabled"; 78 | displayMessage = message || "Disabled"; 79 | } else { 80 | // Default to idle if ready and no other active state requested 81 | effectiveState = "idle"; 82 | displayMessage = message; 83 | } 84 | break; 85 | // If managerState is not one of the above, something is wrong, default to disabled? 86 | // Or let the initial newState pass through? Let's default to disabled for safety. 87 | default: 88 | console.warn( 89 | `[MicButton] Unexpected manager state: ${actualAsrState}, defaulting UI to disabled.` 90 | ); 91 | effectiveState = "disabled"; 92 | displayMessage = actualAsrMessage || "ASR not ready"; 93 | } 94 | 95 | // Ensure consistency: If the effective state requires ASR readiness (recording, transcribing), 96 | // but the manager isn't actually ready (e.g., due to race condition), force disabled. 97 | if ( 98 | (effectiveState === "recording" || effectiveState === "transcribing") && 99 | actualAsrState !== "ready" 100 | ) { 101 | console.warn( 102 | `[MicButton] State mismatch: Requested ${effectiveState} but manager state is ${actualAsrState}. Forcing disabled.` 103 | ); 104 | effectiveState = "disabled"; 105 | displayMessage = actualAsrMessage || "ASR not ready"; 106 | } 107 | 108 | // Update button's internal state marker (used for logic like mouseleave) 109 | button.asrState = 110 | effectiveState === "uninitialized" || effectiveState === "loading" 111 | ? "idle" // Treat loading/uninit as idle internally for logic purposes 112 | : (effectiveState as MicButtonState); // Cast: 'idle'|'recording'|'transcribing'|'disabled' 113 | 114 | // Check if we are staying in the loading state and a spinner already exists 115 | const alreadyHasSpinner = !!button.querySelector(".mic-spinner"); 116 | const isStayingLoading = effectiveState === "loading" && alreadyHasSpinner; 117 | 118 | // Only clear and rebuild if not just updating the message for an existing loading state 119 | if (!isStayingLoading) { 120 | button.classList.remove("active", "transcribing", "disabled"); 121 | button.innerHTML = ""; // Clear previous content 122 | 123 | // Create and append the tooltip span first 124 | const tooltip = document.createElement("span"); 125 | tooltip.className = "status-tooltip"; 126 | button.appendChild(tooltip); 127 | } else { 128 | // If staying loading, ensure tooltip exists 129 | let tooltip = button.querySelector(".status-tooltip"); 130 | if (!tooltip) { 131 | tooltip = document.createElement("span"); 132 | tooltip.className = "status-tooltip"; 133 | button.appendChild(tooltip); // Append if missing 134 | } 135 | } 136 | 137 | // Retrieve the potentially recreated/ensured tooltip reference 138 | const tooltip = button.querySelector(".status-tooltip"); 139 | let iconClass = ""; 140 | let defaultTitle = displayMessage || ""; // Use determined display message 141 | 142 | // Handle tooltip visibility 143 | if (tooltip) { 144 | tooltip.style.display = defaultTitle ? "block" : "none"; 145 | } 146 | 147 | // Only execute the switch if we are not just updating the message for loading state 148 | if (!isStayingLoading) { 149 | switch (effectiveState) { 150 | case "recording": 151 | button.classList.add("active"); 152 | iconClass = "codicon-primitive-square"; 153 | break; 154 | case "transcribing": 155 | button.classList.add("transcribing"); 156 | const transcribeControlContainer = document.createElement("div"); 157 | transcribeControlContainer.className = "transcribe-controls"; 158 | const spinnerT = document.createElement("div"); 159 | spinnerT.className = "mic-spinner"; 160 | transcribeControlContainer.appendChild(spinnerT); 161 | const stopBtn = document.createElement("span"); 162 | stopBtn.className = 163 | "codicon codicon-x stop-transcription-btn stop-btn-style"; 164 | stopBtn.setAttribute("title", "Stop Transcription"); 165 | transcribeControlContainer.appendChild(stopBtn); 166 | button.appendChild(transcribeControlContainer); 167 | iconClass = ""; // No main icon 168 | break; 169 | case "loading": 170 | button.classList.add("disabled"); 171 | if (!button.querySelector(".mic-spinner")) { 172 | const spinnerL = document.createElement("div"); 173 | spinnerL.className = "mic-spinner"; 174 | button.appendChild(spinnerL); 175 | } 176 | iconClass = ""; // No main icon 177 | break; 178 | case "disabled": 179 | button.classList.add("disabled"); 180 | if (actualAsrState === "error") { 181 | iconClass = "codicon-error"; 182 | } else { 183 | iconClass = "codicon-mic-off"; // Default disabled icon 184 | } 185 | break; 186 | case "uninitialized": 187 | iconClass = "codicon-mic"; // Same as idle visually 188 | break; 189 | case "idle": 190 | default: 191 | iconClass = "codicon-mic"; 192 | break; 193 | } 194 | 195 | if (iconClass) { 196 | const icon = document.createElement("span"); 197 | icon.className = `codicon ${iconClass} !text-[12px]`; 198 | // Ensure icon is added correctly relative to the tooltip 199 | if (tooltip && tooltip.parentNode === button) { 200 | button.insertBefore(icon, tooltip); 201 | } else { 202 | // Append icon first, then tooltip if it exists 203 | button.appendChild(icon); 204 | if (tooltip && !button.contains(tooltip)) { 205 | button.appendChild(tooltip); 206 | } 207 | } 208 | } 209 | } else { 210 | // If staying loading, ensure disabled class and update title 211 | button.classList.add("disabled"); 212 | } 213 | 214 | // Update tooltip content and button title attribute (always) 215 | if (tooltip) { 216 | tooltip.textContent = defaultTitle; 217 | } 218 | } 219 | 220 | // --- Initialization for each Mic Instance --- 221 | export function initWave(box: HTMLElement): void { 222 | if (box.dataset.waveInit) return; 223 | box.dataset.waveInit = "1"; 224 | 225 | const area = box.querySelector(DOM_SELECTORS.buttonContainer); 226 | const chatInputContentEditable = box.querySelector( 227 | DOM_SELECTORS.chatInputContentEditable 228 | ); 229 | 230 | if (!area || !chatInputContentEditable) { 231 | console.warn( 232 | "Could not find button area or chatInputContentEditable for", 233 | box 234 | ); 235 | return; 236 | } 237 | 238 | // Build DOM 239 | const wrap = document.createElement("div"); 240 | wrap.className = "sv-wrap"; 241 | wrap.style.opacity = "0"; 242 | 243 | const canvas = document.createElement("canvas"); 244 | canvas.width = 120; 245 | canvas.height = 24; 246 | wrap.appendChild(canvas); 247 | 248 | // --- Add cancel button (trash icon) --- 249 | const cancelBtn = document.createElement("div"); // Use div for easier styling 250 | cancelBtn.className = "sv-cancel-btn"; 251 | cancelBtn.setAttribute("title", "Cancel and discard recording"); 252 | cancelBtn.style.display = "none"; // Only show when recording 253 | const cancelIcon = document.createElement("span"); 254 | cancelIcon.className = "codicon codicon-trash !text-[12px]"; 255 | cancelBtn.appendChild(cancelIcon); 256 | // --- 257 | 258 | const mic = document.createElement("div") as MicButtonElement; 259 | mic.className = "mic-btn"; 260 | mic.dataset.asrInit = "1"; 261 | 262 | const statusTooltip = document.createElement("span"); 263 | statusTooltip.className = "status-tooltip"; 264 | mic.appendChild(statusTooltip); 265 | 266 | // Prepend in order: cancel, wrap, mic 267 | area.prepend(mic); 268 | area.prepend(wrap); 269 | area.prepend(cancelBtn); 270 | 271 | // Visualization params 272 | const ctx = canvas.getContext("2d"); 273 | const W = canvas.width, 274 | H = canvas.height; 275 | const BAR_WIDTH = 2, 276 | BAR_GAP = 1, 277 | STEP = BAR_WIDTH + BAR_GAP; 278 | const SLOTS = Math.floor(W / STEP); 279 | const MIN_H = 1, 280 | MAX_H = H - 2, 281 | SENS = 3.5, 282 | SCROLL = 0.5; 283 | let amps = new Array(SLOTS).fill(MIN_H); 284 | let alphas = new Array(SLOTS).fill(1); 285 | let offset = 0; 286 | 287 | // Audio state variables 288 | let audioCtx: AudioContext | null = null; 289 | let analyser: AnalyserNode | null = null; 290 | let dataArr: Uint8Array | null = null; 291 | let stream: MediaStream | null = null; 292 | let sourceNode: MediaStreamAudioSourceNode | null = null; 293 | let raf: number | null = null; 294 | let mediaRecorder: MediaRecorder | null = null; 295 | let audioChunks: Blob[] = []; 296 | let isCancelled = false; 297 | 298 | // Set initial button state based on current global state 299 | updateMicButtonState(mic, "idle"); 300 | 301 | // Listen to global ASR status updates (using AsrStatusUpdateDetail) 302 | const handleAsrStatusUpdate = (event: Event) => { 303 | const customEvent = event as CustomEvent; 304 | console.log("[MicButton] ASR Status Update Received:", customEvent.detail); 305 | // Trigger a UI update based on the new global state 306 | // Pass the button's current internal state as the base for comparison/override 307 | updateMicButtonState(mic, mic.asrState || "idle"); 308 | }; 309 | document.addEventListener( 310 | "asrStatusUpdate", 311 | handleAsrStatusUpdate as EventListener 312 | ); 313 | // TODO: Add cleanup for this listener if the element is removed 314 | 315 | // --- Internal Helper Functions --- 316 | function draw() { 317 | if (!analyser || !dataArr || !ctx) return; 318 | analyser.getByteTimeDomainData(dataArr); 319 | let peak = 0; 320 | for (const v of dataArr) peak = Math.max(peak, Math.abs(v - 128) / 128); 321 | peak = Math.min(1, peak * SENS); 322 | const h = MIN_H + peak * (MAX_H - MIN_H); 323 | 324 | offset += SCROLL; 325 | if (offset >= STEP) { 326 | offset -= STEP; 327 | amps.shift(); 328 | alphas.shift(); 329 | amps.push(h); 330 | alphas.push(0); 331 | } 332 | 333 | ctx.clearRect(0, 0, W, H); 334 | ctx.lineWidth = BAR_WIDTH; 335 | ctx.lineCap = "round"; 336 | 337 | for (let i = 0; i < SLOTS; i++) { 338 | const barH = amps[i]; 339 | if (alphas[i] < 1) alphas[i] = Math.min(1, alphas[i] + 0.1); 340 | const x = i * STEP - offset + BAR_WIDTH / 2; 341 | const y1 = (H - barH) / 2, 342 | y2 = y1 + barH; 343 | ctx.strokeStyle = "#0cf"; 344 | ctx.globalAlpha = alphas[i]; 345 | ctx.beginPath(); 346 | ctx.moveTo(x, y1); 347 | ctx.lineTo(x, y2); 348 | ctx.stroke(); 349 | } 350 | ctx.globalAlpha = 1; 351 | raf = requestAnimationFrame(draw); 352 | } 353 | 354 | function stopVisualization() { 355 | if (raf !== null) cancelAnimationFrame(raf); 356 | raf = null; 357 | wrap.style.opacity = "0"; 358 | wrap.style.width = "0"; 359 | setTimeout(() => { 360 | // Use mic's internal state, not global status 361 | // Linter error fixed here 362 | const currentMicState = mic.asrState; 363 | if (currentMicState !== "recording" && ctx) { 364 | ctx.clearRect(0, 0, W, H); 365 | } 366 | }, 300); 367 | amps.fill(MIN_H); 368 | alphas.fill(1); 369 | offset = 0; 370 | sourceNode?.disconnect(); 371 | analyser = null; 372 | sourceNode = null; 373 | } 374 | 375 | function stopRecording(forceStop: boolean = false) { 376 | // Check the button's *current* visual/intended state, not just manager state 377 | const currentMicState = mic.asrState; // Read from the element attribute/property if you store it there 378 | if (!forceStop && currentMicState !== "recording") return; // Check button's state 379 | 380 | console.log("Stopping recording..."); 381 | stopVisualization(); 382 | 383 | if (mediaRecorder && mediaRecorder.state === "recording") { 384 | try { 385 | mediaRecorder.stop(); // This triggers the onstop handler 386 | } catch (e) { 387 | console.warn("Error stopping MediaRecorder:", e); 388 | } 389 | } 390 | mediaRecorder = null; // Clear recorder ref *after* stopping 391 | 392 | stream?.getTracks().forEach((track) => track.stop()); 393 | stream = null; 394 | 395 | audioCtx 396 | ?.close() 397 | .catch((e) => console.warn("Error closing AudioContext:", e)); 398 | audioCtx = null; 399 | 400 | cancelBtn.style.display = "none"; // Hide cancel when not recording 401 | 402 | // Only update state if forceStop happened without cancellation 403 | // The onstop handler will set transcribing/idle otherwise 404 | if (forceStop && !isCancelled) { 405 | updateMicButtonState(mic, "idle", "Recording stopped"); // Update state after forced stop 406 | } 407 | // Don't reset isCancelled here, let onstop handle it 408 | } 409 | 410 | // --- startRecording: Now only called when ASR is ready --- 411 | function startRecording() { 412 | // Check if the mic is already recording 413 | if (mic.asrState === "recording") { 414 | console.warn("Mic is already recording, ignoring startRecording call."); 415 | return; 416 | } 417 | 418 | // Assume we are in 'ready' state if this function is called. 419 | console.log( 420 | "Attempting to start recording (ASR should be ready)...", 421 | getManagerState() 422 | ); 423 | updateMicButtonState(mic, "recording"); // Update UI to recording state 424 | audioChunks = []; 425 | isCancelled = false; // Reset cancel flag for new recording 426 | 427 | navigator.mediaDevices 428 | .getUserMedia({ audio: true }) 429 | .then((ms) => { 430 | if (mic.asrState !== "recording") { 431 | console.warn( 432 | "Mic state changed away from recording during getUserMedia, aborting." 433 | ); 434 | ms.getTracks().forEach((track) => track.stop()); 435 | updateMicButtonState(mic, "idle"); 436 | return; 437 | } 438 | 439 | stream = ms; 440 | const AudioContext = window.AudioContext; 441 | if (!AudioContext) throw new Error("AudioContext not supported"); 442 | audioCtx = new AudioContext(); 443 | analyser = audioCtx.createAnalyser(); 444 | analyser.fftSize = 1024; 445 | analyser.smoothingTimeConstant = 0.6; 446 | dataArr = new Uint8Array(analyser.frequencyBinCount); 447 | sourceNode = audioCtx.createMediaStreamSource(stream); 448 | sourceNode.connect(analyser); 449 | 450 | wrap.style.width = `${SLOTS * STEP}px`; 451 | wrap.style.opacity = "1"; 452 | raf = requestAnimationFrame(draw); 453 | 454 | cancelBtn.style.display = "inline-flex"; // Show cancel when recording 455 | 456 | try { 457 | const mimeTypes = [ 458 | "audio/webm;codecs=opus", 459 | "audio/ogg;codecs=opus", 460 | "audio/wav", 461 | "audio/mp4", 462 | "audio/webm", 463 | ]; 464 | let selectedMimeType: string | undefined = undefined; 465 | for (const mimeType of mimeTypes) { 466 | if (MediaRecorder.isTypeSupported(mimeType)) { 467 | selectedMimeType = mimeType; 468 | break; 469 | } 470 | } 471 | if (!selectedMimeType) 472 | console.warn("Using browser default MIME type."); 473 | 474 | mediaRecorder = new MediaRecorder(stream, { 475 | mimeType: selectedMimeType, 476 | }); 477 | 478 | mediaRecorder.ondataavailable = (event: BlobEvent) => { 479 | if (event.data.size > 0) audioChunks.push(event.data); 480 | }; 481 | 482 | // --- mediaRecorder.onstop --- 483 | mediaRecorder.onstop = async () => { 484 | console.log("MediaRecorder stopped. isCancelled:", isCancelled); 485 | cancelBtn.style.display = "none"; // Hide cancel button 486 | 487 | if (isCancelled) { 488 | console.log("Recording was cancelled. Discarding audio chunks."); 489 | audioChunks = []; // Clear chunks 490 | updateMicButtonState(mic, "idle"); 491 | isCancelled = false; // Reset flag 492 | return; // Don't process cancelled audio 493 | } 494 | 495 | // Proceed with processing if not cancelled 496 | if (audioChunks.length === 0) { 497 | console.log("No audio chunks recorded."); 498 | updateMicButtonState(mic, "idle", "No audio recorded"); 499 | return; 500 | } 501 | 502 | console.log("Processing recorded audio chunks..."); 503 | updateMicButtonState(mic, "transcribing"); // Set state to transcribing 504 | 505 | const audioBlob = new Blob(audioChunks, { 506 | type: mediaRecorder?.mimeType || "audio/webm", 507 | }); 508 | audioChunks = []; // Clear chunks after creating blob 509 | 510 | try { 511 | const float32Array = await processAudioBlob(audioBlob); 512 | // Get the currently selected language from localStorage 513 | const currentLanguage = getSelectedLanguage(); 514 | console.log(`Requesting transcription in: ${currentLanguage}`); 515 | 516 | if (float32Array && isWorkerReady()) { 517 | updateMicButtonState(mic, "transcribing"); // Update message 518 | requestTranscription(float32Array, currentLanguage); // Use selected language 519 | } else if (!float32Array) { 520 | console.error("Audio processing failed."); 521 | updateMicButtonState(mic, "idle", "Audio processing failed"); 522 | } else { 523 | console.error("ASR worker not ready for transcription."); 524 | updateMicButtonState(mic, "idle", "ASR worker not ready"); 525 | } 526 | } catch (procError) { 527 | console.error("Error processing audio blob:", procError); 528 | updateMicButtonState(mic, "idle", "Error processing audio"); 529 | } 530 | }; // --- End of onstop --- 531 | 532 | mediaRecorder.onerror = (event: Event) => { 533 | console.error("MediaRecorder Error:", (event as ErrorEvent).error); 534 | updateMicButtonState(mic, "idle", "Recording error"); 535 | stopRecording(true); // Force stop on error 536 | }; 537 | 538 | mediaRecorder.start(); 539 | console.log("MediaRecorder started."); 540 | } catch (e: unknown) { 541 | console.error("Failed to create MediaRecorder:", e); 542 | updateMicButtonState(mic, "idle", "Recorder init failed"); 543 | stopRecording(true); // Force stop 544 | } 545 | }) 546 | .catch((err: Error) => { 547 | console.error("getUserMedia failed:", err); 548 | let message = "Mic access denied or failed"; 549 | if (err.name === "NotAllowedError") 550 | message = "Microphone access denied"; 551 | else if (err.name === "NotFoundError") message = "No microphone found"; 552 | updateMicButtonState(mic, "idle", message); // Update state to idle with error message 553 | stopRecording(true); // Ensure cleanup and hide cancel button 554 | }); 555 | } 556 | 557 | // --- Event Listeners --- 558 | 559 | registerHotkey(HOTKEYS.TOGGLE_RECORDING, () => { 560 | const managerState = getManagerState(); 561 | 562 | if (managerState === "uninitialized") { 563 | triggerASRInitialization({ 564 | onReady: startRecording, 565 | }); 566 | return; 567 | } 568 | 569 | if (mic.asrState === "recording") { 570 | console.warn("[Hotkey] Stopping recording..."); 571 | // If the mic is already recording, stop the recording 572 | stopRecording(); 573 | } else if (mic.asrState === "idle" || mic.asrState === "disabled") { 574 | console.warn("[Hotkey] Starting recording..."); 575 | // If the mic is idle, start the recording 576 | startRecording(); 577 | } else { 578 | console.warn("[Hotkey] Ignoring hotkey in current state:", mic.asrState); 579 | } 580 | }); 581 | 582 | // Mousedown: Trigger initialization or start recording 583 | mic.addEventListener("click", (e: MouseEvent) => { 584 | if (e.button !== 0) return; // Only left click 585 | 586 | // Set current instance context on any click/mousedown 587 | if (chatInputContentEditable) { 588 | setCurrentAsrInstance({ mic, chatInputContentEditable }); 589 | } 590 | 591 | if (mic.asrState === "recording") { 592 | // If the mic is already recording, stop the recording 593 | stopRecording(); 594 | return; 595 | } 596 | 597 | const managerState = getManagerState(); // Check global status from manager 598 | 599 | console.log("Mousedown detected. ASR State:", managerState); 600 | 601 | switch (managerState) { 602 | case "uninitialized": 603 | console.log("ASR uninitialized, triggering initialization..."); 604 | triggerASRInitialization({ 605 | onReady: startRecording, 606 | }); 607 | updateMicButtonState(mic, "idle", "Initializing..."); // Update state (will show loading based on global status) 608 | break; 609 | case "ready": 610 | console.log("ASR ready, starting recording..."); 611 | startRecording(); // ASR is ready, proceed to record 612 | break; 613 | case "initializing": 614 | case "loading_model": 615 | case "warming_up": 616 | console.log("ASR is currently loading/initializing. Please wait."); 617 | updateMicButtonState(mic, "idle"); // Refresh state to show loading/disabled 618 | break; 619 | case "error": 620 | console.warn("Cannot start recording, ASR is in error state."); 621 | updateMicButtonState(mic, "idle"); // Refresh state to show error 622 | break; 623 | default: 624 | // Handle other states like 'transcribing' - do nothing on mousedown? 625 | console.log("Mousedown ignored in current state:", managerState); 626 | break; 627 | } 628 | }); 629 | 630 | // Cancel button event (Keep as is) 631 | cancelBtn.addEventListener("click", (e) => { 632 | e.preventDefault(); 633 | e.stopPropagation(); 634 | if (mic.asrState === "recording") { 635 | // Only act if currently recording 636 | console.log("Cancel button clicked."); 637 | isCancelled = true; 638 | stopRecording(true); // Force stop recording immediately 639 | // State is updated within stopRecording/onstop now 640 | } 641 | }); 642 | 643 | // --- Context Menu Listener --- 644 | mic.addEventListener("contextmenu", (e: MouseEvent) => { 645 | e.preventDefault(); // Prevent default browser menu 646 | console.log("Right-click detected on mic button."); 647 | 648 | // Set current instance context if needed (might already be set by mousedown) 649 | if (chatInputContentEditable && !getCurrentAsrInstance()) { 650 | setCurrentAsrInstance({ mic, chatInputContentEditable }); 651 | } 652 | 653 | // Create and show the language menu, passing the mic element 654 | createLanguageContextMenu(mic, (selectedLang) => { 655 | // Pass `mic` element instead of coordinates 656 | // This callback runs when a language is selected from the menu 657 | setSelectedLanguage(selectedLang); // Persist the choice 658 | // Optional: Provide feedback to the user, e.g., update tooltip briefly 659 | // updateMicButtonState(mic, "idle", `Language set to ${selectedLang}`); 660 | // The actual language use happens during `requestTranscription` 661 | }); 662 | }); 663 | // -------------------------- 664 | } 665 | 666 | // --- DOM Observer Setup --- 667 | export function setupMicButtonObserver(): void { 668 | // Listen for global status updates once at setup, mainly for initial state 669 | const handleInitialStatus = (event: Event) => { 670 | // Cast to CustomEvent to access detail if needed (optional) 671 | const customEvent = event as CustomEvent; 672 | const state = customEvent.detail.state; // Use the new state property 673 | console.log("Observer setup: Received initial ASR state", state); 674 | // Potentially update any existing buttons if needed, though initWave handles new ones 675 | document 676 | .querySelectorAll(DOM_SELECTORS.fullInputBox) 677 | .forEach((el) => { 678 | const mic = el.querySelector(".mic-btn"); 679 | if (mic && mic.dataset.waveInit) { 680 | // Only update if already initialized by initWave 681 | updateMicButtonState(mic, mic.asrState || "idle"); 682 | } 683 | }); 684 | }; 685 | document.addEventListener( 686 | "asrStatusUpdate", 687 | handleInitialStatus as EventListener, 688 | { 689 | once: true, 690 | } 691 | ); 692 | 693 | const obs = new MutationObserver((records) => { 694 | records.forEach((r) => { 695 | r.addedNodes.forEach((n) => { 696 | if (n instanceof HTMLElement) { 697 | if (n.matches(DOM_SELECTORS.fullInputBox)) { 698 | initWave(n); 699 | } 700 | n.querySelectorAll(DOM_SELECTORS.fullInputBox).forEach( 701 | (el) => { 702 | // Check if already initialized to prevent duplicate listeners/DOM elements 703 | if (!el.querySelector('.mic-btn[data-wave-init="1"]')) { 704 | initWave(el); 705 | } 706 | } 707 | ); 708 | } 709 | }); 710 | // Optional: Handle node removal for cleanup (remove event listeners) 711 | // r.removedNodes.forEach(n => { ... }); 712 | }); 713 | }); 714 | obs.observe(document.documentElement, { childList: true, subtree: true }); 715 | 716 | // Initialize existing elements on load 717 | document 718 | .querySelectorAll(DOM_SELECTORS.fullInputBox) 719 | .forEach((el) => { 720 | if (!el.querySelector('.mic-btn[data-wave-init="1"]')) { 721 | initWave(el); 722 | } 723 | }); 724 | 725 | // Inject styles once 726 | injectGlobalStyles(); 727 | } 728 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "cursor-voice", 6 | "dependencies": { 7 | "@huggingface/transformers": "3.5.0", 8 | "esbuild-plugin-inline-import": "^1.1.0", 9 | "nodemon": "^3.1.9", 10 | }, 11 | "devDependencies": { 12 | "@aidenlx/esbuild-plugin-inline-worker": "^1.0.1", 13 | "@types/bun": "latest", 14 | "@types/web": "^0.0.222", 15 | "esbuild": "^0.25.2", 16 | "typescript": "^5.8.3", 17 | }, 18 | "peerDependencies": { 19 | "typescript": "^5.0.0", 20 | }, 21 | }, 22 | }, 23 | "packages": { 24 | "@aidenlx/esbuild-plugin-inline-worker": ["@aidenlx/esbuild-plugin-inline-worker@1.0.1", "", { "dependencies": { "nanoid": "~4.0.0" }, "peerDependencies": { "esbuild": "^0.17.4" } }, "sha512-of5EuzeFgVoBU4qHCFjxgk3UsHafev9pUMJXZ5DpWRJ6i3pCh7N+B1HHHrphDnT4J4u1XPtRaeMJ8KvvsNwcbQ=="], 25 | 26 | "@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], 27 | 28 | "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag=="], 29 | 30 | "@esbuild/android-arm": ["@esbuild/android-arm@0.25.2", "", { "os": "android", "cpu": "arm" }, "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA=="], 31 | 32 | "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w=="], 33 | 34 | "@esbuild/android-x64": ["@esbuild/android-x64@0.25.2", "", { "os": "android", "cpu": "x64" }, "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg=="], 35 | 36 | "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA=="], 37 | 38 | "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA=="], 39 | 40 | "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w=="], 41 | 42 | "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ=="], 43 | 44 | "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.2", "", { "os": "linux", "cpu": "arm" }, "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g=="], 45 | 46 | "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g=="], 47 | 48 | "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ=="], 49 | 50 | "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w=="], 51 | 52 | "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q=="], 53 | 54 | "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g=="], 55 | 56 | "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw=="], 57 | 58 | "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q=="], 59 | 60 | "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.2", "", { "os": "linux", "cpu": "x64" }, "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg=="], 61 | 62 | "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.2", "", { "os": "none", "cpu": "arm64" }, "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw=="], 63 | 64 | "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.2", "", { "os": "none", "cpu": "x64" }, "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg=="], 65 | 66 | "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg=="], 67 | 68 | "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw=="], 69 | 70 | "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA=="], 71 | 72 | "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q=="], 73 | 74 | "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg=="], 75 | 76 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.2", "", { "os": "win32", "cpu": "x64" }, "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA=="], 77 | 78 | "@huggingface/jinja": ["@huggingface/jinja@0.3.4", "", {}, "sha512-kFFQWJiWwvxezKQnvH1X7GjsECcMljFx+UZK9hx6P26aVHwwidJVTB0ptLfRVZQvVkOGHoMmTGvo4nT0X9hHOA=="], 79 | 80 | "@huggingface/transformers": ["@huggingface/transformers@3.5.0", "", { "dependencies": { "@huggingface/jinja": "^0.3.4", "onnxruntime-node": "1.21.0", "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", "sharp": "^0.34.1" } }, "sha512-/KE7pb22NIJw30gjA56MKuvOI7KgipfcP2n1PSmNKfxXnaBE6T75JUVYWAExp1UNmrwaMm20yzDhSgoCk2NL7w=="], 81 | 82 | "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.1.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A=="], 83 | 84 | "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.1.0" }, "os": "darwin", "cpu": "x64" }, "sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q=="], 85 | 86 | "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA=="], 87 | 88 | "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ=="], 89 | 90 | "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA=="], 91 | 92 | "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew=="], 93 | 94 | "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ=="], 95 | 96 | "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA=="], 97 | 98 | "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q=="], 99 | 100 | "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w=="], 101 | 102 | "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A=="], 103 | 104 | "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.1.0" }, "os": "linux", "cpu": "arm" }, "sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA=="], 105 | 106 | "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.1.0" }, "os": "linux", "cpu": "arm64" }, "sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ=="], 107 | 108 | "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.1.0" }, "os": "linux", "cpu": "s390x" }, "sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA=="], 109 | 110 | "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.1.0" }, "os": "linux", "cpu": "x64" }, "sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA=="], 111 | 112 | "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" }, "os": "linux", "cpu": "arm64" }, "sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ=="], 113 | 114 | "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.1.0" }, "os": "linux", "cpu": "x64" }, "sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg=="], 115 | 116 | "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.1", "", { "dependencies": { "@emnapi/runtime": "^1.4.0" }, "cpu": "none" }, "sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg=="], 117 | 118 | "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw=="], 119 | 120 | "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.1", "", { "os": "win32", "cpu": "x64" }, "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw=="], 121 | 122 | "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], 123 | 124 | "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], 125 | 126 | "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], 127 | 128 | "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], 129 | 130 | "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], 131 | 132 | "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], 133 | 134 | "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], 135 | 136 | "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], 137 | 138 | "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], 139 | 140 | "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], 141 | 142 | "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], 143 | 144 | "@types/bun": ["@types/bun@1.2.10", "", { "dependencies": { "bun-types": "1.2.10" } }, "sha512-eilv6WFM3M0c9ztJt7/g80BDusK98z/FrFwseZgT4bXCq2vPhXD4z8R3oddmAn+R/Nmz9vBn4kweJKmGTZj+lg=="], 145 | 146 | "@types/node": ["@types/node@22.14.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw=="], 147 | 148 | "@types/web": ["@types/web@0.0.222", "", {}, "sha512-vBvVv2L5ArWQenebQiUCtZNpL4mguLCbrZ8aqzVCundYRvwZZzlqBP7EEpoUszLnztmzWmBxQ4+4nHAMFlfYlA=="], 149 | 150 | "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], 151 | 152 | "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], 153 | 154 | "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], 155 | 156 | "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], 157 | 158 | "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], 159 | 160 | "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], 161 | 162 | "bun-types": ["bun-types@1.2.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-b5ITZMnVdf3m1gMvJHG+gIfeJHiQPJak0f7925Hxu6ZN5VKA8AGy4GZ4lM+Xkn6jtWxg5S3ldWvfmXdvnkp3GQ=="], 163 | 164 | "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], 165 | 166 | "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], 167 | 168 | "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], 169 | 170 | "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 171 | 172 | "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 173 | 174 | "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], 175 | 176 | "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], 177 | 178 | "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], 179 | 180 | "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], 181 | 182 | "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], 183 | 184 | "detect-libc": ["detect-libc@2.0.3", "", {}, "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw=="], 185 | 186 | "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], 187 | 188 | "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], 189 | 190 | "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], 191 | 192 | "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], 193 | 194 | "esbuild": ["esbuild@0.25.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.2", "@esbuild/android-arm": "0.25.2", "@esbuild/android-arm64": "0.25.2", "@esbuild/android-x64": "0.25.2", "@esbuild/darwin-arm64": "0.25.2", "@esbuild/darwin-x64": "0.25.2", "@esbuild/freebsd-arm64": "0.25.2", "@esbuild/freebsd-x64": "0.25.2", "@esbuild/linux-arm": "0.25.2", "@esbuild/linux-arm64": "0.25.2", "@esbuild/linux-ia32": "0.25.2", "@esbuild/linux-loong64": "0.25.2", "@esbuild/linux-mips64el": "0.25.2", "@esbuild/linux-ppc64": "0.25.2", "@esbuild/linux-riscv64": "0.25.2", "@esbuild/linux-s390x": "0.25.2", "@esbuild/linux-x64": "0.25.2", "@esbuild/netbsd-arm64": "0.25.2", "@esbuild/netbsd-x64": "0.25.2", "@esbuild/openbsd-arm64": "0.25.2", "@esbuild/openbsd-x64": "0.25.2", "@esbuild/sunos-x64": "0.25.2", "@esbuild/win32-arm64": "0.25.2", "@esbuild/win32-ia32": "0.25.2", "@esbuild/win32-x64": "0.25.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ=="], 195 | 196 | "esbuild-plugin-inline-import": ["esbuild-plugin-inline-import@1.1.0", "", {}, "sha512-b0xX4tPKBdRjX1CkzpnULpEdeTo9vxD+wf83PKvgUYnOEaJfVxLey4q+sfTUPAdDnRRYasJsQUgQW7/e2Gm5Dw=="], 197 | 198 | "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], 199 | 200 | "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], 201 | 202 | "flatbuffers": ["flatbuffers@25.2.10", "", {}, "sha512-7JlN9ZvLDG1McO3kbX0k4v+SUAg48L1rIwEvN6ZQl/eCtgJz9UylTMzE9wrmYrcorgxm3CX/3T/w5VAub99UUw=="], 203 | 204 | "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 205 | 206 | "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], 207 | 208 | "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], 209 | 210 | "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], 211 | 212 | "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], 213 | 214 | "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], 215 | 216 | "has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], 217 | 218 | "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], 219 | 220 | "ignore-by-default": ["ignore-by-default@1.0.1", "", {}, "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA=="], 221 | 222 | "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], 223 | 224 | "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], 225 | 226 | "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], 227 | 228 | "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], 229 | 230 | "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], 231 | 232 | "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], 233 | 234 | "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], 235 | 236 | "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], 237 | 238 | "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], 239 | 240 | "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], 241 | 242 | "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], 243 | 244 | "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], 245 | 246 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 247 | 248 | "nanoid": ["nanoid@4.0.2", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw=="], 249 | 250 | "nodemon": ["nodemon@3.1.9", "", { "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", "minimatch": "^3.1.2", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" }, "bin": { "nodemon": "bin/nodemon.js" } }, "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg=="], 251 | 252 | "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], 253 | 254 | "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], 255 | 256 | "onnxruntime-common": ["onnxruntime-common@1.21.0", "", {}, "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ=="], 257 | 258 | "onnxruntime-node": ["onnxruntime-node@1.21.0", "", { "dependencies": { "global-agent": "^3.0.0", "onnxruntime-common": "1.21.0", "tar": "^7.0.1" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw=="], 259 | 260 | "onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="], 261 | 262 | "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], 263 | 264 | "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], 265 | 266 | "protobufjs": ["protobufjs@7.5.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-Z2E/kOY1QjoMlCytmexzYfDm/w5fKAiRwpSzGtdnXW1zC88Z2yXazHHrOtwCzn+7wSxyE8PYM4rvVcMphF9sOA=="], 267 | 268 | "pstree.remy": ["pstree.remy@1.1.8", "", {}, "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w=="], 269 | 270 | "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], 271 | 272 | "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], 273 | 274 | "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], 275 | 276 | "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], 277 | 278 | "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], 279 | 280 | "sharp": ["sharp@0.34.1", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.7.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.1", "@img/sharp-darwin-x64": "0.34.1", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.1", "@img/sharp-linux-arm64": "0.34.1", "@img/sharp-linux-s390x": "0.34.1", "@img/sharp-linux-x64": "0.34.1", "@img/sharp-linuxmusl-arm64": "0.34.1", "@img/sharp-linuxmusl-x64": "0.34.1", "@img/sharp-wasm32": "0.34.1", "@img/sharp-win32-ia32": "0.34.1", "@img/sharp-win32-x64": "0.34.1" } }, "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg=="], 281 | 282 | "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], 283 | 284 | "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], 285 | 286 | "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], 287 | 288 | "supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], 289 | 290 | "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], 291 | 292 | "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], 293 | 294 | "touch": ["touch@3.1.1", "", { "bin": { "nodetouch": "bin/nodetouch.js" } }, "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA=="], 295 | 296 | "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 297 | 298 | "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], 299 | 300 | "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], 301 | 302 | "undefsafe": ["undefsafe@2.0.5", "", {}, "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA=="], 303 | 304 | "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 305 | 306 | "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], 307 | 308 | "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.22.0-dev.20250409-89f8206ba4", "", {}, "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ=="], 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /dist/yap-for-cursor.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | (() => { 3 | var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { 4 | get: (a, b) => (typeof require !== "undefined" ? require : a)[b] 5 | }) : x)(function(x) { 6 | if (typeof require !== "undefined") return require.apply(this, arguments); 7 | throw Error('Dynamic require of "' + x + '" is not supported'); 8 | }); 9 | 10 | // src/asr/instance.ts 11 | function setCurrentAsrInstance(instance) { 12 | window._currentAsrInstance = instance; 13 | } 14 | function getCurrentAsrInstance() { 15 | return window._currentAsrInstance ?? null; 16 | } 17 | 18 | // node_modules/@aidenlx/esbuild-plugin-inline-worker/dist/utils.js 19 | var toObjectURL = (script) => { 20 | const blob = new Blob([script], { type: "text/javascript" }); 21 | const url = URL.createObjectURL(blob); 22 | return url; 23 | }; 24 | var fromScriptText = (script, options) => { 25 | const url = toObjectURL(script); 26 | const worker2 = new Worker(url, options); 27 | URL.revokeObjectURL(url); 28 | return worker2; 29 | }; 30 | 31 | // inline-worker:/var/folders/wt/r3jjdtb90sl84637qrrd32s00000gn/T/epiw-Fgl3J6/worker_lusbh.ts 32 | var worker_lusbh_default = 'var C=Object.defineProperty,F=Object.defineProperties;var x=Object.getOwnPropertyDescriptors;var V=Object.getOwnPropertySymbols;var H=Object.prototype.hasOwnProperty,A=Object.prototype.propertyIsEnumerable;var w=(e,r,o)=>r in e?C(e,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):e[r]=o,T=(e,r)=>{for(var o in r||(r={}))H.call(r,o)&&w(e,o,r[o]);if(V)for(var o of V(r))A.call(r,o)&&w(e,o,r[o]);return e},M=(e,r)=>F(e,x(r));console.log("[Voice Worker] Code execution started.");var d=!1,m="onnx-community/whisper-base",l=null,i=null,s=null,t=null,n=!1,c=!1,W,G,v,P,y,f;async function z(){console.log("[Voice Worker][Init] Initializing Transformers library...");try{let e=await import("https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.0");console.log("[Voice Worker][Init] Transformers library imported successfully."),{AutoTokenizer:W,AutoProcessor:G,WhisperForConditionalGeneration:v,TextStreamer:P,full:y,env:f}=e,f.allowLocalModels=!1,f.backends.onnx.logLevel="info"}catch(e){throw console.error("[Voice Worker][Init] Failed to import Transformers library:",e),e}}async function S(e){console.log("[Voice Worker][Load] Loading model components..."),W||await z(),n=!1,c=!1;try{let r=[W.from_pretrained(m,{progress_callback:e}),G.from_pretrained(m,{progress_callback:e}),v.from_pretrained(m,{dtype:{encoder_model:"fp32",decoder_model_merged:"q4"},device:"webgpu",progress_callback:e})],o=await Promise.all(r);if(console.log("[Voice Worker][Load] All model components loaded."),l=o[0],i=o[1],s=o[2],!l||!i||!s)throw new Error("[Voice Worker][Load] Model components not assigned correctly after load.");await E(),n=!0,console.log("[Voice Worker][Load] Model is loaded and warmed up.")}catch(r){throw console.error("[Voice Worker][Load] Model loading or warmup failed:",r),l=null,i=null,s=null,n=!1,c=!1,t=null,r}}async function E(){if(!s||!y){console.warn("[Voice Worker][Warmup] Cannot warmup model: Not loaded yet.");return}if(c){console.log("[Voice Worker][Warmup] Model already warmed up.");return}console.log("[Voice Worker][Warmup] Warming up model...");try{let o={input_features:y([1,80,3e3],0),max_new_tokens:1,generation_config:{}};await s.generate(o),c=!0,console.log("[Voice Worker][Warmup] Model warmup successful.")}catch(e){console.warn("[Voice Worker][Warmup] Model warmup failed:",e),c=!1}}var k=!1;async function L({audio:e,language:r}){if(k){console.warn("[Voice Worker][Generate] Already processing audio."),self.postMessage({status:"error",data:"Already processing audio."});return}if(!e||e.length===0){console.warn("[Voice Worker][Generate] No audio data received."),self.postMessage({status:"error",data:"No audio data received."});return}if(!n||!l||!i||!s){console.error("[Voice Worker][Generate] Model not ready for transcription."),self.postMessage({status:"error",data:"Model not ready."});return}k=!0,d=!1,console.log("[Voice Worker][Generate] Starting transcription process..."),self.postMessage({status:"transcribing_start"});try{console.log("[Voice Worker][Generate] Processing audio input...");let o=await i(e);console.log("[Voice Worker][Generate] Audio processed.");let a=null,u=0,g="",h=_=>{if(d){console.log("[Voice Worker][Generate] Streamer callback cancelled.");return}a!=null||(a=performance.now()),g=_;let p=0;u++>0&&a&&(p=u/(performance.now()-a)*1e3),self.postMessage({status:"update",output:g,tps:p?parseFloat(p.toFixed(1)):0,numTokens:u})};console.log("[Voice Worker][Generate] Creating text streamer...");let b=new P(l,{skip_prompt:!0,skip_special_tokens:!0,callback_function:h});console.log("[Voice Worker][Generate] Text streamer created."),console.log("[Voice Worker][Generate] Starting model generation..."),await s.generate(M(T({},o),{language:r,streamer:b})),console.log("[Voice Worker][Generate] Model generation finished."),d?console.log("[Voice Worker][Generate] Transcription cancelled post-generation. Discarding result."):(console.log("[Voice Worker][Generate] Transcription complete. Sending final message."),self.postMessage({status:"complete",output:g}))}catch(o){console.error("[Voice Worker][Generate] Transcription failed:",o),self.postMessage({status:"error",data:`Transcription failed: ${o instanceof Error?o.message:String(o)}`})}finally{console.log("[Voice Worker][Generate] Cleaning up transcription process."),k=!1}}console.log("[Voice Worker] Setting up message listener.");self.addEventListener("message",async e=>{if(console.log("[Voice Worker][Handler] Received message:",e.data),!e.data||typeof e.data!="object"||!("type"in e.data)){console.warn("[Voice Worker][Handler] Received invalid message format:",e.data);return}let{type:r,data:o}=e.data;switch(r){case"load":if(console.log("[Voice Worker][Handler] Handling \'load\' message."),t){console.log("[Voice Worker][Handler] Model loading already in progress or completed.");try{await t,n&&self.postMessage({status:"ready"})}catch(a){console.error("[Voice Worker][Handler] Previous load attempt failed."),n||self.postMessage({status:"error",data:`Model initialization failed: ${a instanceof Error?a.message:String(a)}`})}return}t=S(a=>{a.status==="progress"&&self.postMessage({status:"loading",data:`Loading: ${a.file} (${a.progress.toFixed(0)}%)`})});try{await t,self.postMessage({status:"ready"})}catch(a){console.error("[Voice Worker][Handler] loadModel promise rejected:",a),t=null,n||self.postMessage({status:"error",data:`Model initialization failed: ${a instanceof Error?a.message:String(a)}`})}break;case"generate":o?(console.log("[Voice Worker][Handler] Handling \'generate\' message."),L(o)):(console.warn("[Voice Worker][Handler] \'generate\' message received without data."),self.postMessage({status:"error",data:"Generate request missing audio data."}));break;case"stop":console.log("[Voice Worker][Handler] Handling \'stop\' message."),d=!0,console.log("[Voice Worker][Handler] Cancellation requested flag set.");break;default:console.warn("[Voice Worker][Handler] Received unknown message type:",r);break}});console.log("[Voice Worker] Message listener set up. Initial script execution complete.");\n//# sourceMappingURL=worker_lusbh.ts.map\n'; 33 | 34 | // src/asr/manager.ts 35 | var managerState = "uninitialized"; 36 | var managerMessage = "Click mic to initialize"; 37 | var worker = null; 38 | var currentWorkerUrl = null; 39 | function setManagerState(state, message) { 40 | if (state === managerState && message === managerMessage) { 41 | return; 42 | } 43 | console.log( 44 | `[ASR Manager] State changing: ${managerState} -> ${state}`, 45 | message ? `(${message})` : "" 46 | ); 47 | managerState = state; 48 | switch (state) { 49 | case "uninitialized": 50 | managerMessage = message || "Click mic to initialize"; 51 | break; 52 | case "initializing": 53 | managerMessage = message || "Initializing ASR..."; 54 | break; 55 | case "loading_model": 56 | managerMessage = message || "Loading ASR model..."; 57 | break; 58 | case "warming_up": 59 | managerMessage = message || "Preparing model..."; 60 | break; 61 | case "ready": 62 | managerMessage = message || "ASR Ready"; 63 | break; 64 | case "error": 65 | managerMessage = message || "ASR Error: Unknown"; 66 | break; 67 | default: 68 | console.warn( 69 | "[ASR Manager] setManagerState called with unknown state:", 70 | state 71 | ); 72 | managerMessage = "ASR Status Unknown"; 73 | } 74 | console.log( 75 | `[ASR Manager] Dispatching asrStatusUpdate: { state: ${state}, message: ${managerMessage} }` 76 | ); 77 | const detail = { 78 | state, 79 | // Use the AsrManagerState directly 80 | message: managerMessage 81 | }; 82 | document.dispatchEvent( 83 | new CustomEvent("asrStatusUpdate", { detail }) 84 | ); 85 | } 86 | function cleanupWorker(errorMessage) { 87 | console.warn( 88 | `[ASR Manager] Cleaning up worker. Error: ${errorMessage || "None"}` 89 | ); 90 | if (worker) { 91 | worker.terminate(); 92 | worker = null; 93 | } 94 | if (currentWorkerUrl) { 95 | URL.revokeObjectURL(currentWorkerUrl); 96 | currentWorkerUrl = null; 97 | } 98 | setManagerState(errorMessage ? "error" : "uninitialized", errorMessage); 99 | } 100 | function createWorker(args) { 101 | const { onReady } = args || {}; 102 | console.log("[ASR Manager] createWorker called."); 103 | if (worker) { 104 | console.warn( 105 | "[ASR Manager] createWorker called when worker already exists." 106 | ); 107 | return true; 108 | } 109 | if (managerState !== "uninitialized" && managerState !== "error") { 110 | console.warn( 111 | `[ASR Manager] createWorker called in unexpected state: ${managerState}` 112 | ); 113 | return false; 114 | } 115 | if (!navigator.gpu) { 116 | console.error("[ASR Manager] createWorker: WebGPU not supported."); 117 | setManagerState("error", "WebGPU not supported"); 118 | return false; 119 | } 120 | setManagerState("initializing", "Creating ASR Worker..."); 121 | try { 122 | if (currentWorkerUrl) { 123 | URL.revokeObjectURL(currentWorkerUrl); 124 | currentWorkerUrl = null; 125 | } 126 | worker = fromScriptText(worker_lusbh_default, {}); 127 | currentWorkerUrl = worker.objectURL; 128 | worker.onmessage = (e) => { 129 | const { status, data, ...rest } = e.data; 130 | console.log("[ASR Manager] Received message from worker:", e.data); 131 | switch (status) { 132 | case "loading": 133 | setManagerState("loading_model", data || "Loading model..."); 134 | break; 135 | case "ready": 136 | setManagerState("ready"); 137 | onReady?.(); 138 | break; 139 | case "error": 140 | console.error( 141 | "[ASR Manager] Received error status from Worker:", 142 | data 143 | ); 144 | cleanupWorker(data || "Unknown worker error"); 145 | break; 146 | case "transcribing_start": 147 | case "update": 148 | case "complete": 149 | document.dispatchEvent( 150 | new CustomEvent("asrResult", { 151 | detail: { status, ...rest, data } 152 | }) 153 | ); 154 | break; 155 | default: 156 | console.warn( 157 | "[ASR Manager] Received unknown status from worker:", 158 | status 159 | ); 160 | break; 161 | } 162 | }; 163 | worker.onerror = (err) => { 164 | console.error( 165 | "[ASR Manager] Unhandled Worker Error event:", 166 | err.message, 167 | err 168 | ); 169 | cleanupWorker(err.message || "Unhandled worker error"); 170 | }; 171 | console.log( 172 | "[ASR Manager] Worker instance created, sending initial load message." 173 | ); 174 | const initialMessage = { type: "load" }; 175 | worker.postMessage(initialMessage); 176 | return true; 177 | } catch (error) { 178 | console.error("[ASR Manager] Failed to instantiate worker:", error); 179 | cleanupWorker(`Failed to create worker: ${error.message || error}`); 180 | return false; 181 | } 182 | } 183 | function initializeASRSystem() { 184 | console.log( 185 | "[ASR Manager] initializeASRSystem called (passive initialization)." 186 | ); 187 | if (managerState !== "uninitialized") { 188 | console.log("[ASR Manager] Already initialized or initializing."); 189 | return; 190 | } 191 | if (!navigator.gpu) { 192 | console.warn("[ASR Manager] WebGPU not supported. ASR will be disabled."); 193 | setManagerState("error", "WebGPU not supported"); 194 | } else { 195 | console.log( 196 | "[ASR Manager] WebGPU supported. ASR state remains 'uninitialized'." 197 | ); 198 | } 199 | } 200 | function triggerASRInitialization(args) { 201 | console.log( 202 | "[ASR Manager] triggerASRInitialization called. Current state:", 203 | managerState 204 | ); 205 | if (managerState === "uninitialized" || managerState === "error") { 206 | console.log("[ASR Manager] Triggering worker creation..."); 207 | createWorker(args); 208 | } else { 209 | console.log( 210 | "[ASR Manager] Initialization trigger ignored, state is:", 211 | managerState 212 | ); 213 | } 214 | } 215 | function requestTranscription(audioData, language) { 216 | console.log( 217 | "[ASR Manager] requestTranscription called. Current state:", 218 | managerState 219 | ); 220 | if (managerState === "ready" && worker) { 221 | console.log("[ASR Manager] Worker is ready, posting generate message."); 222 | const message = { 223 | type: "generate", 224 | data: { 225 | audio: audioData, 226 | language 227 | } 228 | }; 229 | worker.postMessage(message); 230 | } else { 231 | console.warn( 232 | `[ASR Manager] Transcription requested but manager state is '${managerState}'. Ignoring.` 233 | ); 234 | if (!worker) { 235 | console.error( 236 | "[ASR Manager] Worker instance is null, cannot transcribe." 237 | ); 238 | } 239 | } 240 | } 241 | function isWorkerReady() { 242 | return managerState === "ready"; 243 | } 244 | function getManagerState() { 245 | return managerState; 246 | } 247 | function getManagerMessage() { 248 | return managerMessage; 249 | } 250 | 251 | // src/config.ts 252 | var HUGGING_FACE_TRANSFORMERS_VERSION = "3.5.0"; 253 | var TARGET_SAMPLE_RATE = 16e3; 254 | var HOTKEYS = { 255 | TOGGLE_RECORDING: "cmd+shift+y" 256 | }; 257 | 258 | // src/ui/dom-selectors.ts 259 | var DOM_SELECTORS = { 260 | micButton: ".mic-btn[data-asr-init]", 261 | chatInputContentEditable: ".aislash-editor-input[contenteditable='true']", 262 | fullInputBox: ".full-input-box", 263 | buttonContainer: ".button-container.composer-button-area" 264 | }; 265 | 266 | // _134891hjb:/Users/mika/experiments/yap-for-cursor/src/styles/styles.css 267 | var styles_default = `.sv-wrap { 268 | width: 0; 269 | height: 24px; 270 | opacity: 0; 271 | overflow: hidden; 272 | transition: width 0.3s ease, opacity 0.3s ease; 273 | margin-right: 2px; 274 | border-radius: 4px; 275 | vertical-align: middle; 276 | display: inline-block; 277 | position: relative; 278 | mask-image: linear-gradient( 279 | to right, 280 | transparent 0, 281 | black 10px, 282 | black calc(100% - 10px), 283 | transparent 100% 284 | ); 285 | } 286 | .mic-btn { 287 | cursor: pointer; 288 | padding: 4px; 289 | border-radius: 10px; 290 | transition: background 0.2s, color 0.2s; 291 | display: inline-flex; 292 | align-items: center; 293 | justify-content: center; 294 | vertical-align: middle; 295 | position: relative; 296 | color: #888; 297 | } 298 | .mic-btn:hover { 299 | background: rgba(0, 0, 0, 0.05); 300 | color: #555; 301 | } 302 | .mic-btn.active { 303 | color: #e66; 304 | background: rgba(255, 100, 100, 0.1); 305 | } 306 | .mic-btn.transcribing { 307 | color: #0cf; 308 | background: rgba(0, 200, 255, 0.1); 309 | } 310 | .mic-btn.disabled { 311 | cursor: not-allowed; 312 | color: #bbb; 313 | background: transparent !important; 314 | } 315 | @keyframes sv-spin { 316 | from { 317 | transform: rotate(0); 318 | } 319 | to { 320 | transform: rotate(360deg); 321 | } 322 | } 323 | .mic-spinner { 324 | width: 12px; 325 | height: 12px; 326 | border: 2px solid rgba(0, 0, 0, 0.2); 327 | border-top-color: #0cf; 328 | border-radius: 10px; 329 | animation: sv-spin 1s linear infinite; 330 | } 331 | .mic-btn.disabled .mic-spinner { 332 | border-top-color: #ccc; 333 | } 334 | .mic-btn.transcribing .mic-spinner { 335 | border-top-color: #0cf; 336 | } 337 | .mic-btn .status-tooltip { 338 | visibility: hidden; 339 | width: 120px; 340 | background-color: #555; 341 | color: #fff; 342 | text-align: center; 343 | border-radius: 6px; 344 | padding: 5px 3px; 345 | position: absolute; 346 | z-index: 1; 347 | bottom: 125%; 348 | left: 50%; 349 | margin-left: -60px; 350 | opacity: 0; 351 | transition: opacity 0.3s; 352 | font-size: 10px; 353 | white-space: nowrap; 354 | overflow: hidden; 355 | text-overflow: ellipsis; 356 | max-width: 120px; 357 | } 358 | .mic-btn .status-tooltip::after { 359 | content: ""; 360 | position: absolute; 361 | top: 100%; 362 | left: 50%; 363 | margin-left: -5px; 364 | border-width: 5px; 365 | border-style: solid; 366 | border-color: #555 transparent transparent transparent; 367 | } 368 | .mic-btn:hover .status-tooltip, 369 | .mic-btn.disabled .status-tooltip { 370 | visibility: visible; 371 | opacity: 1; 372 | } 373 | /* Styles for the cancel button - mimicking mic-btn but red */ 374 | .sv-cancel-btn { 375 | cursor: pointer; 376 | padding: 4px; 377 | border-radius: 50%; 378 | transition: background 0.2s, color 0.2s; 379 | display: inline-flex; 380 | align-items: center; 381 | justify-content: center; 382 | vertical-align: middle; 383 | color: #e66; 384 | margin-right: 2px; 385 | } 386 | .sv-cancel-btn:hover { 387 | background: rgba(255, 100, 100, 0.1); 388 | color: #c33; /* Darker red on hover */ 389 | } 390 | /* Styles for transcribing state controls */ 391 | .transcribe-controls { 392 | display: inline-flex; 393 | align-items: center; 394 | justify-content: center; 395 | gap: 4px; 396 | } 397 | .stop-btn-style { 398 | color: #e66; 399 | cursor: pointer; 400 | font-size: 10px; 401 | } 402 | 403 | /* --- Context Menu Styles --- */ 404 | .asr-context-menu { 405 | position: absolute; /* Ensure position is set */ 406 | z-index: 10000; /* Ensure it's on top */ 407 | background-color: var(--vscode-menu-background, #252526); 408 | border: 1px solid var(--vscode-menu-border, #3c3c3c); 409 | color: var(--vscode-menu-foreground, #cccccc); 410 | min-width: 150px; 411 | max-width: 250px; /* Optional: Prevent excessive width */ 412 | max-height: 40vh; /* Limit height to 40% of viewport height */ 413 | overflow-y: auto; /* Enable vertical scrolling */ 414 | overflow-x: hidden; /* Prevent horizontal scrolling */ 415 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); 416 | padding: 4px 0; 417 | border-radius: 4px; 418 | font-family: var(--vscode-font-family, Arial, sans-serif); 419 | font-size: var(--vscode-font-size, 13px); 420 | } 421 | .asr-context-menu-title { 422 | padding: 4px 8px; 423 | font-weight: bold; 424 | opacity: 0.7; 425 | border-bottom: 1px solid var(--vscode-menu-separatorBackground, #454545); 426 | margin-bottom: 4px; 427 | pointer-events: none; /* Don't intercept clicks */ 428 | } 429 | .asr-context-menu-item { 430 | padding: 4px 12px; 431 | cursor: pointer; 432 | white-space: nowrap; 433 | } 434 | .asr-context-menu-item:hover { 435 | background-color: var(--vscode-menu-selectionBackground, #04395e); 436 | color: var(--vscode-menu-selectionForeground, #ffffff); 437 | } 438 | .asr-context-menu-item.selected { 439 | font-weight: bold; 440 | /* Optional: Add a checkmark or other indicator */ 441 | /* Example: Use a ::before pseudo-element */ 442 | } 443 | .asr-context-menu-item.selected::before { 444 | content: "\u2713 "; 445 | margin-right: 4px; 446 | } 447 | 448 | /* --- Custom Scrollbar for Context Menu --- */ 449 | .asr-context-menu::-webkit-scrollbar { 450 | width: 6px; /* Thinner scrollbar */ 451 | } 452 | 453 | .asr-context-menu::-webkit-scrollbar-track { 454 | background: var( 455 | --vscode-menu-background, 456 | #252526 457 | ); /* Match menu background */ 458 | border-radius: 3px; 459 | } 460 | 461 | .asr-context-menu::-webkit-scrollbar-thumb { 462 | background-color: var( 463 | --vscode-scrollbarSlider-background, 464 | #4d4d4d 465 | ); /* Subtle thumb color */ 466 | border-radius: 3px; 467 | border: 1px solid var(--vscode-menu-background, #252526); /* Creates a small border effect */ 468 | } 469 | 470 | .asr-context-menu::-webkit-scrollbar-thumb:hover { 471 | background-color: var( 472 | --vscode-scrollbarSlider-hoverBackground, 473 | #6b6b6b 474 | ); /* Darker on hover */ 475 | } 476 | 477 | /* Firefox scrollbar styling */ 478 | .asr-context-menu { 479 | scrollbar-width: thin; /* Use thin scrollbar */ 480 | scrollbar-color: var(--vscode-scrollbarSlider-background, #4d4d4d) 481 | var(--vscode-menu-background, #252526); /* thumb track */ 482 | } 483 | `; 484 | 485 | // src/audio/processing.ts 486 | async function processAudioBlob(blob, targetSr = TARGET_SAMPLE_RATE) { 487 | if (!blob || blob.size === 0) return null; 488 | const AudioContext = window.AudioContext; 489 | if (!AudioContext) { 490 | console.error("Browser does not support AudioContext."); 491 | return null; 492 | } 493 | const audioContext = new AudioContext(); 494 | try { 495 | const arrayBuffer = await blob.arrayBuffer(); 496 | const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); 497 | if (audioBuffer.sampleRate === targetSr) { 498 | await audioContext.close(); 499 | return audioBuffer.getChannelData(0); 500 | } 501 | console.log(`Resampling from ${audioBuffer.sampleRate}Hz to ${targetSr}Hz`); 502 | const duration = audioBuffer.duration; 503 | const offlineContext = new OfflineAudioContext( 504 | 1, 505 | // Mono 506 | Math.ceil(duration * targetSr), 507 | // Calculate output buffer size correctly 508 | targetSr 509 | ); 510 | const bufferSource = offlineContext.createBufferSource(); 511 | bufferSource.buffer = audioBuffer; 512 | bufferSource.connect(offlineContext.destination); 513 | bufferSource.start(); 514 | const resampledBuffer = await offlineContext.startRendering(); 515 | await audioContext.close(); 516 | return resampledBuffer.getChannelData(0); 517 | } catch (error) { 518 | console.error("Audio processing failed:", error); 519 | if (audioContext && audioContext.state !== "closed") { 520 | await audioContext.close(); 521 | } 522 | return null; 523 | } 524 | } 525 | 526 | // src/ui/context-menu.ts 527 | var LOCAL_STORAGE_KEY = "asr_selected_language"; 528 | var CONTEXT_MENU_ID = "asr-language-context-menu"; 529 | var SUPPORTED_LANGUAGES = [ 530 | { code: "en", name: "English" }, 531 | { code: "zh", name: "Chinese" }, 532 | { code: "de", name: "German" }, 533 | { code: "es", name: "Spanish" }, 534 | { code: "ru", name: "Russian" }, 535 | { code: "ko", name: "Korean" }, 536 | { code: "fr", name: "French" }, 537 | { code: "ja", name: "Japanese" }, 538 | { code: "pt", name: "Portuguese" }, 539 | { code: "tr", name: "Turkish" }, 540 | { code: "pl", name: "Polish" }, 541 | { code: "ca", name: "Catalan" }, 542 | { code: "nl", name: "Dutch" }, 543 | { code: "ar", name: "Arabic" }, 544 | { code: "sv", name: "Swedish" }, 545 | { code: "it", name: "Italian" }, 546 | { code: "id", name: "Indonesian" }, 547 | { code: "hi", name: "Hindi" }, 548 | { code: "fi", name: "Finnish" }, 549 | { code: "vi", name: "Vietnamese" }, 550 | { code: "he", name: "Hebrew" }, 551 | { code: "uk", name: "Ukrainian" }, 552 | { code: "el", name: "Greek" }, 553 | { code: "ms", name: "Malay" }, 554 | { code: "cs", name: "Czech" }, 555 | { code: "ro", name: "Romanian" }, 556 | { code: "da", name: "Danish" }, 557 | { code: "hu", name: "Hungarian" }, 558 | { code: "ta", name: "Tamil" }, 559 | { code: "no", name: "Norwegian" }, 560 | { code: "th", name: "Thai" }, 561 | { code: "ur", name: "Urdu" }, 562 | { code: "hr", name: "Croatian" }, 563 | { code: "bg", name: "Bulgarian" }, 564 | { code: "lt", name: "Lithuanian" }, 565 | { code: "la", name: "Latin" }, 566 | { code: "mi", name: "Maori" }, 567 | { code: "ml", name: "Malayalam" }, 568 | { code: "cy", name: "Welsh" }, 569 | { code: "sk", name: "Slovak" }, 570 | { code: "te", name: "Telugu" }, 571 | { code: "fa", name: "Persian" }, 572 | { code: "lv", name: "Latvian" }, 573 | { code: "bn", name: "Bengali" }, 574 | { code: "sr", name: "Serbian" }, 575 | { code: "az", name: "Azerbaijani" }, 576 | { code: "sl", name: "Slovenian" }, 577 | { code: "kn", name: "Kannada" }, 578 | { code: "et", name: "Estonian" }, 579 | { code: "mk", name: "Macedonian" }, 580 | { code: "br", name: "Breton" }, 581 | { code: "eu", name: "Basque" }, 582 | { code: "is", name: "Icelandic" }, 583 | { code: "hy", name: "Armenian" }, 584 | { code: "ne", name: "Nepali" }, 585 | { code: "mn", name: "Mongolian" }, 586 | { code: "bs", name: "Bosnian" }, 587 | { code: "kk", name: "Kazakh" }, 588 | { code: "sq", name: "Albanian" }, 589 | { code: "sw", name: "Swahili" }, 590 | { code: "gl", name: "Galician" }, 591 | { code: "mr", name: "Marathi" }, 592 | { code: "pa", name: "Punjabi" }, 593 | { code: "si", name: "Sinhala" }, 594 | { code: "km", name: "Khmer" }, 595 | { code: "sn", name: "Shona" }, 596 | { code: "yo", name: "Yoruba" }, 597 | { code: "so", name: "Somali" }, 598 | { code: "af", name: "Afrikaans" }, 599 | { code: "oc", name: "Occitan" }, 600 | { code: "ka", name: "Georgian" }, 601 | { code: "be", name: "Belarusian" }, 602 | { code: "tg", name: "Tajik" }, 603 | { code: "sd", name: "Sindhi" }, 604 | { code: "gu", name: "Gujarati" }, 605 | { code: "am", name: "Amharic" }, 606 | { code: "yi", name: "Yiddish" }, 607 | { code: "lo", name: "Lao" }, 608 | { code: "uz", name: "Uzbek" }, 609 | { code: "fo", name: "Faroese" }, 610 | { code: "ht", name: "Haitian Creole" }, 611 | { code: "ps", name: "Pashto" }, 612 | { code: "tk", name: "Turkmen" }, 613 | { code: "nn", name: "Nynorsk" }, 614 | { code: "mt", name: "Maltese" }, 615 | { code: "sa", name: "Sanskrit" }, 616 | { code: "lb", name: "Luxembourgish" }, 617 | { code: "my", name: "Myanmar" }, 618 | { code: "bo", name: "Tibetan" }, 619 | { code: "tl", name: "Tagalog" }, 620 | { code: "mg", name: "Malagasy" }, 621 | { code: "as", name: "Assamese" }, 622 | { code: "tt", name: "Tatar" }, 623 | { code: "haw", name: "Hawaiian" }, 624 | { code: "ln", name: "Lingala" }, 625 | { code: "ha", name: "Hausa" }, 626 | { code: "ba", name: "Bashkir" }, 627 | { code: "jw", name: "Javanese" }, 628 | { code: "su", name: "Sundanese" } 629 | ].toSorted((a, b) => a.name.localeCompare(b.name)); 630 | function getSelectedLanguage() { 631 | try { 632 | const storedLanguage = localStorage.getItem(LOCAL_STORAGE_KEY); 633 | if (storedLanguage && SUPPORTED_LANGUAGES.some((lang) => lang.code === storedLanguage)) { 634 | return storedLanguage; 635 | } 636 | } catch (error) { 637 | console.error("Error reading language from localStorage:", error); 638 | } 639 | return "en"; 640 | } 641 | function setSelectedLanguage(languageCode) { 642 | try { 643 | if (SUPPORTED_LANGUAGES.some((lang) => lang.code === languageCode)) { 644 | localStorage.setItem(LOCAL_STORAGE_KEY, languageCode); 645 | console.log(`ASR language set to: ${languageCode}`); 646 | } else { 647 | console.warn(`Attempted to set unsupported language: ${languageCode}`); 648 | } 649 | } catch (error) { 650 | console.error("Error writing language to localStorage:", error); 651 | } 652 | } 653 | function removeExistingContextMenu() { 654 | const existingMenu = document.getElementById(CONTEXT_MENU_ID); 655 | existingMenu?.remove(); 656 | document.removeEventListener("click", handleOutsideClick, true); 657 | } 658 | function handleOutsideClick(event) { 659 | const menu = document.getElementById(CONTEXT_MENU_ID); 660 | if (menu && !menu.contains(event.target)) { 661 | removeExistingContextMenu(); 662 | } 663 | } 664 | function createLanguageContextMenu(targetElement, onSelect) { 665 | removeExistingContextMenu(); 666 | const currentLanguage = getSelectedLanguage(); 667 | const menu = document.createElement("div"); 668 | menu.id = CONTEXT_MENU_ID; 669 | menu.className = "asr-context-menu"; 670 | menu.style.position = "absolute"; 671 | menu.style.visibility = "hidden"; 672 | menu.style.top = "-10000px"; 673 | menu.style.left = "-10000px"; 674 | menu.style.zIndex = "10000"; 675 | const title = document.createElement("div"); 676 | title.className = "asr-context-menu-title"; 677 | title.textContent = "Select Language"; 678 | menu.appendChild(title); 679 | SUPPORTED_LANGUAGES.forEach((lang) => { 680 | const item = document.createElement("div"); 681 | item.className = "asr-context-menu-item"; 682 | item.textContent = lang.name; 683 | item.dataset.langCode = lang.code; 684 | if (lang.code === currentLanguage) { 685 | item.classList.add("selected"); 686 | } 687 | item.addEventListener("click", (e) => { 688 | e.stopPropagation(); 689 | const selectedCode = e.target.dataset.langCode; 690 | if (selectedCode) { 691 | onSelect(selectedCode); 692 | } 693 | removeExistingContextMenu(); 694 | }); 695 | menu.appendChild(item); 696 | }); 697 | document.body.appendChild(menu); 698 | const menuWidth = menu.offsetWidth; 699 | const menuHeight = menu.offsetHeight; 700 | const targetRect = targetElement.getBoundingClientRect(); 701 | const viewportHeight = window.innerHeight; 702 | const scrollY = window.scrollY; 703 | const scrollX = window.scrollX; 704 | const verticalOffset = 5; 705 | const potentialTop = targetRect.top - menuHeight - verticalOffset; 706 | const potentialBottom = targetRect.bottom + verticalOffset; 707 | const finalLeft = targetRect.left + targetRect.width / 2 - menuWidth / 2; 708 | let finalTop; 709 | if (potentialTop >= 0) { 710 | finalTop = potentialTop; 711 | } else if (potentialBottom + menuHeight <= viewportHeight) { 712 | finalTop = potentialBottom; 713 | } else { 714 | finalTop = potentialBottom; 715 | } 716 | menu.style.top = `${finalTop + scrollY}px`; 717 | menu.style.left = `${finalLeft + scrollX}px`; 718 | menu.style.visibility = "visible"; 719 | setTimeout(() => { 720 | document.addEventListener("click", handleOutsideClick, true); 721 | }, 0); 722 | } 723 | 724 | // src/utils/hotkey.ts 725 | var registeredHotkeys = /* @__PURE__ */ new Map(); 726 | function getHotkeyId(key, target) { 727 | return `${key}:${target === document ? "document" : "element"}`; 728 | } 729 | function registerHotkey(key, callback, options = {}) { 730 | const { 731 | target = document, 732 | preventDefault = true, 733 | stopPropagation = true, 734 | allowInInputs = true 735 | } = options; 736 | const keys = key.split("+").map((k) => k.trim().toLowerCase()); 737 | const mainKey = keys[keys.length - 1]; 738 | const modifiers = { 739 | ctrl: keys.includes("ctrl") || keys.includes("control"), 740 | alt: keys.includes("alt"), 741 | shift: keys.includes("shift"), 742 | meta: keys.includes("meta") || keys.includes("command") || keys.includes("cmd") 743 | }; 744 | const hotkeyId = getHotkeyId(key, target); 745 | if (registeredHotkeys.has(hotkeyId)) { 746 | const existingRegistration = registeredHotkeys.get(hotkeyId); 747 | existingRegistration.unregister(); 748 | } 749 | const handler = (event) => { 750 | if (!allowInInputs && isInputElement(event.target)) { 751 | return; 752 | } 753 | const keyMatch = event.key.toLowerCase() === mainKey.toLowerCase(); 754 | const ctrlMatch = event.ctrlKey === modifiers.ctrl; 755 | const altMatch = event.altKey === modifiers.alt; 756 | const shiftMatch = event.shiftKey === modifiers.shift; 757 | const metaMatch = event.metaKey === modifiers.meta; 758 | if (keyMatch && ctrlMatch && altMatch && shiftMatch && metaMatch) { 759 | if (preventDefault) { 760 | event.preventDefault(); 761 | } 762 | if (stopPropagation) { 763 | event.stopPropagation(); 764 | } 765 | callback(event); 766 | } 767 | }; 768 | const unregister = () => { 769 | target.removeEventListener("keydown", handler, { 770 | capture: true 771 | }); 772 | registeredHotkeys.delete(hotkeyId); 773 | }; 774 | target.addEventListener("keydown", handler, { 775 | capture: true 776 | }); 777 | registeredHotkeys.set(hotkeyId, { 778 | target, 779 | handler, 780 | unregister 781 | }); 782 | return unregister; 783 | } 784 | function isInputElement(element) { 785 | if (!element) return false; 786 | const tagName = element.tagName.toLowerCase(); 787 | const isContentEditable = element.getAttribute("contenteditable") === "true"; 788 | return tagName === "input" || tagName === "textarea" || tagName === "select" || isContentEditable; 789 | } 790 | 791 | // src/ui/mic-button.ts 792 | var styleId = "fadein-width-bar-wave-styles"; 793 | function injectGlobalStyles() { 794 | if (!document.getElementById(styleId)) { 795 | const s = document.createElement("style"); 796 | s.id = styleId; 797 | s.textContent = styles_default; 798 | document.head.appendChild(s); 799 | } 800 | } 801 | function updateMicButtonState(button, newState, message = "") { 802 | if (!button) return; 803 | const actualAsrState = getManagerState(); 804 | const actualAsrMessage = getManagerMessage(); 805 | let effectiveState = newState; 806 | let displayMessage = message; 807 | switch (actualAsrState) { 808 | case "uninitialized": 809 | effectiveState = "uninitialized"; 810 | displayMessage = actualAsrMessage || "Click to initialize"; 811 | break; 812 | case "initializing": 813 | case "loading_model": 814 | case "warming_up": 815 | effectiveState = "loading"; 816 | displayMessage = actualAsrMessage; 817 | break; 818 | case "error": 819 | effectiveState = "disabled"; 820 | displayMessage = `Error: ${actualAsrMessage}`; 821 | break; 822 | case "ready": 823 | if (newState === "recording") { 824 | effectiveState = "recording"; 825 | displayMessage = message || "Recording..."; 826 | } else if (newState === "transcribing") { 827 | effectiveState = "transcribing"; 828 | displayMessage = message || "Transcribing..."; 829 | } else if (newState === "disabled") { 830 | effectiveState = "disabled"; 831 | displayMessage = message || "Disabled"; 832 | } else { 833 | effectiveState = "idle"; 834 | displayMessage = message; 835 | } 836 | break; 837 | // If managerState is not one of the above, something is wrong, default to disabled? 838 | // Or let the initial newState pass through? Let's default to disabled for safety. 839 | default: 840 | console.warn( 841 | `[MicButton] Unexpected manager state: ${actualAsrState}, defaulting UI to disabled.` 842 | ); 843 | effectiveState = "disabled"; 844 | displayMessage = actualAsrMessage || "ASR not ready"; 845 | } 846 | if ((effectiveState === "recording" || effectiveState === "transcribing") && actualAsrState !== "ready") { 847 | console.warn( 848 | `[MicButton] State mismatch: Requested ${effectiveState} but manager state is ${actualAsrState}. Forcing disabled.` 849 | ); 850 | effectiveState = "disabled"; 851 | displayMessage = actualAsrMessage || "ASR not ready"; 852 | } 853 | button.asrState = effectiveState === "uninitialized" || effectiveState === "loading" ? "idle" : effectiveState; 854 | const alreadyHasSpinner = !!button.querySelector(".mic-spinner"); 855 | const isStayingLoading = effectiveState === "loading" && alreadyHasSpinner; 856 | if (!isStayingLoading) { 857 | button.classList.remove("active", "transcribing", "disabled"); 858 | button.innerHTML = ""; 859 | const tooltip2 = document.createElement("span"); 860 | tooltip2.className = "status-tooltip"; 861 | button.appendChild(tooltip2); 862 | } else { 863 | let tooltip2 = button.querySelector(".status-tooltip"); 864 | if (!tooltip2) { 865 | tooltip2 = document.createElement("span"); 866 | tooltip2.className = "status-tooltip"; 867 | button.appendChild(tooltip2); 868 | } 869 | } 870 | const tooltip = button.querySelector(".status-tooltip"); 871 | let iconClass = ""; 872 | let defaultTitle = displayMessage || ""; 873 | if (tooltip) { 874 | tooltip.style.display = defaultTitle ? "block" : "none"; 875 | } 876 | if (!isStayingLoading) { 877 | switch (effectiveState) { 878 | case "recording": 879 | button.classList.add("active"); 880 | iconClass = "codicon-primitive-square"; 881 | break; 882 | case "transcribing": 883 | button.classList.add("transcribing"); 884 | const transcribeControlContainer = document.createElement("div"); 885 | transcribeControlContainer.className = "transcribe-controls"; 886 | const spinnerT = document.createElement("div"); 887 | spinnerT.className = "mic-spinner"; 888 | transcribeControlContainer.appendChild(spinnerT); 889 | const stopBtn = document.createElement("span"); 890 | stopBtn.className = "codicon codicon-x stop-transcription-btn stop-btn-style"; 891 | stopBtn.setAttribute("title", "Stop Transcription"); 892 | transcribeControlContainer.appendChild(stopBtn); 893 | button.appendChild(transcribeControlContainer); 894 | iconClass = ""; 895 | break; 896 | case "loading": 897 | button.classList.add("disabled"); 898 | if (!button.querySelector(".mic-spinner")) { 899 | const spinnerL = document.createElement("div"); 900 | spinnerL.className = "mic-spinner"; 901 | button.appendChild(spinnerL); 902 | } 903 | iconClass = ""; 904 | break; 905 | case "disabled": 906 | button.classList.add("disabled"); 907 | if (actualAsrState === "error") { 908 | iconClass = "codicon-error"; 909 | } else { 910 | iconClass = "codicon-mic-off"; 911 | } 912 | break; 913 | case "uninitialized": 914 | iconClass = "codicon-mic"; 915 | break; 916 | case "idle": 917 | default: 918 | iconClass = "codicon-mic"; 919 | break; 920 | } 921 | if (iconClass) { 922 | const icon = document.createElement("span"); 923 | icon.className = `codicon ${iconClass} !text-[12px]`; 924 | if (tooltip && tooltip.parentNode === button) { 925 | button.insertBefore(icon, tooltip); 926 | } else { 927 | button.appendChild(icon); 928 | if (tooltip && !button.contains(tooltip)) { 929 | button.appendChild(tooltip); 930 | } 931 | } 932 | } 933 | } else { 934 | button.classList.add("disabled"); 935 | } 936 | if (tooltip) { 937 | tooltip.textContent = defaultTitle; 938 | } 939 | } 940 | function initWave(box) { 941 | if (box.dataset.waveInit) return; 942 | box.dataset.waveInit = "1"; 943 | const area = box.querySelector(DOM_SELECTORS.buttonContainer); 944 | const chatInputContentEditable = box.querySelector( 945 | DOM_SELECTORS.chatInputContentEditable 946 | ); 947 | if (!area || !chatInputContentEditable) { 948 | console.warn( 949 | "Could not find button area or chatInputContentEditable for", 950 | box 951 | ); 952 | return; 953 | } 954 | const wrap = document.createElement("div"); 955 | wrap.className = "sv-wrap"; 956 | wrap.style.opacity = "0"; 957 | const canvas = document.createElement("canvas"); 958 | canvas.width = 120; 959 | canvas.height = 24; 960 | wrap.appendChild(canvas); 961 | const cancelBtn = document.createElement("div"); 962 | cancelBtn.className = "sv-cancel-btn"; 963 | cancelBtn.setAttribute("title", "Cancel and discard recording"); 964 | cancelBtn.style.display = "none"; 965 | const cancelIcon = document.createElement("span"); 966 | cancelIcon.className = "codicon codicon-trash !text-[12px]"; 967 | cancelBtn.appendChild(cancelIcon); 968 | const mic = document.createElement("div"); 969 | mic.className = "mic-btn"; 970 | mic.dataset.asrInit = "1"; 971 | const statusTooltip = document.createElement("span"); 972 | statusTooltip.className = "status-tooltip"; 973 | mic.appendChild(statusTooltip); 974 | area.prepend(mic); 975 | area.prepend(wrap); 976 | area.prepend(cancelBtn); 977 | const ctx = canvas.getContext("2d"); 978 | const W = canvas.width, H = canvas.height; 979 | const BAR_WIDTH = 2, BAR_GAP = 1, STEP = BAR_WIDTH + BAR_GAP; 980 | const SLOTS = Math.floor(W / STEP); 981 | const MIN_H = 1, MAX_H = H - 2, SENS = 3.5, SCROLL = 0.5; 982 | let amps = new Array(SLOTS).fill(MIN_H); 983 | let alphas = new Array(SLOTS).fill(1); 984 | let offset = 0; 985 | let audioCtx = null; 986 | let analyser = null; 987 | let dataArr = null; 988 | let stream = null; 989 | let sourceNode = null; 990 | let raf = null; 991 | let mediaRecorder = null; 992 | let audioChunks = []; 993 | let isCancelled = false; 994 | updateMicButtonState(mic, "idle"); 995 | const handleAsrStatusUpdate = (event) => { 996 | const customEvent = event; 997 | console.log("[MicButton] ASR Status Update Received:", customEvent.detail); 998 | updateMicButtonState(mic, mic.asrState || "idle"); 999 | }; 1000 | document.addEventListener( 1001 | "asrStatusUpdate", 1002 | handleAsrStatusUpdate 1003 | ); 1004 | function draw() { 1005 | if (!analyser || !dataArr || !ctx) return; 1006 | analyser.getByteTimeDomainData(dataArr); 1007 | let peak = 0; 1008 | for (const v of dataArr) peak = Math.max(peak, Math.abs(v - 128) / 128); 1009 | peak = Math.min(1, peak * SENS); 1010 | const h = MIN_H + peak * (MAX_H - MIN_H); 1011 | offset += SCROLL; 1012 | if (offset >= STEP) { 1013 | offset -= STEP; 1014 | amps.shift(); 1015 | alphas.shift(); 1016 | amps.push(h); 1017 | alphas.push(0); 1018 | } 1019 | ctx.clearRect(0, 0, W, H); 1020 | ctx.lineWidth = BAR_WIDTH; 1021 | ctx.lineCap = "round"; 1022 | for (let i = 0; i < SLOTS; i++) { 1023 | const barH = amps[i]; 1024 | if (alphas[i] < 1) alphas[i] = Math.min(1, alphas[i] + 0.1); 1025 | const x = i * STEP - offset + BAR_WIDTH / 2; 1026 | const y1 = (H - barH) / 2, y2 = y1 + barH; 1027 | ctx.strokeStyle = "#0cf"; 1028 | ctx.globalAlpha = alphas[i]; 1029 | ctx.beginPath(); 1030 | ctx.moveTo(x, y1); 1031 | ctx.lineTo(x, y2); 1032 | ctx.stroke(); 1033 | } 1034 | ctx.globalAlpha = 1; 1035 | raf = requestAnimationFrame(draw); 1036 | } 1037 | function stopVisualization() { 1038 | if (raf !== null) cancelAnimationFrame(raf); 1039 | raf = null; 1040 | wrap.style.opacity = "0"; 1041 | wrap.style.width = "0"; 1042 | setTimeout(() => { 1043 | const currentMicState = mic.asrState; 1044 | if (currentMicState !== "recording" && ctx) { 1045 | ctx.clearRect(0, 0, W, H); 1046 | } 1047 | }, 300); 1048 | amps.fill(MIN_H); 1049 | alphas.fill(1); 1050 | offset = 0; 1051 | sourceNode?.disconnect(); 1052 | analyser = null; 1053 | sourceNode = null; 1054 | } 1055 | function stopRecording(forceStop = false) { 1056 | const currentMicState = mic.asrState; 1057 | if (!forceStop && currentMicState !== "recording") return; 1058 | console.log("Stopping recording..."); 1059 | stopVisualization(); 1060 | if (mediaRecorder && mediaRecorder.state === "recording") { 1061 | try { 1062 | mediaRecorder.stop(); 1063 | } catch (e) { 1064 | console.warn("Error stopping MediaRecorder:", e); 1065 | } 1066 | } 1067 | mediaRecorder = null; 1068 | stream?.getTracks().forEach((track) => track.stop()); 1069 | stream = null; 1070 | audioCtx?.close().catch((e) => console.warn("Error closing AudioContext:", e)); 1071 | audioCtx = null; 1072 | cancelBtn.style.display = "none"; 1073 | if (forceStop && !isCancelled) { 1074 | updateMicButtonState(mic, "idle", "Recording stopped"); 1075 | } 1076 | } 1077 | function startRecording() { 1078 | if (mic.asrState === "recording") { 1079 | console.warn("Mic is already recording, ignoring startRecording call."); 1080 | return; 1081 | } 1082 | console.log( 1083 | "Attempting to start recording (ASR should be ready)...", 1084 | getManagerState() 1085 | ); 1086 | updateMicButtonState(mic, "recording"); 1087 | audioChunks = []; 1088 | isCancelled = false; 1089 | navigator.mediaDevices.getUserMedia({ audio: true }).then((ms) => { 1090 | if (mic.asrState !== "recording") { 1091 | console.warn( 1092 | "Mic state changed away from recording during getUserMedia, aborting." 1093 | ); 1094 | ms.getTracks().forEach((track) => track.stop()); 1095 | updateMicButtonState(mic, "idle"); 1096 | return; 1097 | } 1098 | stream = ms; 1099 | const AudioContext = window.AudioContext; 1100 | if (!AudioContext) throw new Error("AudioContext not supported"); 1101 | audioCtx = new AudioContext(); 1102 | analyser = audioCtx.createAnalyser(); 1103 | analyser.fftSize = 1024; 1104 | analyser.smoothingTimeConstant = 0.6; 1105 | dataArr = new Uint8Array(analyser.frequencyBinCount); 1106 | sourceNode = audioCtx.createMediaStreamSource(stream); 1107 | sourceNode.connect(analyser); 1108 | wrap.style.width = `${SLOTS * STEP}px`; 1109 | wrap.style.opacity = "1"; 1110 | raf = requestAnimationFrame(draw); 1111 | cancelBtn.style.display = "inline-flex"; 1112 | try { 1113 | const mimeTypes = [ 1114 | "audio/webm;codecs=opus", 1115 | "audio/ogg;codecs=opus", 1116 | "audio/wav", 1117 | "audio/mp4", 1118 | "audio/webm" 1119 | ]; 1120 | let selectedMimeType = void 0; 1121 | for (const mimeType of mimeTypes) { 1122 | if (MediaRecorder.isTypeSupported(mimeType)) { 1123 | selectedMimeType = mimeType; 1124 | break; 1125 | } 1126 | } 1127 | if (!selectedMimeType) 1128 | console.warn("Using browser default MIME type."); 1129 | mediaRecorder = new MediaRecorder(stream, { 1130 | mimeType: selectedMimeType 1131 | }); 1132 | mediaRecorder.ondataavailable = (event) => { 1133 | if (event.data.size > 0) audioChunks.push(event.data); 1134 | }; 1135 | mediaRecorder.onstop = async () => { 1136 | console.log("MediaRecorder stopped. isCancelled:", isCancelled); 1137 | cancelBtn.style.display = "none"; 1138 | if (isCancelled) { 1139 | console.log("Recording was cancelled. Discarding audio chunks."); 1140 | audioChunks = []; 1141 | updateMicButtonState(mic, "idle"); 1142 | isCancelled = false; 1143 | return; 1144 | } 1145 | if (audioChunks.length === 0) { 1146 | console.log("No audio chunks recorded."); 1147 | updateMicButtonState(mic, "idle", "No audio recorded"); 1148 | return; 1149 | } 1150 | console.log("Processing recorded audio chunks..."); 1151 | updateMicButtonState(mic, "transcribing"); 1152 | const audioBlob = new Blob(audioChunks, { 1153 | type: mediaRecorder?.mimeType || "audio/webm" 1154 | }); 1155 | audioChunks = []; 1156 | try { 1157 | const float32Array = await processAudioBlob(audioBlob); 1158 | const currentLanguage = getSelectedLanguage(); 1159 | console.log(`Requesting transcription in: ${currentLanguage}`); 1160 | if (float32Array && isWorkerReady()) { 1161 | updateMicButtonState(mic, "transcribing"); 1162 | requestTranscription(float32Array, currentLanguage); 1163 | } else if (!float32Array) { 1164 | console.error("Audio processing failed."); 1165 | updateMicButtonState(mic, "idle", "Audio processing failed"); 1166 | } else { 1167 | console.error("ASR worker not ready for transcription."); 1168 | updateMicButtonState(mic, "idle", "ASR worker not ready"); 1169 | } 1170 | } catch (procError) { 1171 | console.error("Error processing audio blob:", procError); 1172 | updateMicButtonState(mic, "idle", "Error processing audio"); 1173 | } 1174 | }; 1175 | mediaRecorder.onerror = (event) => { 1176 | console.error("MediaRecorder Error:", event.error); 1177 | updateMicButtonState(mic, "idle", "Recording error"); 1178 | stopRecording(true); 1179 | }; 1180 | mediaRecorder.start(); 1181 | console.log("MediaRecorder started."); 1182 | } catch (e) { 1183 | console.error("Failed to create MediaRecorder:", e); 1184 | updateMicButtonState(mic, "idle", "Recorder init failed"); 1185 | stopRecording(true); 1186 | } 1187 | }).catch((err) => { 1188 | console.error("getUserMedia failed:", err); 1189 | let message = "Mic access denied or failed"; 1190 | if (err.name === "NotAllowedError") 1191 | message = "Microphone access denied"; 1192 | else if (err.name === "NotFoundError") message = "No microphone found"; 1193 | updateMicButtonState(mic, "idle", message); 1194 | stopRecording(true); 1195 | }); 1196 | } 1197 | registerHotkey(HOTKEYS.TOGGLE_RECORDING, () => { 1198 | const managerState2 = getManagerState(); 1199 | if (managerState2 === "uninitialized") { 1200 | triggerASRInitialization({ 1201 | onReady: startRecording 1202 | }); 1203 | return; 1204 | } 1205 | if (mic.asrState === "recording") { 1206 | console.warn("[Hotkey] Stopping recording..."); 1207 | stopRecording(); 1208 | } else if (mic.asrState === "idle" || mic.asrState === "disabled") { 1209 | console.warn("[Hotkey] Starting recording..."); 1210 | startRecording(); 1211 | } else { 1212 | console.warn("[Hotkey] Ignoring hotkey in current state:", mic.asrState); 1213 | } 1214 | }); 1215 | mic.addEventListener("click", (e) => { 1216 | if (e.button !== 0) return; 1217 | if (chatInputContentEditable) { 1218 | setCurrentAsrInstance({ mic, chatInputContentEditable }); 1219 | } 1220 | if (mic.asrState === "recording") { 1221 | stopRecording(); 1222 | return; 1223 | } 1224 | const managerState2 = getManagerState(); 1225 | console.log("Mousedown detected. ASR State:", managerState2); 1226 | switch (managerState2) { 1227 | case "uninitialized": 1228 | console.log("ASR uninitialized, triggering initialization..."); 1229 | triggerASRInitialization({ 1230 | onReady: startRecording 1231 | }); 1232 | updateMicButtonState(mic, "idle", "Initializing..."); 1233 | break; 1234 | case "ready": 1235 | console.log("ASR ready, starting recording..."); 1236 | startRecording(); 1237 | break; 1238 | case "initializing": 1239 | case "loading_model": 1240 | case "warming_up": 1241 | console.log("ASR is currently loading/initializing. Please wait."); 1242 | updateMicButtonState(mic, "idle"); 1243 | break; 1244 | case "error": 1245 | console.warn("Cannot start recording, ASR is in error state."); 1246 | updateMicButtonState(mic, "idle"); 1247 | break; 1248 | default: 1249 | console.log("Mousedown ignored in current state:", managerState2); 1250 | break; 1251 | } 1252 | }); 1253 | cancelBtn.addEventListener("click", (e) => { 1254 | e.preventDefault(); 1255 | e.stopPropagation(); 1256 | if (mic.asrState === "recording") { 1257 | console.log("Cancel button clicked."); 1258 | isCancelled = true; 1259 | stopRecording(true); 1260 | } 1261 | }); 1262 | mic.addEventListener("contextmenu", (e) => { 1263 | e.preventDefault(); 1264 | console.log("Right-click detected on mic button."); 1265 | if (chatInputContentEditable && !getCurrentAsrInstance()) { 1266 | setCurrentAsrInstance({ mic, chatInputContentEditable }); 1267 | } 1268 | createLanguageContextMenu(mic, (selectedLang) => { 1269 | setSelectedLanguage(selectedLang); 1270 | }); 1271 | }); 1272 | } 1273 | function setupMicButtonObserver() { 1274 | const handleInitialStatus = (event) => { 1275 | const customEvent = event; 1276 | const state = customEvent.detail.state; 1277 | console.log("Observer setup: Received initial ASR state", state); 1278 | document.querySelectorAll(DOM_SELECTORS.fullInputBox).forEach((el) => { 1279 | const mic = el.querySelector(".mic-btn"); 1280 | if (mic && mic.dataset.waveInit) { 1281 | updateMicButtonState(mic, mic.asrState || "idle"); 1282 | } 1283 | }); 1284 | }; 1285 | document.addEventListener( 1286 | "asrStatusUpdate", 1287 | handleInitialStatus, 1288 | { 1289 | once: true 1290 | } 1291 | ); 1292 | const obs = new MutationObserver((records) => { 1293 | records.forEach((r) => { 1294 | r.addedNodes.forEach((n) => { 1295 | if (n instanceof HTMLElement) { 1296 | if (n.matches(DOM_SELECTORS.fullInputBox)) { 1297 | initWave(n); 1298 | } 1299 | n.querySelectorAll(DOM_SELECTORS.fullInputBox).forEach( 1300 | (el) => { 1301 | if (!el.querySelector('.mic-btn[data-wave-init="1"]')) { 1302 | initWave(el); 1303 | } 1304 | } 1305 | ); 1306 | } 1307 | }); 1308 | }); 1309 | }); 1310 | obs.observe(document.documentElement, { childList: true, subtree: true }); 1311 | document.querySelectorAll(DOM_SELECTORS.fullInputBox).forEach((el) => { 1312 | if (!el.querySelector('.mic-btn[data-wave-init="1"]')) { 1313 | initWave(el); 1314 | } 1315 | }); 1316 | injectGlobalStyles(); 1317 | } 1318 | 1319 | // src/main.ts 1320 | (function() { 1321 | "use strict"; 1322 | setCurrentAsrInstance(null); 1323 | if (!navigator.gpu) { 1324 | console.warn("WebGPU not supported on this browser. ASR will not work."); 1325 | } 1326 | let transformersLibLoaded = typeof window.transformers !== "undefined"; 1327 | if (!transformersLibLoaded && typeof __require !== "undefined") { 1328 | const scriptId = "hf-transformers-script"; 1329 | if (!document.getElementById(scriptId)) { 1330 | console.log("Loading Hugging Face Transformers library..."); 1331 | const script = document.createElement("script"); 1332 | script.id = scriptId; 1333 | script.type = "module"; 1334 | script.textContent = ` 1335 | console.log('[ASR] Injected script block executing...'); 1336 | console.log('[ASR] Attempting to load Transformers library...'); 1337 | try { 1338 | const { ${[ 1339 | "AutoTokenizer", 1340 | "AutoProcessor", 1341 | "WhisperForConditionalGeneration", 1342 | "TextStreamer", 1343 | "full", 1344 | "env" 1345 | ].join( 1346 | "," 1347 | )} } = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@${HUGGING_FACE_TRANSFORMERS_VERSION}'); 1348 | console.log('[ASR] Transformers library imported successfully.'); 1349 | window.transformers = { AutoTokenizer, AutoProcessor, WhisperForConditionalGeneration, TextStreamer, full, env }; 1350 | window.transformers.env.backends.onnx.logLevel = 'info'; 1351 | console.log('[ASR] Transformers library loaded and configured.'); 1352 | document.dispatchEvent(new CustomEvent('transformersLoaded')); 1353 | } catch (error) { 1354 | console.error("[ASR] Failed to load Hugging Face Transformers library:", error); 1355 | } 1356 | `; 1357 | document.head.appendChild(script); 1358 | } 1359 | } else if (transformersLibLoaded && window.transformers) { 1360 | window.transformers.env.backends.onnx.logLevel = "info"; 1361 | } 1362 | console.log("Initializing ASR system..."); 1363 | initializeASRSystem(); 1364 | console.log("ASR system initialized"); 1365 | document.addEventListener("asrStatusUpdate", (e) => { 1366 | const event = e; 1367 | const managerState2 = event.detail.state; 1368 | const message = event.detail.message; 1369 | console.log( 1370 | `[ASR] Received asrStatusUpdate: State=${managerState2}, Msg=${message}` 1371 | ); 1372 | let targetMicState; 1373 | switch (managerState2) { 1374 | case "uninitialized": 1375 | case "ready": 1376 | case "error": 1377 | targetMicState = "idle"; 1378 | break; 1379 | case "initializing": 1380 | case "loading_model": 1381 | case "warming_up": 1382 | targetMicState = "disabled"; 1383 | break; 1384 | default: 1385 | console.warn( 1386 | "[ASR] Unhandled manager state in status listener:", 1387 | managerState2 1388 | ); 1389 | targetMicState = "idle"; 1390 | } 1391 | if (managerState2 === "error") { 1392 | console.error( 1393 | "[ASR System Error]:", 1394 | message || "Unknown ASR system error" 1395 | ); 1396 | } 1397 | document.querySelectorAll(".mic-btn[data-asr-init]").forEach((btn) => { 1398 | if (btn.asrState !== targetMicState) { 1399 | updateMicButtonState(btn, targetMicState); 1400 | } 1401 | }); 1402 | }); 1403 | if (!window._asrGlobalHandlerAttached) { 1404 | let globalAsrResultHandler = function(e) { 1405 | const event = e; 1406 | const { status, output = "", data } = event.detail; 1407 | const asrInstance = getCurrentAsrInstance(); 1408 | if (!asrInstance) return; 1409 | const { mic: mic2, chatInputContentEditable: chatInputContentEditable2 } = asrInstance; 1410 | const currentMicState = mic2.asrState; 1411 | if (status === "transcribing_start") { 1412 | updateMicButtonState(mic2, "transcribing"); 1413 | } else if (status === "update") { 1414 | buffer += output; 1415 | if (currentMicState !== "transcribing") { 1416 | updateMicButtonState(mic2, "transcribing"); 1417 | } 1418 | } else if (status === "complete") { 1419 | updateReactInput(chatInputContentEditable2, buffer, false); 1420 | buffer = ""; 1421 | updateMicButtonState(mic2, "idle"); 1422 | chatInputContentEditable2.focus(); 1423 | } else if (status === "error") { 1424 | console.error("Transcription error:", data); 1425 | updateMicButtonState( 1426 | mic2, 1427 | "idle", 1428 | `Error: ${data || "Unknown transcription error"}` 1429 | ); 1430 | } 1431 | }; 1432 | let buffer = ""; 1433 | document.addEventListener("asrResult", globalAsrResultHandler); 1434 | window._asrGlobalHandlerAttached = true; 1435 | } 1436 | setupMicButtonObserver(); 1437 | const mic = document.querySelector(DOM_SELECTORS.micButton); 1438 | const chatInputContentEditable = document.querySelector( 1439 | DOM_SELECTORS.fullInputBox 1440 | ); 1441 | if (mic && chatInputContentEditable) { 1442 | setCurrentAsrInstance({ mic, chatInputContentEditable }); 1443 | } 1444 | function updateReactInput(element, text, shouldReplace = false) { 1445 | if (text === "") { 1446 | return; 1447 | } 1448 | element.focus(); 1449 | if (shouldReplace) { 1450 | if (element.textContent === text) { 1451 | return; 1452 | } 1453 | const selection = window.getSelection(); 1454 | const range = document.createRange(); 1455 | range.selectNodeContents(element); 1456 | selection?.removeAllRanges(); 1457 | selection?.addRange(range); 1458 | document.execCommand("insertText", false, text); 1459 | } else { 1460 | const currentContent = element.textContent?.trim() || ""; 1461 | let textToAppend = text; 1462 | if (currentContent.length > 0 && !text.startsWith(" ")) { 1463 | textToAppend = " " + text; 1464 | } 1465 | const selection = window.getSelection(); 1466 | const range = document.createRange(); 1467 | range.selectNodeContents(element); 1468 | range.collapse(false); 1469 | selection?.removeAllRanges(); 1470 | selection?.addRange(range); 1471 | document.execCommand("insertText", false, textToAppend); 1472 | } 1473 | const inputEvent = new Event("input", { bubbles: true, cancelable: true }); 1474 | element.dispatchEvent(inputEvent); 1475 | } 1476 | })(); 1477 | })(); 1478 | --------------------------------------------------------------------------------