aboutsummaryrefslogtreecommitdiff
path: root/modules/ext/voice.js
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-07-09 06:00:32 +0200
committerAgando <roduccomm@gmail.com>2026-07-09 06:00:32 +0200
commitcf7461ffeb4f53f3f50c9c692a2e4fdcecf17e82 (patch)
treee7780cd41271381cf108c9c81d71a3cb65cfd555 /modules/ext/voice.js
parentea4d09fb5ccba12ed774fcf7c913edfa1e6a851a (diff)
auto: 2026-07-09_06:00 changes
Diffstat (limited to 'modules/ext/voice.js')
-rw-r--r--modules/ext/voice.js91
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) {