diff options
| author | Agando <roduccomm@gmail.com> | 2026-06-15 06:00:58 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-06-15 06:00:58 +0200 |
| commit | 5272a7c77711d94ce4e91c1ff1460e0fab8e2f4d (patch) | |
| tree | 0edcbc3e621a4db6e488a8a7afd5befe80d86567 /modules/ext | |
| parent | 3dda77c60c19ebe810ac86e35a5c510c737b7b39 (diff) | |
auto: 2026-06-15_06:00 changes
Diffstat (limited to 'modules/ext')
| -rw-r--r-- | modules/ext/voice.js | 200 |
1 files changed, 156 insertions, 44 deletions
diff --git a/modules/ext/voice.js b/modules/ext/voice.js index 06c5480..de0caed 100644 --- a/modules/ext/voice.js +++ b/modules/ext/voice.js @@ -2,18 +2,28 @@ class VoiceModule { constructor() { this.commands = {}; this.terminal = null; - this.recognition = null; + this.mediaRecorder = null; + this.audioChunks = []; this.listening = false; + this.recordingTimeout = null; this._lastError = ''; + this._sttEndpoint = '/vmai-api/stt'; // VaultMind STT via nginx proxy + this._recordingDuration = 10000; // 10s auto-stop } requirements() { - if (typeof SpeechRecognition !== 'undefined' || typeof webkitSpeechRecognition !== 'undefined') { + // MediaRecorder + getUserMedia work on ALL modern browsers + const hasMic = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia; + const hasRecorder = typeof MediaRecorder !== 'undefined'; + if (hasMic && hasRecorder) { return { met: true, missing: [] }; } - return { met: false, missing: ['SpeechRecognition not available in this browser'] }; + const missing = []; + if (!hasMic) missing.push('Microphone access not available'); + if (!hasRecorder) missing.push('MediaRecorder not available'); + return { met: false, missing }; } getHelpInfo() { - return { 'voice': 'voice [stop] — Start voice recognition, or type "voice stop" to cancel' }; + return { 'voice': 'voice [stop] — Start voice recording (Whisper STT), or type "voice stop" to cancel' }; } registerCommands() { this.commands['voice'] = (args) => this.handle(args); @@ -21,7 +31,7 @@ class VoiceModule { handle(args) { const sub = args[0] && args[0].toLowerCase(); if (sub === 'stop') return this.stopListening(); - if (this.listening) return this.terminal.print('Already listening. Say something or type "voice stop"', 'warning'); + if (this.listening) return this.terminal.print('Already recording. Say something or type "voice stop"', 'warning'); this.startListening(); } _updateButton(listening, error) { @@ -31,7 +41,7 @@ class VoiceModule { btn.style.borderColor = 'var(--success-color)'; btn.style.color = 'var(--success-color)'; btn.style.animation = 'pulse 1.5s ease-in-out infinite'; - btn.textContent = '🔴 Listening'; + btn.textContent = '🔴 Recording'; } else { btn.style.borderColor = ''; btn.style.color = ''; @@ -47,56 +57,158 @@ class VoiceModule { } } startListening() { - const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; - if (!SpeechRecognition) { + if (!navigator.mediaDevices?.getUserMedia) { this._updateButton(false, true); - return this.terminal.print('Speech recognition not supported in this browser', 'error'); + this.terminal.print('🎤 Audio recording not supported in this browser', 'error'); + return; } - this.recognition = new SpeechRecognition(); - this.recognition.lang = 'en-US'; - this.recognition.interimResults = false; - this.recognition.maxAlternatives = 1; - this.recognition.continuous = false; this.listening = true; + this.audioChunks = []; this._updateButton(true); - this.terminal.print('🎤 Listening... (say a command, or tap mic button to cancel)', 'info'); - this.recognition.onresult = (event) => { - const transcript = event.results[0][0].transcript; - this.terminal.print(`🎤 Recognized: "${transcript}"`, 'success'); - const input = this.terminal.inputField; - input.value = transcript; - input.focus(); + this.terminal.print('🎤 Recording... (tap mic again or type "voice stop" to finish)', 'info'); + + navigator.mediaDevices.getUserMedia({ audio: true }) + .then(stream => { + // Prefer webm/opus (Chrome, Firefox) or fall back to whatever works + const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') + ? 'audio/webm;codecs=opus' + : MediaRecorder.isTypeSupported('audio/webm') + ? 'audio/webm' + : MediaRecorder.isTypeSupported('audio/ogg;codecs=opus') + ? 'audio/ogg;codecs=opus' + : ''; + this.mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : {}); + this.mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) this.audioChunks.push(e.data); + }; + this.mediaRecorder.onstop = () => { + stream.getTracks().forEach(t => t.stop()); + this._transcribeAudio(); + }; + this.mediaRecorder.start(); + + // Auto-stop after recording duration + this.recordingTimeout = setTimeout(() => { + if (this.mediaRecorder?.state === 'recording') { + this.mediaRecorder.stop(); + } + }, this._recordingDuration); + }) + .catch(err => { + this.terminal.print('🎤 Mic access denied: ' + err.message, 'error'); + this.listening = false; + this._updateButton(false, true); + }); + } + stopListening() { + if (this.mediaRecorder?.state === 'recording') { + this.mediaRecorder.stop(); + clearTimeout(this.recordingTimeout); + } + this.listening = false; + this._updateButton(false); + this.terminal.print('🎤 Recording stopped', 'info'); + } + _transcribeAudio() { + const blob = new Blob(this.audioChunks, { type: this.mediaRecorder?.mimeType || 'audio/webm' }); + if (blob.size < 100) { + this.terminal.print('🎤 No audio detected', 'warning'); this.listening = false; this._updateButton(false); - }; - this.recognition.onerror = (event) => { - this.terminal.print(`🎤 Error: ${event.error}`, 'error'); - this.listening = false; - this._updateButton(false, true); - }; - this.recognition.onend = () => { - if (this.listening) { - this.listening = false; - this._updateButton(false); - this.terminal.print('🎤 Voice input ended', 'info'); + return; + } + + this.terminal.print('🎤 Transcribing...', 'info'); + + // Convert blob to WAV via AudioContext (MediaRecorder captures compressed) + // We send raw audio bytes — faster-whisper accepts WAV + const reader = new FileReader(); + reader.onload = async () => { + try { + // Decode compressed audio via Web Audio API → raw PCM + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const audioBuffer = await ctx.decodeAudioData(reader.result); + + // Convert to 16-bit PCM WAV at 16kHz + const pcmData = this._audioBufferToWav(audioBuffer); + const b64 = btoa(String.fromCharCode(...new Uint8Array(pcmData))); + + const resp = await fetch(this._sttEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ audio: b64, sample_rate: 16000 }), + }); + const data = await resp.json(); + const text = (data.text || '').trim(); + if (text) { + this.terminal.print('🎤 Recognized: "' + text + '"', 'success'); + const input = document.getElementById('command-input'); + if (input) { + input.value = text; + input.style.height = 'auto'; + input.focus(); + } + } else { + this.terminal.print('🎤 No speech detected', 'warning'); + } + } catch (err) { + this.terminal.print('🎤 Transcription error: ' + err.message, 'error'); } - }; - try { this.recognition.start(); } - catch (e) { - this.terminal.print(`🎤 Failed to start: ${e.message}`, 'error'); this.listening = false; - this._updateButton(false, true); + this._updateButton(false); + }; + reader.readAsArrayBuffer(blob); + } + _audioBufferToWav(audioBuffer) { + // Resample to 16kHz mono 16-bit PCM + const targetSampleRate = 16000; + const numChannels = 1; + const src = audioBuffer.getChannelData(0); // take first channel + const srcRate = audioBuffer.sampleRate; + + // Resample + const ratio = srcRate / targetSampleRate; + const newLen = Math.round(src.length / ratio); + const pcm = new Int16Array(newLen); + for (let i = 0; i < newLen; i++) { + const srcIdx = Math.round(i * ratio); + const sample = Math.max(-1, Math.min(1, src[Math.min(srcIdx, src.length - 1)])); + pcm[i] = sample < 0 ? sample * 0x8000 : sample * 0x7FFF; } + + // Build WAV header + data + const buffer = new ArrayBuffer(44 + pcm.byteLength); + const view = new DataView(buffer); + + // RIFF header + this._writeString(view, 0, 'RIFF'); + view.setUint32(4, 36 + pcm.byteLength, true); + this._writeString(view, 8, 'WAVE'); + + // fmt chunk + this._writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); // chunk size + view.setUint16(20, 1, true); // PCM + view.setUint16(22, numChannels, true); + view.setUint32(24, targetSampleRate, true); + view.setUint32(28, targetSampleRate * numChannels * 2, true); // byte rate + view.setUint16(32, numChannels * 2, true); // block align + view.setUint16(34, 16, true); // bits per sample + + // data chunk + this._writeString(view, 36, 'data'); + view.setUint32(40, pcm.byteLength, true); + + new Uint8Array(buffer, 44).set(new Uint8Array(pcm.buffer)); + + return buffer; } - stopListening() { - if (this.recognition && this.listening) { - this.recognition.stop(); - this.listening = false; - this._updateButton(false); - this.terminal.print('🎤 Listening stopped', 'info'); + _writeString(view, offset, str) { + for (let i = 0; i < str.length; i++) { + view.setUint8(offset + i, str.charCodeAt(i)); } } } if (typeof window.webx11 !== 'undefined' && window.webx11) { webx11.moduleManager.registerModule(new VoiceModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/voice.js'); -} +}
\ No newline at end of file |