Skip to main content
Glama

mcp-voice-hooks

app.js29.4 kB
class VoiceHooksClient { constructor() { this.baseUrl = window.location.origin; this.debug = localStorage.getItem('voiceHooksDebug') === 'true'; this.refreshBtn = document.getElementById('refreshBtn'); this.clearAllBtn = document.getElementById('clearAllBtn'); this.utterancesList = document.getElementById('utterancesList'); this.infoMessage = document.getElementById('infoMessage'); // Voice controls this.listenBtn = document.getElementById('listenBtn'); this.listenBtnText = document.getElementById('listenBtnText'); this.listeningIndicator = document.getElementById('listeningIndicator'); this.interimText = document.getElementById('interimText'); // Speech recognition this.recognition = null; this.isListening = false; this.initializeSpeechRecognition(); // Speech synthesis this.initializeSpeechSynthesis(); // Server-Sent Events for TTS this.initializeTTSEvents(); // TTS controls this.languageSelect = document.getElementById('languageSelect'); this.voiceSelect = document.getElementById('voiceSelect'); this.speechRateSlider = document.getElementById('speechRate'); this.speechRateInput = document.getElementById('speechRateInput'); this.testTTSBtn = document.getElementById('testTTSBtn'); this.voiceResponsesToggle = document.getElementById('voiceResponsesToggle'); this.voiceOptions = document.getElementById('voiceOptions'); this.localVoicesGroup = document.getElementById('localVoicesGroup'); this.cloudVoicesGroup = document.getElementById('cloudVoicesGroup'); this.rateWarning = document.getElementById('rateWarning'); this.systemVoiceInfo = document.getElementById('systemVoiceInfo'); // Load saved preferences this.loadPreferences(); this.setupEventListeners(); this.loadData(); // Auto-refresh every 2 seconds setInterval(() => this.loadData(), 2000); } initializeSpeechRecognition() { // Check for browser support const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { console.error('Speech recognition not supported in this browser'); this.listenBtn.disabled = true; this.listenBtnText.textContent = 'Not Supported'; return; } this.recognition = new SpeechRecognition(); this.recognition.continuous = true; this.recognition.interimResults = true; // Handle results this.recognition.onresult = (event) => { let interimTranscript = ''; for (let i = event.resultIndex; i < event.results.length; i++) { const transcript = event.results[i][0].transcript; if (event.results[i].isFinal) { // User paused - send as complete utterance this.sendVoiceUtterance(transcript); // Restore placeholder text this.interimText.textContent = 'Start speaking and your words will appear here...'; this.interimText.classList.remove('active'); } else { // Still speaking - show interim results interimTranscript += transcript; } } if (interimTranscript) { this.interimText.textContent = interimTranscript; this.interimText.classList.add('active'); } }; // Handle errors this.recognition.onerror = (event) => { console.error('Speech recognition error:', event.error); if (event.error === 'no-speech') { // Continue listening return; } if (event.error === 'not-allowed') { alert('Microphone access denied. Please allow microphone access to use voice input.'); } else { alert(`Speech recognition error: ${event.error}`); } this.stopListening(); }; // Handle end this.recognition.onend = () => { if (this.isListening) { // Restart recognition to continue listening try { this.recognition.start(); } catch (e) { console.error('Failed to restart recognition:', e); this.stopListening(); } } }; } setupEventListeners() { this.refreshBtn.addEventListener('click', () => this.loadData()); this.clearAllBtn.addEventListener('click', () => this.clearAllUtterances()); this.listenBtn.addEventListener('click', () => this.toggleListening()); // Language filter if (this.languageSelect) { this.languageSelect.addEventListener('change', () => { // Save language preference localStorage.setItem('selectedLanguage', this.languageSelect.value); // Repopulate voice list with filtered voices this.populateVoiceList(); }); } // TTS controls this.voiceSelect.addEventListener('change', (e) => { this.selectedVoice = e.target.value; // Save selected voice to localStorage localStorage.setItem('selectedVoice', this.selectedVoice); this.updateVoicePreferences(); this.updateVoiceWarnings(); }); this.speechRateSlider.addEventListener('input', (e) => { this.speechRate = parseFloat(e.target.value); this.speechRateInput.value = this.speechRate.toFixed(1); // Save rate to localStorage localStorage.setItem('speechRate', this.speechRate.toString()); }); this.speechRateInput.addEventListener('input', (e) => { let value = parseFloat(e.target.value); if (!isNaN(value)) { value = Math.max(0.5, Math.min(5, value)); // Clamp to valid range this.speechRate = value; this.speechRateSlider.value = value.toString(); this.speechRateInput.value = value.toFixed(1); // Save rate to localStorage localStorage.setItem('speechRate', this.speechRate.toString()); } }); this.testTTSBtn.addEventListener('click', () => { this.speakText('This is Voice Mode for Claude Code. How can I help you today?'); }); // Voice toggle listeners this.voiceResponsesToggle.addEventListener('change', (e) => { const enabled = e.target.checked; localStorage.setItem('voiceResponsesEnabled', enabled); this.updateVoicePreferences(); this.updateVoiceOptionsVisibility(); }); } async loadData() { try { // Load utterances const utterancesResponse = await fetch(`${this.baseUrl}/api/utterances?limit=20`); if (utterancesResponse.ok) { const data = await utterancesResponse.json(); this.updateUtterancesList(data.utterances); } } catch (error) { console.error('Failed to load data:', error); } } updateUtterancesList(utterances) { if (utterances.length === 0) { this.utterancesList.innerHTML = '<div class="empty-state">Nothing yet.</div>'; this.infoMessage.style.display = 'none'; return; } // Check if all messages are pending const allPending = utterances.every(u => u.status === 'pending'); if (allPending) { // Show info message but don't replace the utterances list this.infoMessage.style.display = 'block'; } else { // Hide info message when at least one utterance is delivered this.infoMessage.style.display = 'none'; } this.utterancesList.innerHTML = utterances.map(utterance => ` <div class="utterance-item"> <div class="utterance-text">${this.escapeHtml(utterance.text)}</div> <div class="utterance-meta"> <div>${this.formatTimestamp(utterance.timestamp)}</div> <div class="utterance-status status-${utterance.status}"> ${utterance.status.toUpperCase()} </div> </div> </div> `).join(''); } formatTimestamp(timestamp) { const date = new Date(timestamp); return date.toLocaleTimeString(); } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } toggleListening() { if (this.isListening) { this.stopListening(); } else { this.startListening(); } } async startListening() { if (!this.recognition) { alert('Speech recognition not supported in this browser'); return; } try { this.recognition.start(); this.isListening = true; this.listenBtn.classList.add('listening'); this.listenBtnText.textContent = 'Stop Listening'; this.listeningIndicator.classList.add('active'); this.debugLog('Started listening'); // Notify server that voice input is active await this.updateVoiceInputState(true); } catch (e) { console.error('Failed to start recognition:', e); alert('Failed to start speech recognition. Please try again.'); } } async stopListening() { if (this.recognition) { this.isListening = false; this.recognition.stop(); this.listenBtn.classList.remove('listening'); this.listenBtnText.textContent = 'Start Listening'; this.listeningIndicator.classList.remove('active'); this.interimText.textContent = 'Start speaking and your words will appear here...'; this.interimText.classList.remove('active'); this.debugLog('Stopped listening'); // Notify server that voice input is no longer active await this.updateVoiceInputState(false); } } async sendVoiceUtterance(text) { const trimmedText = text.trim(); if (!trimmedText) return; this.debugLog('Sending voice utterance:', trimmedText); try { const response = await fetch(`${this.baseUrl}/api/potential-utterances`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ text: trimmedText, timestamp: new Date().toISOString() }), }); if (response.ok) { this.loadData(); // Refresh the list } else { const error = await response.json(); console.error('Error sending voice utterance:', error); } } catch (error) { console.error('Failed to send voice utterance:', error); } } async clearAllUtterances() { this.clearAllBtn.disabled = true; this.clearAllBtn.textContent = 'Clearing...'; try { const response = await fetch(`${this.baseUrl}/api/utterances`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', } }); if (response.ok) { const result = await response.json(); this.loadData(); // Refresh the list this.debugLog('Cleared all utterances:', result); } else { const error = await response.json(); alert(`Error: ${error.error || 'Failed to clear utterances'}`); } } catch (error) { console.error('Failed to clear utterances:', error); alert('Failed to clear utterances. Make sure the server is running.'); } finally { this.clearAllBtn.disabled = false; this.clearAllBtn.textContent = 'Clear All'; } } debugLog(...args) { if (this.debug) { console.log(...args); } } initializeSpeechSynthesis() { // Check for browser support if (!window.speechSynthesis) { console.warn('Speech synthesis not supported in this browser'); return; } // Get available voices this.voices = []; // Enhanced voice loading with deduplication const loadVoices = () => { const voices = window.speechSynthesis.getVoices(); // Deduplicate voices - keep the first occurrence of each unique voice const deduplicatedVoices = []; const seen = new Set(); voices.forEach(voice => { // Create a unique key based on name, language, and URI const key = `${voice.name}-${voice.lang}-${voice.voiceURI}`; if (!seen.has(key)) { seen.add(key); deduplicatedVoices.push(voice); } }); this.voices = deduplicatedVoices; this.populateVoiceList(); }; // Load voices initially and with a delayed retry for reliability loadVoices(); setTimeout(loadVoices, 100); // Set up voice change listener if (window.speechSynthesis.onvoiceschanged !== undefined) { window.speechSynthesis.onvoiceschanged = loadVoices; } // Set default voice preferences this.speechRate = 1.0; this.speechPitch = 1.0; this.selectedVoice = 'system'; } initializeTTSEvents() { // Connect to Server-Sent Events endpoint this.eventSource = new EventSource(`${this.baseUrl}/api/tts-events`); this.eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); this.debugLog('TTS Event:', data); if (data.type === 'speak' && data.text) { this.speakText(data.text); } else if (data.type === 'waitStatus') { this.handleWaitStatus(data.isWaiting); } } catch (error) { console.error('Failed to parse TTS event:', error); } }; this.eventSource.onerror = (error) => { console.error('SSE connection error:', error); // Will automatically reconnect }; this.eventSource.onopen = () => { this.debugLog('TTS Events connected'); // Sync state when connection is established (includes reconnections) this.syncStateWithServer(); }; } populateLanguageFilter() { if (!this.languageSelect || !this.voices) return; // Get current selection const currentSelection = this.languageSelect.value || 'en-US'; // Clear existing options this.languageSelect.innerHTML = ''; // Add "All Languages" option const allOption = document.createElement('option'); allOption.value = 'all'; allOption.textContent = 'All Languages'; this.languageSelect.appendChild(allOption); // Collect unique language codes const languageCodes = new Set(); this.voices.forEach(voice => { languageCodes.add(voice.lang); }); // Sort and add language codes Array.from(languageCodes).sort().forEach(lang => { const option = document.createElement('option'); option.value = lang; option.textContent = lang; this.languageSelect.appendChild(option); }); // Restore selection this.languageSelect.value = currentSelection; if (this.languageSelect.value !== currentSelection) { // If saved selection not available, default to en-US this.languageSelect.value = 'en-US'; } } populateVoiceList() { if (!this.voiceSelect || !this.localVoicesGroup || !this.cloudVoicesGroup) return; // First populate the language filter this.populateLanguageFilter(); // Clear existing browser voice options this.localVoicesGroup.innerHTML = ''; this.cloudVoicesGroup.innerHTML = ''; // List of voices to exclude (novelty, Eloquence, and non-premium voices) const excludedVoices = [ // Eloquence voices 'Eddy', 'Flo', 'Grandma', 'Grandpa', 'Reed', 'Rocko', 'Sandy', 'Shelley', // Novelty voices 'Albert', 'Bad News', 'Bahh', 'Bells', 'Boing', 'Bubbles', 'Cellos', 'Good News', 'Jester', 'Organ', 'Superstar', 'Trinoids', 'Whisper', 'Wobble', 'Zarvox', // Voices without premium options 'Fred', 'Junior', 'Kathy', 'Ralph' ]; // Get selected language filter const selectedLanguage = this.languageSelect ? this.languageSelect.value : 'en-US'; // Filter voices based on selected language this.voices.forEach((voice, index) => { const voiceLang = voice.lang; let shouldInclude = false; if (selectedLanguage === 'all') { // Include all languages shouldInclude = true; } else { // Check if voice matches selected language/locale shouldInclude = voiceLang === selectedLanguage; } if (shouldInclude) { // Check if voice should be excluded const voiceName = voice.name; const isExcluded = excludedVoices.some(excluded => voiceName.toLowerCase().startsWith(excluded.toLowerCase()) ); if (!isExcluded) { const option = document.createElement('option'); option.value = `browser:${index}`; // Show voice name and language code option.textContent = `${voice.name} (${voice.lang})`; // Categorize voices if (voice.localService) { this.localVoicesGroup.appendChild(option); this.debugLog(voice.voiceURI); } else { this.cloudVoicesGroup.appendChild(option); } } } }); // Hide empty groups if (this.localVoicesGroup.children.length === 0) { this.localVoicesGroup.style.display = 'none'; } else { this.localVoicesGroup.style.display = ''; } if (this.cloudVoicesGroup.children.length === 0) { this.cloudVoicesGroup.style.display = 'none'; } else { this.cloudVoicesGroup.style.display = ''; } // Restore saved selection const savedVoice = localStorage.getItem('selectedVoice'); if (savedVoice) { this.voiceSelect.value = savedVoice; this.selectedVoice = savedVoice; } else { // Look for Google US English Male voice first let googleUSMaleIndex = -1; let microsoftAndrewIndex = -1; this.voices.forEach((voice, index) => { const voiceName = voice.name.toLowerCase(); // Check for Google US English Male if (voiceName.includes('google') && voiceName.includes('us') && voiceName.includes('english')) { googleUSMaleIndex = index; } // Check for Microsoft Andrew Online if (voiceName.includes('microsoft') && voiceName.includes('andrew') && voiceName.includes('online')) { microsoftAndrewIndex = index; } }); if (googleUSMaleIndex !== -1) { this.selectedVoice = `browser:${googleUSMaleIndex}`; this.voiceSelect.value = this.selectedVoice; this.debugLog('Defaulting to Google US English Male voice'); } else if (microsoftAndrewIndex !== -1) { this.selectedVoice = `browser:${microsoftAndrewIndex}`; this.voiceSelect.value = this.selectedVoice; this.debugLog('Google US English Male not found, defaulting to Microsoft Andrew Online'); } else { this.selectedVoice = 'system'; this.debugLog('Preferred voices not found, using system default'); } } // Update warnings based on selected voice this.updateVoiceWarnings(); } async speakText(text) { // Check if we should use system voice if (this.selectedVoice === 'system') { // Use Mac system voice via server try { const response = await fetch(`${this.baseUrl}/api/speak-system`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ text: text, rate: Math.round(this.speechRate * 150) // Convert rate to words per minute }), }); if (!response.ok) { const error = await response.json(); console.error('Failed to speak via system voice:', error); } } catch (error) { console.error('Failed to call speak-system API:', error); } } else { // Use browser voice if (!window.speechSynthesis) { console.error('Speech synthesis not available'); return; } // Cancel any ongoing speech window.speechSynthesis.cancel(); // Create utterance const utterance = new SpeechSynthesisUtterance(text); // Set voice if using browser voice if (this.selectedVoice && this.selectedVoice.startsWith('browser:')) { const voiceIndex = parseInt(this.selectedVoice.substring(8)); if (this.voices[voiceIndex]) { utterance.voice = this.voices[voiceIndex]; } } // Set speech properties utterance.rate = this.speechRate; utterance.pitch = this.speechPitch; // Event handlers utterance.onstart = () => { this.debugLog('Started speaking:', text); }; utterance.onend = () => { this.debugLog('Finished speaking'); }; utterance.onerror = (event) => { console.error('Speech synthesis error:', event); }; // Speak the text window.speechSynthesis.speak(utterance); } } loadPreferences() { // Simple localStorage with defaults to true const storedVoiceResponses = localStorage.getItem('voiceResponsesEnabled'); // Default to true if not stored const voiceResponsesEnabled = storedVoiceResponses !== null ? storedVoiceResponses === 'true' : true; // Set the checkbox this.voiceResponsesToggle.checked = voiceResponsesEnabled; // Save to localStorage if this is first time if (storedVoiceResponses === null) { localStorage.setItem('voiceResponsesEnabled', 'true'); } // Load voice settings const storedRate = localStorage.getItem('speechRate'); if (storedRate !== null) { this.speechRate = parseFloat(storedRate); this.speechRateSlider.value = storedRate; this.speechRateInput.value = this.speechRate.toFixed(1); } // Load selected voice (will be applied after voices load) this.selectedVoice = localStorage.getItem('selectedVoice') || 'system'; // Load selected language const savedLanguage = localStorage.getItem('selectedLanguage'); if (savedLanguage && this.languageSelect) { this.languageSelect.value = savedLanguage; } // Update UI visibility this.updateVoiceOptionsVisibility(); // Send preferences to server this.updateVoicePreferences(); // Update warnings after preferences are loaded this.updateVoiceWarnings(); } updateVoiceOptionsVisibility() { const voiceResponsesEnabled = this.voiceResponsesToggle.checked; this.voiceOptions.style.display = voiceResponsesEnabled ? 'flex' : 'none'; } async updateVoicePreferences() { const voiceResponsesEnabled = this.voiceResponsesToggle.checked; try { // Send preferences to server await fetch(`${this.baseUrl}/api/voice-preferences`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ voiceResponsesEnabled }), }); this.debugLog('Voice preferences updated:', { voiceResponsesEnabled }); } catch (error) { console.error('Failed to update voice preferences:', error); } } async updateVoiceInputState(active) { try { // Send voice input state to server await fetch(`${this.baseUrl}/api/voice-input-state`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ active }), }); this.debugLog('Voice input state updated:', { active }); } catch (error) { console.error('Failed to update voice input state:', error); } } async syncStateWithServer() { this.debugLog('Syncing state with server after reconnection'); // Sync voice response preferences await this.updateVoicePreferences(); // Sync voice input state if currently listening if (this.isListening) { await this.updateVoiceInputState(true); } } updateVoiceWarnings() { // Show/hide warnings based on selected voice if (this.selectedVoice === 'system') { // Show system voice info for Mac System Voice this.systemVoiceInfo.style.display = 'flex'; this.rateWarning.style.display = 'none'; } else if (this.selectedVoice && this.selectedVoice.startsWith('browser:')) { // Check voice properties const voiceIndex = parseInt(this.selectedVoice.substring(8)); const voice = this.voices[voiceIndex]; if (voice) { const isGoogleVoice = voice.name.toLowerCase().includes('google'); const isLocalVoice = voice.localService === true; // Show appropriate warnings if (isGoogleVoice) { // Show rate warning for Google voices this.rateWarning.style.display = 'flex'; } else { this.rateWarning.style.display = 'none'; } if (isLocalVoice) { // Show system info for local browser voices this.systemVoiceInfo.style.display = 'flex'; } else { this.systemVoiceInfo.style.display = 'none'; } } else { // Hide both warnings if voice not found this.rateWarning.style.display = 'none'; this.systemVoiceInfo.style.display = 'none'; } } else { // Hide both warnings if no voice selected this.rateWarning.style.display = 'none'; this.systemVoiceInfo.style.display = 'none'; } } handleWaitStatus(isWaiting) { const listeningIndicatorText = this.listeningIndicator.querySelector('span'); if (isWaiting) { // Claude is waiting for voice input listeningIndicatorText.textContent = 'Claude is paused and waiting for voice input'; this.debugLog('Claude is waiting for voice input'); } else { // Back to normal listening state listeningIndicatorText.textContent = 'Listening...'; this.debugLog('Claude finished waiting'); } } } // Initialize the client when the page loads document.addEventListener('DOMContentLoaded', () => { new VoiceHooksClient(); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/johnmatthewtennant/mcp-voice-hooks'

If you have feedback or need assistance with the MCP directory API, please join our Discord server