diff options
| author | Agando <roduccomm@gmail.com> | 2026-07-09 06:00:32 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-07-09 06:00:32 +0200 |
| commit | 2538c91876455fe96febf441fc8dde20f2606023 (patch) | |
| tree | b74c949cdf4296d10103dd57ef8178ba427a5b39 /modules/ext/voice.js | |
| parent | ea4d09fb5ccba12ed774fcf7c913edfa1e6a851a (diff) | |
auto: 2026-07-09_06:00 changes
Diffstat (limited to 'modules/ext/voice.js')
| -rw-r--r-- | modules/ext/voice.js | 91 |
1 files changed, 62 insertions, 29 deletions
diff --git a/modules/ext/voice.js b/modules/ext/voice.js index b243ace..284d6f3 100644 --- a/modules/ext/voice.js +++ b/modules/ext/voice.js @@ -9,9 +9,29 @@ class VoiceModule { this._lastError = ''; this._sttEndpoint = '/vmai-api/stt'; // VaultMind STT via nginx proxy this._recordingDuration = 10000; // 10s auto-stop + this._voices = []; + this._autoSpeak = false; + } + init(terminal) { + this.terminal = terminal; + // Pre-load TTS voices (Chrome loads async) + if (typeof speechSynthesis !== 'undefined') { + this._voices = speechSynthesis.getVoices() || []; + if (this._voices.length === 0) { + var self = this; + speechSynthesis.onvoiceschanged = function() { + self._voices = speechSynthesis.getVoices() || []; + }; + } + } + } + _getBestVoice() { + var voices = this._voices.length > 0 ? this._voices : (typeof speechSynthesis !== 'undefined' ? speechSynthesis.getVoices() : []); + return voices.find(function(v) { return v.lang && v.lang.startsWith('en') && v.name.includes('Google'); }) + || voices.find(function(v) { return v.lang && v.lang.startsWith('en'); }) + || (voices.length > 0 ? voices[0] : null); } requirements() { - // MediaRecorder + getUserMedia work on ALL modern browsers const hasMic = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia; const hasRecorder = typeof MediaRecorder !== 'undefined'; if (hasMic && hasRecorder) { @@ -23,26 +43,20 @@ class VoiceModule { return { met: false, missing }; } getHelpInfo() { - return { 'voice': 'voice [stop] — Start voice recording (Whisper STT), or type "voice stop" to cancel' }; + return { 'voice': 'voice [stop] — Start voice recording (Whisper STT), or type "voice stop" to cancel', 'speak': 'speak [text] — Text-to-speech. No args = toggle auto-speak for AI responses' }; } registerCommands() { this.commands['voice'] = (args) => this.handle(args); this.commands['speak'] = (args) => this.speak(args); } - getHelpInfo() { - return { 'voice': 'voice [stop] — Start voice recording (Whisper STT), or type "voice stop" to cancel', 'speak': 'speak [text] — Text-to-speech. No args = toggle auto-speak for AI responses' }; - } - speak(args) { const text = args.join(' ').trim(); if (!text) { - // If no text, check if speechSynthesis is available if (typeof speechSynthesis === 'undefined') { this.terminal.print('🔊 TTS not supported in this browser', 'error'); return; } - // Toggle auto-speak mode this._autoSpeak = !this._autoSpeak; this.terminal.print(this._autoSpeak ? '🔊 Auto-speak ON — AI responses will be spoken' : '🔊 Auto-speak OFF', 'info'); return; @@ -51,40 +65,59 @@ class VoiceModule { this.terminal.print('🔊 TTS not supported in this browser', 'error'); return; } + this._doSpeak(text); + this.terminal.print('🔊 Speaking: "' + text.substring(0, 80) + (text.length > 80 ? '...' : '') + '"', 'info'); + } + + _doSpeak(text) { + var self = this; speechSynthesis.cancel(); - const utter = new SpeechSynthesisUtterance(text); + var utter = new SpeechSynthesisUtterance(text); utter.rate = parseFloat(localStorage.getItem('webx11_tts_rate') || '1.0'); utter.pitch = 1.0; utter.volume = 1.0; - // Try to use a good English voice - const voices = speechSynthesis.getVoices(); - const enVoice = voices.find(v => v.lang.startsWith('en') && v.name.includes('Google')) || voices.find(v => v.lang.startsWith('en')); - if (enVoice) utter.voice = enVoice; - utter.onstart = () => this._updateButton(true); - utter.onend = () => this._updateButton(false); - utter.onerror = () => this._updateButton(false); - speechSynthesis.speak(utter); - this.terminal.print('🔊 Speaking: "' + text.substring(0, 80) + (text.length > 80 ? '...' : '') + '"', 'info'); + var voice = this._getBestVoice(); + if (voice) utter.voice = voice; + utter.onstart = function() { self._updateButton(true); }; + utter.onend = function() { self._updateButton(false); }; + utter.onerror = function() { self._updateButton(false); }; + // If voices not loaded yet, wait for voiceschanged with 2s timeout + if (this._voices.length === 0 && typeof speechSynthesis !== 'undefined') { + var spoken = false; + var onReady = function() { + if (spoken) return; + spoken = true; + var v = self._getBestVoice(); + if (v) utter.voice = v; + speechSynthesis.cancel(); + speechSynthesis.speak(utter); + speechSynthesis.onvoiceschanged = null; + }; + speechSynthesis.onvoiceschanged = onReady; + setTimeout(function() { + if (!spoken) { + spoken = true; + speechSynthesis.onvoiceschanged = null; + speechSynthesis.cancel(); + speechSynthesis.speak(utter); + } + }, 2000); + } else { + speechSynthesis.speak(utter); + } } speakText(text) { - // Called by AI assistant to auto-speak responses if (!this._autoSpeak || typeof speechSynthesis === 'undefined') return; speechSynthesis.cancel(); - // Strip markdown/code blocks for cleaner speech const clean = text.replace(/```[\s\S]*?```/g, ' code block ').replace(/[*_#|`>]/g, '').replace(/\n+/g, '. ').trim(); if (clean.length > 500) { - // Split long text into chunks const chunks = clean.match(/.{1,500}/g); - chunks.forEach(c => { - const u = new SpeechSynthesisUtterance(c); - u.rate = parseFloat(localStorage.getItem('webx11_tts_rate') || '1.0'); - speechSynthesis.speak(u); - }); + for (var i = 0; i < chunks.length; i++) { + this._doSpeak(chunks[i]); + } } else { - const u = new SpeechSynthesisUtterance(clean); - u.rate = parseFloat(localStorage.getItem('webx11_tts_rate') || '1.0'); - speechSynthesis.speak(u); + this._doSpeak(clean); } } handle(args) { |