initial commit
+2541 · 20 files changed
-
README.md+20 −0 -
src/META-INF/cose.manifest+72 −0 -
src/META-INF/cose.sigbinary -
src/META-INF/manifest.mf+82 −0 -
src/META-INF/mozilla.rsabinary -
src/META-INF/mozilla.sf+4 −0 -
src/background.js+155 −0 -
src/content.js+207 −0 -
src/icons/create-icons.svg+11 −0 -
src/icons/icon-128.pngbinary -
src/icons/icon-16.pngbinary -
src/icons/icon-48.pngbinary -
src/manifest.json+61 −0 -
src/popup/popup.css+286 −0 -
src/popup/popup.html+53 −0 -
src/popup/popup.js+419 −0 -
src/settings/settings.css+349 −0 -
src/settings/settings.html+142 −0 -
src/settings/settings.js+461 −0 -
src/utils/audio-processor.js+219 −0
@@ -0,0 +1,20 @@ +Whisper Voice to Text + +This extension records your voice, transcribes it with OpenAI Whisper or compatible APIs, and inserts the text into the active input on the current page and into the clipboard. + +NB: You will need your own API key. + +Set up (only once): +* Open the settings page, grant access to the microphone +* Choose a Whisper provider and set the API key + +Usage: +* Place the cursor into a text field +* Press CTRL+ALT+S to start recording and speak into your microphone +* Press Space or Enter to stop + +The transcribed text should be placed into the text field. If for some reason the text didn't appear (maybe you accidentally clicked away from the field), a backup copy of the text should be in the clipboard. + +Original author: +Whisper Voice to Text by Vlad Vaviloff +MIT License @@ -0,0 +1,72 @@ +Manifest-Version: 1.0 + +Name: manifest.json +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: 8n/pS4mvVpS0piluR0gQg3BeP5I= +SHA256-Digest: 4zZweEzXU5T68ccUXaK5mgwSn50X3G6IceHCk0cM7xs= + +Name: content.js +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: PxgVrJMdwrTvkNPx7eAPcVQttcw= +SHA256-Digest: xmAa7wOYfAakG59cXGjQuO4swrFneKLcg19r4yCJpsQ= + +Name: background.js +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: o9EbEni3zADSq513gLq4Sr8wWws= +SHA256-Digest: oD8tVvT2n5CahQVQqA4+SW5u46Ql9hqf9NMzukzjLkU= + +Name: utils/audio-processor.js +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: hb0VuYo8ojTPYW2fhhq5MQTJqVs= +SHA256-Digest: pg23jor6QeAGv9gufD7CJeOgUicAjSluF7jr1hKD4sk= + +Name: popup/popup.css +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: 8i6Y2uGJmk3HUa1ZP+6PPMtboIo= +SHA256-Digest: /OfUe87FbRJhDfxIcXXwwcEay7w0ugyl7FpcqhLaXAQ= + +Name: popup/popup.html +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: Hhdr0lBZwIgWBRIwqPr90JOus+4= +SHA256-Digest: 5nenmux2MbheZ8sWLU6dCy/oof0LdRoshG/qlDUdRyk= + +Name: popup/popup.js +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: Kw+Z9qhxqbwKobMR5ojPvPLz0wY= +SHA256-Digest: HQK1L0mu/kV7tX+QxndSHzsPMgi+ThjuwaL0uXS5ts8= + +Name: settings/settings.css +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: HTfaNRx7E3jj+K1fzINrsDm7BZ4= +SHA256-Digest: pYaKW9E1N7fgVv9lNvn0mxOCh5CGW/o6oIpybbMvDHE= + +Name: settings/settings.js +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: wk7BMWvf0kgXleFA+4jN5vxbI6s= +SHA256-Digest: 3tLr63H0MtmVTju38j0ykjxpsvIwLI6NwwMiwNPojuw= + +Name: settings/settings.html +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: GTo2MgfYuyHcmzpZOqKkgGgYOgI= +SHA256-Digest: RG7Giix7x3aUP5Oxw6FaPhDc0CTTlKr0x9TAZkbouCY= + +Name: icons/create-icons.svg +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: VR661JMYB357bFkeFSdLxAl6njw= +SHA256-Digest: BzyvmDH8PmBOhfoN5hHBYZoz+CU0SPeHyki0ugfXfFY= + +Name: icons/icon-16.png +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: qNb5pyexlAXwr0KljZnXlvYgtK8= +SHA256-Digest: W8Mqg0vaYnVRnODmgxnSvenVEy+FL6jug+o3aXT5im8= + +Name: icons/icon-128.png +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: JSAtazdkRoU4hFGLEQ34eI9/1gU= +SHA256-Digest: Zk3pH3NHLtccjKnBWMIvJcobUXhUlaSIBe7Aeb7z5bc= + +Name: icons/icon-48.png +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: anKRjo+3SW8vv1kbhTkijR0+m94= +SHA256-Digest: scZzPu1Z+GafUQ/hRKr8qYeCTnd0ctapyfDDH/327Cg= + Binary files /dev/null and b/src/META-INF/cose.sig differ @@ -0,0 +1,82 @@ +Manifest-Version: 1.0 + +Name: manifest.json +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: 8n/pS4mvVpS0piluR0gQg3BeP5I= +SHA256-Digest: 4zZweEzXU5T68ccUXaK5mgwSn50X3G6IceHCk0cM7xs= + +Name: content.js +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: PxgVrJMdwrTvkNPx7eAPcVQttcw= +SHA256-Digest: xmAa7wOYfAakG59cXGjQuO4swrFneKLcg19r4yCJpsQ= + +Name: background.js +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: o9EbEni3zADSq513gLq4Sr8wWws= +SHA256-Digest: oD8tVvT2n5CahQVQqA4+SW5u46Ql9hqf9NMzukzjLkU= + +Name: utils/audio-processor.js +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: hb0VuYo8ojTPYW2fhhq5MQTJqVs= +SHA256-Digest: pg23jor6QeAGv9gufD7CJeOgUicAjSluF7jr1hKD4sk= + +Name: popup/popup.css +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: 8i6Y2uGJmk3HUa1ZP+6PPMtboIo= +SHA256-Digest: /OfUe87FbRJhDfxIcXXwwcEay7w0ugyl7FpcqhLaXAQ= + +Name: popup/popup.html +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: Hhdr0lBZwIgWBRIwqPr90JOus+4= +SHA256-Digest: 5nenmux2MbheZ8sWLU6dCy/oof0LdRoshG/qlDUdRyk= + +Name: popup/popup.js +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: Kw+Z9qhxqbwKobMR5ojPvPLz0wY= +SHA256-Digest: HQK1L0mu/kV7tX+QxndSHzsPMgi+ThjuwaL0uXS5ts8= + +Name: settings/settings.css +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: HTfaNRx7E3jj+K1fzINrsDm7BZ4= +SHA256-Digest: pYaKW9E1N7fgVv9lNvn0mxOCh5CGW/o6oIpybbMvDHE= + +Name: settings/settings.js +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: wk7BMWvf0kgXleFA+4jN5vxbI6s= +SHA256-Digest: 3tLr63H0MtmVTju38j0ykjxpsvIwLI6NwwMiwNPojuw= + +Name: settings/settings.html +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: GTo2MgfYuyHcmzpZOqKkgGgYOgI= +SHA256-Digest: RG7Giix7x3aUP5Oxw6FaPhDc0CTTlKr0x9TAZkbouCY= + +Name: icons/create-icons.svg +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: VR661JMYB357bFkeFSdLxAl6njw= +SHA256-Digest: BzyvmDH8PmBOhfoN5hHBYZoz+CU0SPeHyki0ugfXfFY= + +Name: icons/icon-16.png +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: qNb5pyexlAXwr0KljZnXlvYgtK8= +SHA256-Digest: W8Mqg0vaYnVRnODmgxnSvenVEy+FL6jug+o3aXT5im8= + +Name: icons/icon-128.png +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: JSAtazdkRoU4hFGLEQ34eI9/1gU= +SHA256-Digest: Zk3pH3NHLtccjKnBWMIvJcobUXhUlaSIBe7Aeb7z5bc= + +Name: icons/icon-48.png +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: anKRjo+3SW8vv1kbhTkijR0+m94= +SHA256-Digest: scZzPu1Z+GafUQ/hRKr8qYeCTnd0ctapyfDDH/327Cg= + +Name: META-INF/cose.manifest +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: FWmfUPS12z1R0/EAg3zaHnw1QhY= +SHA256-Digest: 1vOWOkFX7wT9AK6LAfFoI4xTCfvHbKNjWj3ujT7dWnM= + +Name: META-INF/cose.sig +Digest-Algorithms: SHA1 SHA256 +SHA1-Digest: KcRS/R+GTv82GsfQfO5zTx4sKjg= +SHA256-Digest: VAC5R8B22ofTYJ0plFt+FrXcojYBLbjDjqg2Laa/zKo= + Binary files /dev/null and b/src/META-INF/mozilla.rsa differ @@ -0,0 +1,4 @@ +Signature-Version: 1.0 +SHA1-Digest-Manifest: FGqbW4oYV4DyVj4LUof7iba0CaE= +SHA256-Digest-Manifest: jIRETtUG5vXK3ZuMAVvlIBdlQ8pIuGFo7UAjzucUJI8= + @@ -0,0 +1,155 @@ +/** + * Background script for Voice to Text extension + * Handles API communication with Whisper endpoints + */ + +// Listen for messages from popup or content scripts +browser.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.action === 'transcribe') { + // Handle async transcription + transcribeAudio(message.audioBlob) + .then(result => { + sendResponse(result); + }) + .catch(error => { + console.error('Transcription error:', error); + sendResponse({ + success: false, + error: error.message + }); + }); + + // Return true to indicate we'll send response asynchronously + return true; + } + + if (message.action === 'getSettings') { + // Handle async settings retrieval + getSettings() + .then(settings => { + sendResponse(settings); + }) + .catch(error => { + sendResponse({ + success: false, + error: error.message + }); + }); + + // Return true to indicate we'll send response asynchronously + return true; + } +}); + +/** + * Get settings from browser storage + */ +async function getSettings() { + const defaults = { + apiEndpoint: 'https://api.openai.com/v1/audio/transcriptions', + apiToken: '', + model: 'whisper-1' + }; + + try { + const stored = await browser.storage.sync.get(['apiEndpoint', 'apiToken', 'model']); + return { + success: true, + settings: { + apiEndpoint: stored.apiEndpoint || defaults.apiEndpoint, + apiToken: stored.apiToken || defaults.apiToken, + model: stored.model || defaults.model + } + }; + } catch (error) { + return { + success: false, + error: error.message, + settings: defaults + }; + } +} + +/** + * Transcribe audio using configured API + */ +async function transcribeAudio(audioBlob) { + const settingsResult = await getSettings(); + + if (!settingsResult.success) { + throw new Error('Failed to load settings: ' + settingsResult.error); + } + + const settings = settingsResult.settings; + + if (!settings.apiToken) { + throw new Error('API token not configured. Please set it in the extension settings.'); + } + + // Convert base64 to blob if needed + let audioFile; + if (typeof audioBlob === 'string') { + const response = await fetch(audioBlob); + audioFile = await response.blob(); + } else { + audioFile = audioBlob; + } + + // Create form data for API request + const formData = new FormData(); + formData.append('file', audioFile, 'audio.webm'); + formData.append('model', settings.model); + + try { + const response = await fetch(settings.apiEndpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${settings.apiToken}` + }, + body: formData + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `API request failed: ${response.status} ${response.statusText}`; + + try { + const errorJson = JSON.parse(errorText); + if (errorJson.error && errorJson.error.message) { + errorMessage = errorJson.error.message; + } + } catch (e) { + // If not JSON, use the raw text + if (errorText) { + errorMessage += ` - ${errorText}`; + } + } + + throw new Error(errorMessage); + } + + const result = await response.json(); + + // Handle different response formats + let transcription = ''; + if (result.text) { + transcription = result.text; + } else if (result.transcription) { + transcription = result.transcription; + } else { + throw new Error('Unexpected API response format'); + } + + return { + success: true, + text: transcription.trim() + }; + + } catch (error) { + console.error('API request error:', error); + throw error; + } +} + +// Log when background script is loaded +console.log('Voice to Text extension background script loaded'); @@ -0,0 +1,207 @@ +/** + * Content script for Voice to Text extension + * Handles text insertion into active input fields + */ + +// Store the last focused element +let lastFocusedElement = null; + +// Track focus on all input elements +document.addEventListener('focusin', (event) => { + const target = event.target; + if (isEditableElement(target)) { + lastFocusedElement = target; + } +}, true); + +// Also track click events for contenteditable elements +document.addEventListener('click', (event) => { + const target = event.target; + if (isEditableElement(target)) { + lastFocusedElement = target; + } +}, true); + +/** + * Check if element is editable + */ +function isEditableElement(element) { + if (!element) return false; + + const tagName = element.tagName ? element.tagName.toLowerCase() : ''; + + // Check for input fields + if (tagName === 'input') { + const type = element.type ? element.type.toLowerCase() : 'text'; + const editableTypes = ['text', 'search', 'email', 'url', 'tel', 'password']; + return editableTypes.includes(type) && !element.disabled && !element.readOnly; + } + + // Check for textarea + if (tagName === 'textarea') { + return !element.disabled && !element.readOnly; + } + + // Check for contenteditable + if (element.isContentEditable) { + return true; + } + + return false; +} + +/** + * Get the currently active editable element + */ +function getActiveElement() { + // First try document.activeElement + let active = document.activeElement; + + if (active && isEditableElement(active)) { + return active; + } + + // If no active element, try the last focused one + if (lastFocusedElement && isEditableElement(lastFocusedElement)) { + return lastFocusedElement; + } + + // If still nothing, try to find any focused input + const focused = document.querySelector('input:focus, textarea:focus, [contenteditable="true"]:focus'); + if (focused) { + return focused; + } + + return null; +} + +/** + * Insert text into an element + */ +function insertText(element, text) { + if (!element || !text) return false; + + try { + // Focus the element + element.focus(); + + const tagName = element.tagName ? element.tagName.toLowerCase() : ''; + + // Handle contenteditable elements + if (element.isContentEditable) { + const selection = window.getSelection(); + const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + + if (range) { + // Check if we need to add a space before the text + let textToInsert = text; + const beforeCursor = range.startContainer.textContent?.substring(0, range.startOffset) || ''; + + // Add space if there's text before cursor and it doesn't end with whitespace + if (beforeCursor.length > 0 && !beforeCursor.match(/\s$/)) { + textToInsert = ' ' + text; + } + + range.deleteContents(); + const textNode = document.createTextNode(textToInsert); + range.insertNode(textNode); + + // Move cursor to end of inserted text + range.setStartAfter(textNode); + range.setEndAfter(textNode); + selection.removeAllRanges(); + selection.addRange(range); + } else { + // Fallback: append to end with space if needed + const currentText = element.textContent || ''; + if (currentText.length > 0 && !currentText.match(/\s$/)) { + element.textContent += ' ' + text; + } else { + element.textContent += text; + } + } + + // Trigger input event + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + + return true; + } + + // Handle input and textarea elements + if (tagName === 'input' || tagName === 'textarea') { + const start = element.selectionStart || 0; + const end = element.selectionEnd || 0; + const currentValue = element.value; + + // Check if we need to add a space before the text + let textToInsert = text; + const beforeCursor = currentValue.substring(0, start); + + // Add space if there's text before cursor and it doesn't end with whitespace + if (beforeCursor.length > 0 && !beforeCursor.match(/\s$/)) { + textToInsert = ' ' + text; + } + + // Insert text at cursor position + const newValue = currentValue.substring(0, start) + textToInsert + currentValue.substring(end); + element.value = newValue; + + // Set cursor position after inserted text + const newPosition = start + textToInsert.length; + element.setSelectionRange(newPosition, newPosition); + + // Trigger events to ensure frameworks detect the change + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + + return true; + } + + return false; + } catch (error) { + console.error('Error inserting text:', error); + return false; + } +} + +/** + * Listen for messages from popup + */ +browser.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.action === 'insertText') { + const element = getActiveElement(); + + if (!element) { + sendResponse({ + success: false, + error: 'No active input field found. Please click on a text field first.' + }); + return; + } + + const success = insertText(element, message.text); + + if (success) { + sendResponse({ + success: true, + message: 'Text inserted successfully' + }); + } else { + sendResponse({ + success: false, + error: 'Failed to insert text into the field' + }); + } + } + + if (message.action === 'checkActiveElement') { + const element = getActiveElement(); + sendResponse({ + hasActiveElement: element !== null, + elementType: element ? element.tagName.toLowerCase() : null + }); + } +}); + +console.log('Voice to Text content script loaded'); @@ -0,0 +1,11 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"> + <defs> + <linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%"> + <stop offset="0%" style="stop-color:#667eea;stop-opacity:1" /> + <stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" /> + </linearGradient> + </defs> + <circle cx="64" cy="64" r="60" fill="url(#grad)"/> + <path d="M64 44c-6.6 0-12 5.4-12 12v16c0 6.6 5.4 12 12 12s12-5.4 12-12V56c0-6.6-5.4-12-12-12z" fill="white"/> + <path d="M80 68c0 8.8-7.2 16-16 16s-16-7.2-16-16H40c0 12.4 9.2 22.6 21.3 24.3V104h5.4V92.3C78.8 90.6 88 80.4 88 68h-8z" fill="white"/> +</svg> Binary files /dev/null and b/src/icons/icon-128.png differ Binary files /dev/null and b/src/icons/icon-16.png differ Binary files /dev/null and b/src/icons/icon-48.png differ @@ -0,0 +1,61 @@ +{ + "manifest_version": 2, + "name": "Whisper Voice to Text", + "version": "1.0.0", + "description": "Record your voice and insert transcribed text into any input field using OpenAI Whisper or compatible APIs", + "permissions": [ + "activeTab", + "storage", + "<all_urls>" + ], + "background": { + "scripts": [ + "background.js" + ], + "persistent": false + }, + "content_scripts": [ + { + "matches": [ + "<all_urls>" + ], + "js": [ + "content.js" + ], + "run_at": "document_idle" + } + ], + "browser_action": { + "default_popup": "popup/popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "default_title": "Whisper Voice to Text" + }, + "commands": { + "_execute_browser_action": { + "suggested_key": { + "default": "Ctrl+Alt+S", + "mac": "Command+Alt+S" + }, + "description": "Activate voice recording" + } + }, + "options_ui": { + "page": "settings/settings.html", + "open_in_tab": true + }, + "icons": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "voice-to-text@extension.local", + "strict_min_version": "58.0" + } + } +} \ No newline at end of file @@ -0,0 +1,286 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + width: 320px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: #f5f5f5; + color: #333; +} + +.container { + display: flex; + flex-direction: column; + min-height: 400px; +} + +header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 16px; + text-align: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +header h1 { + font-size: 18px; + font-weight: 600; +} + +main { + flex: 1; + padding: 20px; + background: white; +} + +.status-message { + padding: 12px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 14px; + text-align: center; + transition: all 0.3s ease; +} + +.status-message.hidden { + display: none; +} + +.status-message.error { + background: #fee; + color: #c33; + border: 1px solid #fcc; +} + +.status-message.success { + background: #efe; + color: #3a3; + border: 1px solid #cfc; +} + +.status-message.info { + background: #eef; + color: #33a; + border: 1px solid #ccf; +} + +.recording-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + margin-bottom: 24px; +} + +.record-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 24px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 12px; + cursor: pointer; + transition: all 0.3s ease; + font-size: 14px; + font-weight: 500; + width: 100%; + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); +} + +.record-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4); +} + +.record-btn:active { + transform: translateY(0); +} + +.record-btn.recording { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + animation: pulse 2s infinite; +} + +.record-btn.processing { + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); + cursor: wait; + pointer-events: none; +} + +.mic-icon { + width: 48px; + height: 48px; + fill: currentColor; +} + +.recording-indicator { + display: flex; + align-items: center; + gap: 8px; + color: #f5576c; + font-weight: 500; + font-size: 14px; +} + +.recording-indicator.hidden { + display: none; +} + +.pulse { + width: 12px; + height: 12px; + background: #f5576c; + border-radius: 50%; + animation: pulse-dot 1.5s infinite; +} + +.timer { + font-size: 24px; + font-weight: 600; + color: #667eea; + font-variant-numeric: tabular-nums; +} + +.timer.hidden { + display: none; +} + +.info-section { + padding: 16px; + background: #f9f9f9; + border-radius: 8px; + border: 1px solid #e0e0e0; +} + +.shortcut-hint { + font-size: 13px; + color: #666; + margin-bottom: 8px; + text-align: center; +} + +kbd { + display: inline-block; + padding: 2px 6px; + background: white; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 11px; + font-family: monospace; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.instruction { + font-size: 12px; + color: #888; + text-align: center; + line-height: 1.5; +} + +footer { + padding: 12px 16px; + background: #f5f5f5; + border-top: 1px solid #e0e0e0; +} + +.settings-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 10px; + background: white; + border: 1px solid #d0d0d0; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + color: #555; + transition: all 0.2s ease; +} + +.settings-btn:hover { + background: #fafafa; + border-color: #667eea; + color: #667eea; +} + +.settings-btn svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +.transcribed-text { + margin: 16px 0; + padding: 16px; + background: #f9f9f9; + border: 2px solid #667eea; + border-radius: 8px; + position: relative; +} + +.transcribed-text.hidden { + display: none; +} + +.transcribed-text .text-content { + font-size: 14px; + line-height: 1.6; + color: #333; + word-wrap: break-word; + margin-bottom: 12px; + padding: 8px; + background: white; + border-radius: 4px; + max-height: 150px; + overflow-y: auto; +} + +.transcribed-text .copy-btn { + padding: 8px 16px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s ease; + width: 100%; +} + +.transcribed-text .copy-btn:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); +} + +.transcribed-text .copy-btn:active { + transform: translateY(0); +} + +@keyframes pulse { + 0%, 100% { + box-shadow: 0 4px 12px rgba(245, 87, 108, 0.3); + } + 50% { + box-shadow: 0 4px 20px rgba(245, 87, 108, 0.6); + } +} + +@keyframes pulse-dot { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(1.2); + } +} @@ -0,0 +1,53 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Voice to Text</title> + <link rel="stylesheet" href="popup.css"> +</head> +<body> + <div class="container"> + <header> + <h1>Voice to Text</h1> + </header> + + <main> + <div id="status-message" class="status-message hidden"></div> + + <div class="recording-section"> + <button id="record-btn" class="record-btn" title="Click or press Ctrl+Shift+V to start recording"> + <svg class="mic-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/> + <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/> + </svg> + <span id="button-text">Start Recording</span> + </button> + + <div id="recording-indicator" class="recording-indicator hidden"> + <div class="pulse"></div> + <span>Recording...</span> + </div> + + <div id="timer" class="timer hidden">0:00</div> + </div> + + <div class="info-section"> + <p class="shortcut-hint">Press <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>S</kbd> to start recording</p> + <p class="instruction">Press <kbd>Space</kbd>, <kbd>Enter</kbd>, or <kbd>Esc</kbd> to stop and transcribe</p> + </div> + </main> + + <footer> + <button id="settings-btn" class="settings-btn" title="Open settings"> + <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/> + </svg> + Settings + </button> + </footer> + </div> + + <script src="popup.js"></script> +</body> +</html> @@ -0,0 +1,419 @@ +/** + * Popup script for Voice to Text extension + * Handles audio recording and UI interactions + */ + +// State management +let mediaRecorder = null; +let audioChunks = []; +let isRecording = false; +let recordingStartTime = 0; +let timerInterval = null; + +// DOM elements +const recordBtn = document.getElementById('record-btn'); +const buttonText = document.getElementById('button-text'); +const statusMessage = document.getElementById('status-message'); +const recordingIndicator = document.getElementById('recording-indicator'); +const timer = document.getElementById('timer'); +const settingsBtn = document.getElementById('settings-btn'); + +// Initialize +document.addEventListener('DOMContentLoaded', async () => { + recordBtn.addEventListener('click', toggleRecording); + settingsBtn.addEventListener('click', openSettings); + + // Check if API is configured + await checkConfiguration(); + + // Auto-start recording when popup opens + // (This happens when user presses the keyboard shortcut) + await startRecording(); +}); + +/** + * Check if API is configured + */ +async function checkConfiguration() { + try { + const result = await browser.runtime.sendMessage({ action: 'getSettings' }); + if (result.success && !result.settings.apiToken) { + showStatus('Please configure your API token in settings', 'info'); + } + } catch (error) { + console.error('Error checking configuration:', error); + } +} + +/** + * Toggle recording state + */ +async function toggleRecording() { + if (isRecording) { + stopRecording(); + } else { + await startRecording(); + } +} + +/** + * Start recording audio + */ +async function startRecording() { + try { + showStatus('Requesting microphone access...', 'info'); + + // Request microphone permission + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + } + }); + + // Create MediaRecorder + const mimeType = getSupportedMimeType(); + mediaRecorder = new MediaRecorder(stream, { mimeType }); + + audioChunks = []; + + mediaRecorder.addEventListener('dataavailable', (event) => { + if (event.data.size > 0) { + audioChunks.push(event.data); + } + }); + + mediaRecorder.addEventListener('stop', async () => { + // Stop all tracks + stream.getTracks().forEach(track => track.stop()); + + // Process the recording + await processRecording(); + }); + + // Start recording + mediaRecorder.start(); + isRecording = true; + recordingStartTime = Date.now(); + + // Update UI + updateUIForRecording(); + hideStatus(); + hideTranscribedText(); + startTimer(); + + } catch (error) { + console.error('Error starting recording:', error); + let errorMessage = 'Failed to start recording. '; + + if (error.name === 'NotAllowedError') { + errorMessage += 'Please allow microphone access.'; + } else if (error.name === 'NotFoundError') { + errorMessage += 'No microphone found.'; + } else { + errorMessage += error.message; + } + + showStatus(errorMessage, 'error'); + resetUI(); + } +} + +/** + * Stop recording audio + */ +function stopRecording() { + if (mediaRecorder && isRecording) { + mediaRecorder.stop(); + isRecording = false; + stopTimer(); + + // Update UI to processing state + recordBtn.classList.remove('recording'); + recordBtn.classList.add('processing'); + buttonText.textContent = 'Processing...'; + recordingIndicator.classList.add('hidden'); + + showStatus('Processing audio...', 'info'); + } +} + +/** + * Process recorded audio + */ +async function processRecording() { + try { + // Create blob from chunks + const mimeType = getSupportedMimeType(); + const audioBlob = new Blob(audioChunks, { type: mimeType }); + + // Check if recording is too short + const duration = (Date.now() - recordingStartTime) / 1000; + if (duration < 0.5) { + showStatus('Recording too short. Please try again.', 'error'); + resetUI(); + return; + } + + // Send to background script for transcription + showStatus('Transcribing audio...', 'info'); + + const result = await browser.runtime.sendMessage({ + action: 'transcribe', + audioBlob: await blobToBase64(audioBlob) + }); + + if (result.success && result.text) { + // Always copy to clipboard as backup + try { + await navigator.clipboard.writeText(result.text); + console.log('Text copied to clipboard as backup'); + } catch (clipboardError) { + console.warn('Could not copy to clipboard:', clipboardError); + } + + // Try to insert text into active field + try { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + + if (tabs.length === 0) { + // No active tab, just show the text + showTranscribedText(result.text); + return; + } + + // Try to send text to content script + const response = await browser.tabs.sendMessage(tabs[0].id, { + action: 'insertText', + text: result.text + }); + + if (response && response.success) { + showStatus('Text inserted successfully! (also copied to clipboard)', 'success'); + // Auto-close popup after successful insertion + setTimeout(() => { + window.close(); + }, 800); // Brief delay to show success message + } else { + // Insertion failed, show the text instead + const errorMsg = response?.error || 'No input field focused'; + showTranscribedText(result.text, errorMsg); + } + } catch (error) { + // Content script not responding or other error, show the text + console.log('Could not insert text, showing in popup:', error); + showTranscribedText(result.text, 'Could not insert text into page'); + } + + } else { + const errorMsg = result.error || 'Transcription failed'; + showStatus(errorMsg, 'error'); + } + + } catch (error) { + console.error('Error processing recording:', error); + showStatus('Error: ' + error.message, 'error'); + } finally { + resetUI(); + } +} + +/** + * Get supported MIME type for recording + */ +function getSupportedMimeType() { + const types = [ + 'audio/webm', + 'audio/webm;codecs=opus', + 'audio/ogg;codecs=opus', + 'audio/mp4' + ]; + + for (const type of types) { + if (MediaRecorder.isTypeSupported(type)) { + return type; + } + } + + return 'audio/webm'; // Fallback +} + +/** + * Convert blob to base64 + */ +function blobToBase64(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +/** + * Update UI for recording state + */ +function updateUIForRecording() { + recordBtn.classList.add('recording'); + buttonText.textContent = 'Stop Recording'; + recordingIndicator.classList.remove('hidden'); + timer.classList.remove('hidden'); +} + +/** + * Reset UI to initial state + */ +function resetUI() { + recordBtn.classList.remove('recording', 'processing'); + buttonText.textContent = 'Start Recording'; + recordingIndicator.classList.add('hidden'); + timer.classList.add('hidden'); + timer.textContent = '0:00'; +} + +/** + * Start recording timer + */ +function startTimer() { + timerInterval = setInterval(() => { + const elapsed = Math.floor((Date.now() - recordingStartTime) / 1000); + const minutes = Math.floor(elapsed / 60); + const seconds = elapsed % 60; + timer.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; + }, 1000); +} + +/** + * Stop recording timer + */ +function stopTimer() { + if (timerInterval) { + clearInterval(timerInterval); + timerInterval = null; + } +} + +/** + * Show status message + */ +function showStatus(message, type = 'info') { + statusMessage.textContent = message; + statusMessage.className = `status-message ${type}`; + statusMessage.classList.remove('hidden'); +} + +/** + * Hide status message + */ +function hideStatus() { + statusMessage.classList.add('hidden'); +} + +/** + * Show transcribed text in the popup when insertion fails + */ +function showTranscribedText(text, errorMsg = null) { + // Create or get text display element + let textDisplay = document.getElementById('transcribed-text-display'); + + if (!textDisplay) { + textDisplay = document.createElement('div'); + textDisplay.id = 'transcribed-text-display'; + textDisplay.className = 'transcribed-text'; + + // Add copy button + const copyBtn = document.createElement('button'); + copyBtn.className = 'copy-btn'; + copyBtn.textContent = 'Copy'; + copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(text).then(() => { + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.textContent = 'Copy'; + }, 2000); + }); + }); + + const textContent = document.createElement('div'); + textContent.className = 'text-content'; + textContent.textContent = text; + + textDisplay.appendChild(textContent); + textDisplay.appendChild(copyBtn); + + // Insert after status message + const container = document.querySelector('.container main'); + const recordingSection = document.querySelector('.recording-section'); + container.insertBefore(textDisplay, recordingSection.nextSibling); + } else { + // Update existing display + const textContent = textDisplay.querySelector('.text-content'); + textContent.textContent = text; + + // Update copy button to copy new text + const copyBtn = textDisplay.querySelector('.copy-btn'); + copyBtn.textContent = 'Copy'; + copyBtn.onclick = () => { + navigator.clipboard.writeText(text).then(() => { + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.textContent = 'Copy'; + }, 2000); + }); + }; + + textDisplay.classList.remove('hidden'); + } + + // Show appropriate status message + if (errorMsg) { + showStatus(errorMsg + '. Text shown below (click to copy)', 'info'); + } else { + showStatus('Transcription complete. Text shown below (click to copy)', 'info'); + } +} + +/** + * Hide transcribed text display + */ +function hideTranscribedText() { + const textDisplay = document.getElementById('transcribed-text-display'); + if (textDisplay) { + textDisplay.classList.add('hidden'); + } +} + +/** + * Open settings page + */ +function openSettings() { + browser.runtime.openOptionsPage(); +} + +// Handle keyboard shortcuts within the popup +document.addEventListener('keydown', (event) => { + // Space or Enter to stop recording + if ((event.key === ' ' || event.key === 'Enter') && isRecording) { + event.preventDefault(); + stopRecording(); + return; + } + + // Escape to stop recording (and popup will close) + if (event.key === 'Escape' && isRecording) { + event.preventDefault(); + stopRecording(); + // Don't close popup immediately, let transcription happen + // User can press Esc again to close if needed + return; + } +}); + +// Listen for window focus to handle popup reopening +window.addEventListener('focus', () => { + // If popup regains focus and not recording, don't auto-start again + // This prevents re-recording when popup is clicked +}); + +console.log('Voice to Text popup loaded'); @@ -0,0 +1,349 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: #f5f5f5; + color: #333; + line-height: 1.6; +} + +.container { + max-width: 800px; + margin: 0 auto; + background: white; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 32px 24px; + text-align: center; +} + +header h1 { + font-size: 28px; + font-weight: 600; + margin-bottom: 8px; +} + +header p { + font-size: 16px; + opacity: 0.9; +} + +main { + flex: 1; + padding: 32px 24px; +} + +.status-message { + padding: 16px; + border-radius: 8px; + margin-bottom: 24px; + font-size: 14px; + text-align: center; + transition: all 0.3s ease; +} + +.status-message.hidden { + display: none; +} + +.status-message.error { + background: #fee; + color: #c33; + border: 1px solid #fcc; +} + +.status-message.success { + background: #efe; + color: #3a3; + border: 1px solid #cfc; +} + +.status-message.info { + background: #eef; + color: #33a; + border: 1px solid #ccf; +} + +.form-section { + margin-bottom: 32px; + padding: 24px; + background: #fafafa; + border-radius: 8px; + border: 1px solid #e0e0e0; +} + +.form-section h2 { + font-size: 20px; + font-weight: 600; + margin-bottom: 8px; + color: #333; +} + +.form-section h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 12px; + color: #555; +} + +.section-description { + font-size: 14px; + color: #666; + margin-bottom: 16px; +} + +.form-group { + margin-bottom: 20px; + position: relative; +} + +.form-group:last-child { + margin-bottom: 0; +} + +label { + display: block; + font-weight: 500; + margin-bottom: 8px; + color: #444; + font-size: 14px; +} + +.help-text { + display: block; + font-weight: 400; + font-size: 12px; + color: #888; + margin-top: 4px; +} + +input[type="url"], +input[type="password"], +input[type="text"] { + width: 100%; + padding: 12px; + border: 1px solid #d0d0d0; + border-radius: 6px; + font-size: 14px; + font-family: inherit; + transition: all 0.2s ease; + background: white; +} + +input:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.toggle-btn { + position: absolute; + right: 8px; + bottom: 8px; + padding: 4px 12px; + background: #f0f0f0; + border: 1px solid #d0d0d0; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + color: #666; + transition: all 0.2s ease; +} + +.toggle-btn:hover { + background: #e8e8e8; +} + +.preset-buttons { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; +} + +.preset-btn { + padding: 16px; + background: white; + border: 2px solid #d0d0d0; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; +} + +.preset-btn:hover { + border-color: #667eea; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.preset-btn.active { + border-color: #667eea; + background: #f0f4ff; +} + +.preset-name { + font-weight: 600; + font-size: 16px; + color: #333; + margin-bottom: 4px; +} + +.preset-desc { + font-size: 13px; + color: #666; +} + +.info-section ul { + list-style: none; + padding: 0; +} + +.info-section li { + padding: 8px 0; + font-size: 14px; + color: #555; +} + +.info-section a { + color: #667eea; + text-decoration: none; +} + +.info-section a:hover { + text-decoration: underline; +} + +.form-actions { + display: flex; + gap: 12px; + margin-top: 24px; + flex-wrap: wrap; +} + +.btn { + padding: 12px 24px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; +} + +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + flex: 1; + min-width: 150px; +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.btn-primary:active { + transform: translateY(0); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-secondary { + background: white; + color: #667eea; + border: 1px solid #667eea; +} + +.btn-secondary:hover { + background: #f0f4ff; +} + +kbd { + display: inline-block; + padding: 3px 8px; + background: white; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 13px; + font-family: monospace; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +code { + background: white; + padding: 2px 6px; + border-radius: 3px; + font-size: 13px; + color: #667eea; + border: 1px solid #e0e0e0; +} + +.mic-status { + margin-top: 12px; + padding: 12px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; +} + +.mic-status.hidden { + display: none; +} + +.mic-status.granted { + background: #efe; + color: #3a3; + border: 1px solid #cfc; +} + +.mic-status.denied { + background: #fee; + color: #c33; + border: 1px solid #fcc; +} + +footer { + padding: 20px; + text-align: center; + background: #fafafa; + border-top: 1px solid #e0e0e0; + font-size: 14px; + color: #888; +} + +@media (max-width: 600px) { + .container { + margin: 0; + } + + main { + padding: 20px 16px; + } + + .form-actions { + flex-direction: column; + } + + .btn { + width: 100%; + } + + .preset-buttons { + grid-template-columns: 1fr; + } +} @@ -0,0 +1,142 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Voice to Text - Settings</title> + <link rel="stylesheet" href="settings.css"> +</head> +<body> + <div class="container"> + <header> + <h1>Voice to Text Settings</h1> + <p>Configure your API settings for speech transcription</p> + </header> + + <main> + <div id="status-message" class="status-message hidden"></div> + + <form id="settings-form"> + <div class="form-section"> + <h2>Microphone Permission</h2> + <p class="section-description"> + Before using the extension, you need to grant microphone access. + </p> + <button type="button" id="request-mic-btn" class="btn btn-primary"> + Grant Microphone Access + </button> + <p id="mic-status" class="mic-status hidden"></p> + </div> + + <div class="form-section"> + <h2>API Configuration</h2> + + <div class="form-group"> + <label for="api-endpoint"> + API Endpoint + <span class="help-text">The URL of the Whisper API endpoint</span> + </label> + <input + type="url" + id="api-endpoint" + name="apiEndpoint" + placeholder="https://api.openai.com/v1/audio/transcriptions" + required + /> + </div> + + <div class="form-group"> + <label for="api-token"> + API Token + <span class="help-text">Your API authentication token (stored securely in browser)</span> + </label> + <input + type="password" + id="api-token" + name="apiToken" + placeholder="sk-..." + required + /> + <button type="button" id="toggle-token" class="toggle-btn">Show</button> + </div> + + <div class="form-group"> + <label for="model"> + Model + <span class="help-text">The model name to use for transcription</span> + </label> + <input + type="text" + id="model" + name="model" + placeholder="whisper-1" + required + /> + </div> + </div> + + <div class="form-section presets"> + <h2>Quick Presets</h2> + <p class="section-description">Load configuration for popular providers</p> + + <div class="preset-buttons"> + <button type="button" class="preset-btn" data-provider="openai"> + <div class="preset-name">OpenAI</div> + <div class="preset-desc">Official OpenAI API</div> + </button> + + <button type="button" class="preset-btn" data-provider="groq"> + <div class="preset-name">Groq</div> + <div class="preset-desc">Fast Whisper inference</div> + </button> + + <button type="button" class="preset-btn" data-provider="custom"> + <div class="preset-name">Custom</div> + <div class="preset-desc">Self-hosted or other provider</div> + </button> + </div> + </div> + + <div class="form-section info-section"> + <h3>Getting Started</h3> + <ul> + <li><strong>OpenAI:</strong> Get your API key from <a href="https://platform.openai.com/api-keys" target="_blank">platform.openai.com</a></li> + <li><strong>Groq:</strong> Sign up at <a href="https://console.groq.com" target="_blank">console.groq.com</a> for free API access</li> + <li><strong>Custom:</strong> Use any Whisper-compatible API endpoint</li> + </ul> + </div> + + <div class="form-actions"> + <button type="submit" id="save-btn" class="btn btn-primary"> + Save Settings + </button> + <button type="button" id="test-btn" class="btn btn-secondary"> + Test Connection + </button> + <button type="button" id="reset-btn" class="btn btn-secondary"> + Reset to Defaults + </button> + </div> + </form> + + <div class="form-section"> + <h2>Keyboard Shortcut</h2> + <p class="section-description"> + The default keyboard shortcut is <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>S</kbd> + (<kbd>Cmd</kbd>+<kbd>Alt</kbd>+<kbd>S</kbd> on Mac). + </p> + <p class="section-description"> + To customize the shortcut, visit <code>about:addons</code> in Firefox, + click the gear icon, select "Manage Extension Shortcuts", and find "Voice to Text". + </p> + </div> + </main> + + <footer> + <p>© 2024 Voice to Text Extension</p> + </footer> + </div> + + <script src="settings.js"></script> +</body> +</html> @@ -0,0 +1,461 @@ +/** + * Settings script for Voice to Text extension + * Handles configuration management + */ + +// Preset configurations +const PRESETS = { + openai: { + apiEndpoint: 'https://api.openai.com/v1/audio/transcriptions', + model: 'whisper-1' + }, + groq: { + apiEndpoint: 'https://api.groq.com/openai/v1/audio/transcriptions', + model: 'whisper-large-v3' + }, + custom: { + apiEndpoint: '', + model: 'whisper-1' + } +}; + +// DOM elements +const form = document.getElementById('settings-form'); +const apiEndpointInput = document.getElementById('api-endpoint'); +const apiTokenInput = document.getElementById('api-token'); +const modelInput = document.getElementById('model'); +const toggleTokenBtn = document.getElementById('toggle-token'); +const saveBtn = document.getElementById('save-btn'); +const testBtn = document.getElementById('test-btn'); +const resetBtn = document.getElementById('reset-btn'); +const statusMessage = document.getElementById('status-message'); +const presetButtons = document.querySelectorAll('.preset-btn'); +const requestMicBtn = document.getElementById('request-mic-btn'); +const micStatus = document.getElementById('mic-status'); + +// Initialize +document.addEventListener('DOMContentLoaded', () => { + loadSettings(); + attachEventListeners(); + checkMicrophonePermission(); +}); + +/** + * Attach event listeners + */ +function attachEventListeners() { + form.addEventListener('submit', handleSubmit); + toggleTokenBtn.addEventListener('click', toggleTokenVisibility); + testBtn.addEventListener('click', testConnection); + resetBtn.addEventListener('click', resetToDefaults); + requestMicBtn.addEventListener('click', requestMicrophonePermission); + + presetButtons.forEach(btn => { + btn.addEventListener('click', () => { + const provider = btn.dataset.provider; + applyPreset(provider); + }); + }); +} + +/** + * Check if microphone permission is already granted + */ +async function checkMicrophonePermission() { + try { + // Try to query permission state using Permissions API + if (navigator.permissions && navigator.permissions.query) { + try { + const result = await navigator.permissions.query({ name: 'microphone' }); + updateMicrophoneStatus(result.state); + + // Listen for permission changes + result.addEventListener('change', () => { + updateMicrophoneStatus(result.state); + }); + + // If not granted, show a reminder + if (result.state !== 'granted') { + showStatus('Please grant microphone access to use the extension', 'info'); + } + } catch (queryError) { + // Permissions API query failed, try direct access check + console.log('Permissions query failed, trying direct check:', queryError); + await checkMicrophoneAccessDirectly(); + } + } else { + // Permissions API not supported, try direct check + await checkMicrophoneAccessDirectly(); + } + } catch (error) { + console.log('Permission check not supported, will request on button click'); + } +} + +/** + * Check microphone access directly by attempting to access it + */ +async function checkMicrophoneAccessDirectly() { + try { + // Try to get media devices list to check if we have access + const devices = await navigator.mediaDevices.enumerateDevices(); + const hasAudioInput = devices.some(device => device.kind === 'audioinput'); + + if (hasAudioInput) { + // We can see audio devices, check if we have permission + const audioDevicesWithLabel = devices.filter( + device => device.kind === 'audioinput' && device.label !== '' + ); + + if (audioDevicesWithLabel.length > 0) { + // If we can see device labels, we have permission + updateMicrophoneStatus('granted'); + } else { + // No labels means no permission yet + updateMicrophoneStatus('prompt'); + showStatus('Please grant microphone access to use the extension', 'info'); + } + } else { + // No audio input devices found + updateMicrophoneStatus('prompt'); + } + } catch (error) { + console.error('Error checking microphone access:', error); + updateMicrophoneStatus('prompt'); + } +} + +/** + * Request microphone permission + */ +async function requestMicrophonePermission() { + requestMicBtn.disabled = true; + requestMicBtn.textContent = 'Requesting...'; + + try { + // Request microphone access + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + + // Permission granted - stop the stream immediately + stream.getTracks().forEach(track => track.stop()); + + updateMicrophoneStatus('granted'); + showStatus('Microphone permission granted! You can now use the extension.', 'success'); + + } catch (error) { + console.error('Microphone permission error:', error); + + if (error.name === 'NotAllowedError') { + updateMicrophoneStatus('denied'); + showStatus('Microphone permission denied. Please allow access in your browser settings.', 'error'); + } else if (error.name === 'NotFoundError') { + updateMicrophoneStatus('denied'); + showStatus('No microphone found. Please connect a microphone and try again.', 'error'); + } else { + updateMicrophoneStatus('denied'); + showStatus('Error accessing microphone: ' + error.message, 'error'); + } + } finally { + requestMicBtn.disabled = false; + requestMicBtn.textContent = 'Grant Microphone Access'; + } +} + +/** + * Update microphone permission status UI + */ +function updateMicrophoneStatus(state) { + micStatus.classList.remove('hidden', 'granted', 'denied'); + + if (state === 'granted') { + micStatus.classList.add('granted'); + micStatus.textContent = '✓ Microphone access granted'; + requestMicBtn.textContent = 'Microphone Access Granted'; + requestMicBtn.disabled = true; + } else if (state === 'denied') { + micStatus.classList.add('denied'); + micStatus.textContent = '✗ Microphone access denied'; + requestMicBtn.textContent = 'Grant Microphone Access'; + requestMicBtn.disabled = false; + } else if (state === 'prompt') { + micStatus.classList.add('hidden'); + requestMicBtn.textContent = 'Grant Microphone Access'; + requestMicBtn.disabled = false; + } +} + +/** + * Load settings from storage + */ +async function loadSettings() { + try { + const result = await browser.storage.sync.get(['apiEndpoint', 'apiToken', 'model']); + + if (result.apiEndpoint) { + apiEndpointInput.value = result.apiEndpoint; + } else { + apiEndpointInput.value = PRESETS.openai.apiEndpoint; + } + + if (result.apiToken) { + apiTokenInput.value = result.apiToken; + } + + if (result.model) { + modelInput.value = result.model; + } else { + modelInput.value = PRESETS.openai.model; + } + + // Highlight active preset + updateActivePreset(); + + } catch (error) { + console.error('Error loading settings:', error); + showStatus('Error loading settings: ' + error.message, 'error'); + } +} + +/** + * Handle form submission + */ +async function handleSubmit(event) { + event.preventDefault(); + + const settings = { + apiEndpoint: apiEndpointInput.value.trim(), + apiToken: apiTokenInput.value.trim(), + model: modelInput.value.trim() + }; + + // Validate settings + if (!settings.apiEndpoint) { + showStatus('Please enter an API endpoint', 'error'); + return; + } + + if (!settings.apiToken) { + showStatus('Please enter an API token', 'error'); + return; + } + + if (!settings.model) { + showStatus('Please enter a model name', 'error'); + return; + } + + // Validate URL + try { + new URL(settings.apiEndpoint); + } catch (e) { + showStatus('Please enter a valid URL for the API endpoint', 'error'); + return; + } + + try { + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + await browser.storage.sync.set(settings); + + showStatus('Settings saved successfully!', 'success'); + updateActivePreset(); + + // Reset button text after a delay + setTimeout(() => { + saveBtn.disabled = false; + saveBtn.textContent = 'Save Settings'; + }, 2000); + + } catch (error) { + console.error('Error saving settings:', error); + showStatus('Error saving settings: ' + error.message, 'error'); + saveBtn.disabled = false; + saveBtn.textContent = 'Save Settings'; + } +} + +/** + * Toggle API token visibility + */ +function toggleTokenVisibility() { + if (apiTokenInput.type === 'password') { + apiTokenInput.type = 'text'; + toggleTokenBtn.textContent = 'Hide'; + } else { + apiTokenInput.type = 'password'; + toggleTokenBtn.textContent = 'Show'; + } +} + +/** + * Test API connection + */ +async function testConnection() { + const settings = { + apiEndpoint: apiEndpointInput.value.trim(), + apiToken: apiTokenInput.value.trim(), + model: modelInput.value.trim() + }; + + if (!settings.apiEndpoint || !settings.apiToken) { + showStatus('Please fill in API endpoint and token first', 'error'); + return; + } + + try { + testBtn.disabled = true; + testBtn.textContent = 'Testing...'; + + showStatus('Testing connection to API...', 'info'); + + // Create a minimal audio file for testing (1 second of silence) + const sampleRate = 16000; + const duration = 1; + const numSamples = sampleRate * duration; + const buffer = new ArrayBuffer(44 + numSamples * 2); + const view = new DataView(buffer); + + // WAV header + const writeString = (offset, string) => { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } + }; + + writeString(0, 'RIFF'); + view.setUint32(4, 36 + numSamples * 2, true); + writeString(8, 'WAVE'); + writeString(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + writeString(36, 'data'); + view.setUint32(40, numSamples * 2, true); + + const blob = new Blob([buffer], { type: 'audio/wav' }); + + // Test API request + const formData = new FormData(); + formData.append('file', blob, 'test.wav'); + formData.append('model', settings.model); + + const response = await fetch(settings.apiEndpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${settings.apiToken}` + }, + body: formData + }); + + if (response.ok) { + showStatus('Connection successful! API is working correctly.', 'success'); + } else { + const errorText = await response.text(); + let errorMessage = `Connection failed: ${response.status} ${response.statusText}`; + + try { + const errorJson = JSON.parse(errorText); + if (errorJson.error && errorJson.error.message) { + errorMessage = errorJson.error.message; + } + } catch (e) { + if (errorText) { + errorMessage += ` - ${errorText}`; + } + } + + showStatus(errorMessage, 'error'); + } + + } catch (error) { + console.error('Connection test error:', error); + showStatus('Connection test failed: ' + error.message, 'error'); + } finally { + testBtn.disabled = false; + testBtn.textContent = 'Test Connection'; + } +} + +/** + * Reset settings to defaults + */ +async function resetToDefaults() { + if (!confirm('Are you sure you want to reset all settings to defaults?')) { + return; + } + + try { + await browser.storage.sync.clear(); + await loadSettings(); + showStatus('Settings reset to defaults', 'success'); + } catch (error) { + console.error('Error resetting settings:', error); + showStatus('Error resetting settings: ' + error.message, 'error'); + } +} + +/** + * Apply preset configuration + */ +function applyPreset(provider) { + const preset = PRESETS[provider]; + + if (!preset) { + return; + } + + if (preset.apiEndpoint) { + apiEndpointInput.value = preset.apiEndpoint; + } + + if (preset.model) { + modelInput.value = preset.model; + } + + // Don't clear the API token when switching presets + // Just highlight the selected preset + updateActivePreset(); + + showStatus(`${provider.toUpperCase()} preset applied. Don't forget to enter your API token!`, 'info'); +} + +/** + * Update active preset button + */ +function updateActivePreset() { + const currentEndpoint = apiEndpointInput.value; + + presetButtons.forEach(btn => { + const provider = btn.dataset.provider; + const preset = PRESETS[provider]; + + if (preset.apiEndpoint === currentEndpoint) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); +} + +/** + * Show status message + */ +function showStatus(message, type = 'info') { + statusMessage.textContent = message; + statusMessage.className = `status-message ${type}`; + statusMessage.classList.remove('hidden'); + + // Auto-hide after 5 seconds for success messages + if (type === 'success') { + setTimeout(() => { + statusMessage.classList.add('hidden'); + }, 5000); + } +} + +console.log('Voice to Text settings page loaded'); @@ -0,0 +1,219 @@ +/** + * Audio processor utility for Voice to Text extension + * Handles audio format conversion and processing + */ + +/** + * Convert audio blob to different format if needed + * @param {Blob} audioBlob - The input audio blob + * @param {string} targetFormat - Target format (e.g., 'wav', 'mp3') + * @returns {Promise<Blob>} - Converted audio blob + */ +async function convertAudioFormat(audioBlob, targetFormat = 'wav') { + // For now, we'll return the original blob as most APIs accept webm + // This function can be extended with AudioContext for actual conversion + return audioBlob; +} + +/** + * Get audio duration from blob + * @param {Blob} audioBlob - The audio blob + * @returns {Promise<number>} - Duration in seconds + */ +function getAudioDuration(audioBlob) { + return new Promise((resolve, reject) => { + const audio = new Audio(); + audio.addEventListener('loadedmetadata', () => { + resolve(audio.duration); + }); + audio.addEventListener('error', reject); + audio.src = URL.createObjectURL(audioBlob); + }); +} + +/** + * Resample audio to target sample rate using Web Audio API + * @param {Blob} audioBlob - The input audio blob + * @param {number} targetSampleRate - Target sample rate (e.g., 16000) + * @returns {Promise<Blob>} - Resampled audio blob + */ +async function resampleAudio(audioBlob, targetSampleRate = 16000) { + try { + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const arrayBuffer = await audioBlob.arrayBuffer(); + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + + // If already at target sample rate, return original + if (audioBuffer.sampleRate === targetSampleRate) { + return audioBlob; + } + + // Create offline context with target sample rate + const offlineContext = new OfflineAudioContext( + audioBuffer.numberOfChannels, + audioBuffer.duration * targetSampleRate, + targetSampleRate + ); + + // Create buffer source + const source = offlineContext.createBufferSource(); + source.buffer = audioBuffer; + source.connect(offlineContext.destination); + source.start(0); + + // Render audio + const resampledBuffer = await offlineContext.startRendering(); + + // Convert to WAV + const wavBlob = audioBufferToWav(resampledBuffer); + + return wavBlob; + + } catch (error) { + console.error('Error resampling audio:', error); + // Return original on error + return audioBlob; + } +} + +/** + * Convert AudioBuffer to WAV blob + * @param {AudioBuffer} audioBuffer - The audio buffer + * @returns {Blob} - WAV blob + */ +function audioBufferToWav(audioBuffer) { + const numberOfChannels = audioBuffer.numberOfChannels; + const sampleRate = audioBuffer.sampleRate; + const format = 1; // PCM + const bitDepth = 16; + + const bytesPerSample = bitDepth / 8; + const blockAlign = numberOfChannels * bytesPerSample; + + const data = []; + for (let i = 0; i < numberOfChannels; i++) { + data.push(audioBuffer.getChannelData(i)); + } + + const interleaved = interleaveChannels(data); + const dataLength = interleaved.length * bytesPerSample; + const buffer = new ArrayBuffer(44 + dataLength); + const view = new DataView(buffer); + + // Write WAV header + writeString(view, 0, 'RIFF'); + view.setUint32(4, 36 + dataLength, true); + writeString(view, 8, 'WAVE'); + writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, format, true); + view.setUint16(22, numberOfChannels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * blockAlign, true); + view.setUint16(32, blockAlign, true); + view.setUint16(34, bitDepth, true); + writeString(view, 36, 'data'); + view.setUint32(40, dataLength, true); + + // Write audio data + floatTo16BitPCM(view, 44, interleaved); + + return new Blob([buffer], { type: 'audio/wav' }); +} + +/** + * Interleave multiple audio channels + * @param {Array<Float32Array>} channels - Array of channel data + * @returns {Float32Array} - Interleaved data + */ +function interleaveChannels(channels) { + const length = channels[0].length; + const numberOfChannels = channels.length; + const result = new Float32Array(length * numberOfChannels); + + let offset = 0; + for (let i = 0; i < length; i++) { + for (let channel = 0; channel < numberOfChannels; channel++) { + result[offset++] = channels[channel][i]; + } + } + + return result; +} + +/** + * Convert float samples to 16-bit PCM + * @param {DataView} view - DataView to write to + * @param {number} offset - Offset in the view + * @param {Float32Array} input - Input float samples + */ +function floatTo16BitPCM(view, offset, input) { + for (let i = 0; i < input.length; i++, offset += 2) { + const s = Math.max(-1, Math.min(1, input[i])); + view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true); + } +} + +/** + * Write string to DataView + * @param {DataView} view - DataView to write to + * @param {number} offset - Offset in the view + * @param {string} string - String to write + */ +function writeString(view, offset, string) { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } +} + +/** + * Normalize audio volume + * @param {Blob} audioBlob - The input audio blob + * @returns {Promise<Blob>} - Normalized audio blob + */ +async function normalizeAudio(audioBlob) { + try { + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const arrayBuffer = await audioBlob.arrayBuffer(); + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + + // Find peak value + let peak = 0; + for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) { + const data = audioBuffer.getChannelData(channel); + for (let i = 0; i < data.length; i++) { + const abs = Math.abs(data[i]); + if (abs > peak) peak = abs; + } + } + + // Normalize if needed + if (peak > 0 && peak < 0.95) { + const gain = 0.95 / peak; + for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) { + const data = audioBuffer.getChannelData(channel); + for (let i = 0; i < data.length; i++) { + data[i] *= gain; + } + } + } + + // Convert to WAV + return audioBufferToWav(audioBuffer); + + } catch (error) { + console.error('Error normalizing audio:', error); + return audioBlob; + } +} + +// Export functions if using modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + convertAudioFormat, + getAudioDuration, + resampleAudio, + audioBufferToWav, + normalizeAudio + }; +}