diff --git a/background.js b/background.js index d1b5337..f42e6b4 100644 --- a/background.js +++ b/background.js @@ -4,7 +4,27 @@ const GEMINI_MODEL = 'gemini-flash-lite-latest'; const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`; -// ─── Message listener ──────────────────────────────────────────────────────── +// ─── Offscreen Document Management ─────────────────────────────────────────── + +let recordingTabId = null; + +async function setupOffscreenDocument(path) { + const offscreenUrl = chrome.runtime.getURL(path); + const existingContexts = await chrome.runtime.getContexts({ + contextTypes: ['OFFSCREEN_DOCUMENT'], + documentUrls: [offscreenUrl] + }); + + if (existingContexts.length > 0) { + return; + } + + await chrome.offscreen.createDocument({ + url: path, + reasons: ['USER_MEDIA'], + justification: 'Recording microphone input for speech recognition', + }); +} chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'GEMINI_REQUEST') { @@ -20,6 +40,38 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { .catch(err => sendResponse({ success: false, error: err.message })); return true; // Keep message channel open for async } + + // --- Offscreen Document Relays --- + if (message.type === 'START_RECORDING_FROM_CONTENT') { + recordingTabId = sender.tab ? sender.tab.id : null; + setupOffscreenDocument('claude-arabic-voice/offscreen.html') + .then(() => { + chrome.runtime.sendMessage({ + type: 'START_RECORDING', + payload: message.payload + }, (response) => { + sendResponse(response || { success: true }); + }); + }) + .catch(err => { + console.error('Failed to setup offscreen doc', err); + sendResponse({ success: false, error: err.message }); + }); + return true; + } + + if (message.type === 'STOP_RECORDING_FROM_CONTENT') { + chrome.runtime.sendMessage({ type: 'STOP_RECORDING' }, (response) => { + sendResponse(response || { success: true }); + }); + return true; + } + + if (message.type.startsWith('OFFSCREEN_RECORDING_')) { + if (recordingTabId) { + chrome.tabs.sendMessage(recordingTabId, message).catch(() => {}); + } + } }); // ─── Core API call ─────────────────────────────────────────────────────────── diff --git a/claude-arabic-voice/content.js b/claude-arabic-voice/content.js index c40dd4c..563c31d 100644 --- a/claude-arabic-voice/content.js +++ b/claude-arabic-voice/content.js @@ -49,76 +49,35 @@ }); } - // ─── Speech Recognition Setup ──────────────────────────────────────────── - function initSpeechRecognition() { - const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; - if (!SpeechRecognition) { - console.warn('[ClaudeVoice] Speech recognition not supported in this browser'); - return null; - } - - const recog = new SpeechRecognition(); - recog.continuous = true; - recog.interimResults = true; - recog.lang = settings.language; - recog.maxAlternatives = 1; - - recog.onresult = (event) => { + // ─── Offscreen Document Integration ────────────────────────────────────── + chrome.runtime.onMessage.addListener((message) => { + if (message.type === 'OFFSCREEN_RECORDING_RESULT') { lastSpeechTime = Date.now(); - interimText = ''; - finalText = ''; - - for (let i = event.resultIndex; i < event.results.length; i++) { - const result = event.results[i]; - if (result.isFinal) { - finalText += result[0].transcript + ' '; - } else { - interimText += result[0].transcript; - } - } - + interimText = message.payload.interimText || ''; + finalText = message.payload.finalText || ''; + updateInputField(); - // Reset silence timer on new speech clearTimeout(silenceTimer); silenceTimer = setTimeout(() => { if (isListening && Date.now() - lastSpeechTime >= SILENCE_TIMEOUT) { stopListening(); } }, SILENCE_TIMEOUT); - }; - - recog.onerror = (event) => { - console.error('[ClaudeVoice] Recognition error:', event.error); - if (event.error === 'no-speech') { - // No speech detected, restart if still listening - if (isListening) { - try { recog.stop(); } catch (e) { } - setTimeout(() => { - if (isListening) { - try { recog.start(); } catch (e) { } - } - }, 300); - } - return; + } else if (message.type === 'OFFSCREEN_RECORDING_ERROR') { + console.error('[ClaudeVoice] Recognition error:', message.payload.error); + if (message.payload.error === 'no-speech') { + return; // offscreen script restarts it automatically } stopListening(); - showStatus('error', '❌ خطأ: ' + getArabicError(event.error)); - }; - - recog.onend = () => { - // If we're still supposed to be listening, restart + showStatus('error', '❌ خطأ: ' + getArabicError(message.payload.error)); + } else if (message.type === 'OFFSCREEN_RECORDING_END') { if (isListening) { - try { - recog.start(); - } catch (e) { - console.warn('[ClaudeVoice] Restart failed:', e); - } + // Unexpected end from offscreen while we still consider ourselves listening + stopListening(); } - }; - - return recog; - } + } + }); function getArabicError(error) { const errors = { @@ -137,59 +96,51 @@ async function startListening() { await loadSettings(); - if (!recognition) { - recognition = initSpeechRecognition(); - } - - if (!recognition) { - showStatus('error', '❌ المتصفح لا يدعم التعرف على الصوت'); - return; - } - - // Update language in case it changed - recognition.lang = settings.language; - try { - recognition.start(); - isListening = true; - lastSpeechTime = Date.now(); - updateMicButton(true); - showStatus('listening', '🎤 جارٍ الاستماع...'); + chrome.runtime.sendMessage({ + type: 'START_RECORDING_FROM_CONTENT', + payload: { language: settings.language } + }, (response) => { + if (response && response.success) { + isListening = true; + lastSpeechTime = Date.now(); + interimText = ''; + finalText = ''; + updateMicButton(true); + showStatus('listening', '🎤 جارٍ الاستماع...'); - // Auto-stop after max time - clearTimeout(autoSendTimer); - autoSendTimer = setTimeout(() => { - if (isListening) stopListening(); - }, MAX_RECORDING_TIME); + clearTimeout(autoSendTimer); + autoSendTimer = setTimeout(() => { + if (isListening) stopListening(); + }, MAX_RECORDING_TIME); - // Silence detection - clearTimeout(silenceTimer); - silenceTimer = setTimeout(() => { - if (isListening && Date.now() - lastSpeechTime >= SILENCE_TIMEOUT) { - stopListening(); + clearTimeout(silenceTimer); + silenceTimer = setTimeout(() => { + if (isListening && Date.now() - lastSpeechTime >= SILENCE_TIMEOUT) { + stopListening(); + } + }, SILENCE_TIMEOUT); + } else { + console.error('[ClaudeVoice] Start failed:', response?.error); + showStatus('error', '❌ فشل بدء التسجيل'); } - }, SILENCE_TIMEOUT); - + }); } catch (e) { - console.error('[ClaudeVoice] Start failed:', e); - showStatus('error', '❌ فشل بدء التسجيل'); + console.error('[ClaudeVoice] Start message failed:', e); + showStatus('error', '❌ فشل الاتصال بالإضافة'); } } async function stopListening() { - if (!recognition) return; + if (!isListening) return; isListening = false; clearTimeout(silenceTimer); clearTimeout(autoSendTimer); - try { - recognition.stop(); - } catch (e) { /* ignore */ } - + chrome.runtime.sendMessage({ type: 'STOP_RECORDING_FROM_CONTENT' }); updateMicButton(false); - // If we have final text, process it const text = finalText.trim() || interimText.trim(); if (text) { showStatus('processing', '⏳ جارٍ المعالجة...'); @@ -197,7 +148,6 @@ if (settings.useGemini && settings.geminiApiKey) { await processWithGemini(text); } else { - // Just insert the text directly insertTextIntoClaude(text); showStatus('done', '✅ تم الإدراج'); setTimeout(() => hideStatus(), 2000); diff --git a/claude-arabic-voice/offscreen.html b/claude-arabic-voice/offscreen.html new file mode 100644 index 0000000..bdf0468 --- /dev/null +++ b/claude-arabic-voice/offscreen.html @@ -0,0 +1,10 @@ + + +
+ +