aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--main.html8
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/ext/voice.js200
3 files changed, 161 insertions, 49 deletions
diff --git a/main.html b/main.html
index 656256e..a7f6761 100644
--- a/main.html
+++ b/main.html
@@ -1279,12 +1279,12 @@
}
</script>
- <!-- v4.5.54: Core modules + critical ext modules loaded synchronously as bundles to avoid 503 request storms -->
-<script src="modules/core/core-bundle.js?v=2026-06-13-v4.5.54"></script>
-<script src="modules/ext/critical-ext-bundle.js?v=2026-06-13-v4.5.54"></script>
+ <!-- v4.5.55: Core modules + critical ext modules loaded synchronously as bundles to avoid 503 request storms -->
+<script src="modules/core/core-bundle.js?v=2026-06-15-v4.5.55"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=2026-06-15-v4.5.55"></script>
<!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules -->
-<script src="modules/core/module-loader.js?v=2026-06-13-v4.5.54"></script>
+<script src="modules/core/module-loader.js?v=2026-06-15-v4.5.55"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index cd5ed27..bade62f 100644
--- a/modules/core/module-loader.js
+++ b/modules/core/module-loader.js
@@ -4,7 +4,7 @@
// Core module bundle loaded synchronously in main.html.
// This loader only handles version cache-busting, splash, terminal init, and optional ext modules.
- const LATEST_VER = '2026-06-13-v4.5.54';
+ const LATEST_VER = '2026-06-15-v4.5.55';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {
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