aboutsummaryrefslogtreecommitdiff
path: root/chat
diff options
context:
space:
mode:
Diffstat (limited to 'chat')
-rw-r--r--chat/chat-modules.js177
-rw-r--r--chat/chat-ui.html2
2 files changed, 179 insertions, 0 deletions
diff --git a/chat/chat-modules.js b/chat/chat-modules.js
index 295c7c9..e85cdff 100644
--- a/chat/chat-modules.js
+++ b/chat/chat-modules.js
@@ -398,3 +398,180 @@ function importModules() {
if (document.getElementById('chat-overlay')) {
loadModules();
}
+
+// ═══ TTS + SST Integration ═══
+var _chatTTS = false;
+var _chatVoices = [];
+var _chatMicRecording = false;
+var _chatMediaRecorder = null;
+var _chatAudioChunks = [];
+var _chatRecordingTimeout = null;
+var _chatSTTEndpoint = '/vmai-api/stt';
+
+// Pre-load voices
+if (typeof speechSynthesis !== 'undefined') {
+ _chatVoices = speechSynthesis.getVoices() || [];
+ if (_chatVoices.length === 0) {
+ speechSynthesis.onvoiceschanged = function() {
+ _chatVoices = speechSynthesis.getVoices() || [];
+ };
+ }
+}
+
+function toggleChatTTS() {
+ _chatTTS = !_chatTTS;
+ var btn = document.getElementById('chat-tts-btn');
+ if (btn) {
+ btn.style.borderColor = _chatTTS ? 'var(--success-color)' : '';
+ btn.style.color = _chatTTS ? 'var(--success-color)' : '';
+ }
+ if (typeof addLog === 'function') addLog('TTS ' + (_chatTTS ? 'ON' : 'OFF'));
+ if (typeof showToast === 'function') showToast('🔊 TTS ' + (_chatTTS ? 'ON' : 'OFF'));
+}
+
+function chatSpeakText(text) {
+ if (!_chatTTS || typeof speechSynthesis === 'undefined') return;
+ speechSynthesis.cancel();
+ var clean = text.replace(/```[\s\S]*?```/g, ' code block ').replace(/[*_#|`>]/g, '').replace(/\n+/g, '. ').trim();
+ if (!clean) return;
+ function doSpeak(txt) {
+ var utter = new SpeechSynthesisUtterance(txt);
+ utter.rate = 1.0;
+ utter.pitch = 1.0;
+ var voice = _chatVoices.find(function(v) { return v.lang && v.lang.startsWith('en'); }) || (_chatVoices.length > 0 ? _chatVoices[0] : null);
+ if (voice) utter.voice = voice;
+ if (_chatVoices.length === 0) {
+ var spoken = false;
+ speechSynthesis.onvoiceschanged = function() {
+ if (spoken) return; spoken = true;
+ _chatVoices = speechSynthesis.getVoices() || [];
+ var v = _chatVoices.find(function(v) { return v.lang && v.lang.startsWith('en'); }) || _chatVoices[0];
+ if (v) utter.voice = v;
+ speechSynthesis.cancel(); speechSynthesis.speak(utter);
+ speechSynthesis.onvoiceschanged = null;
+ };
+ setTimeout(function() {
+ if (!spoken) { spoken = true; speechSynthesis.onvoiceschanged = null; speechSynthesis.cancel(); speechSynthesis.speak(utter); }
+ }, 2000);
+ } else {
+ speechSynthesis.speak(utter);
+ }
+ }
+ if (clean.length > 500) {
+ var chunks = clean.match(/.{1,500}/g);
+ for (var i = 0; i < chunks.length; i++) doSpeak(chunks[i]);
+ } else {
+ doSpeak(clean);
+ }
+}
+
+function toggleChatMic() {
+ if (_chatMicRecording) {
+ stopChatRecording();
+ } else {
+ startChatRecording();
+ }
+}
+
+function startChatRecording() {
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ if (typeof showToast === 'function') showToast('🎤 Audio recording not supported');
+ return;
+ }
+ _chatMicRecording = true;
+ _chatAudioChunks = [];
+ var btn = document.getElementById('chat-mic-btn');
+ if (btn) { btn.textContent = '🔴'; btn.style.borderColor = 'var(--success-color)'; btn.style.color = 'var(--success-color)'; }
+ if (typeof addLog === 'function') addLog('🎤 Recording... (tap mic again to stop)');
+
+ navigator.mediaDevices.getUserMedia({ audio: true })
+ .then(function(stream) {
+ var mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus'
+ : MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : '';
+ _chatMediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType: mimeType } : {});
+ _chatMediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) _chatAudioChunks.push(e.data); };
+ _chatMediaRecorder.onstop = function() {
+ stream.getTracks().forEach(function(t) { t.stop(); });
+ transcribeChatAudio();
+ };
+ _chatMediaRecorder.start();
+ _chatRecordingTimeout = setTimeout(function() {
+ if (_chatMediaRecorder && _chatMediaRecorder.state === 'recording') _chatMediaRecorder.stop();
+ }, 10000);
+ })
+ .catch(function(err) {
+ if (typeof showToast === 'function') showToast('🎤 Mic denied: ' + err.message);
+ _chatMicRecording = false;
+ var btn = document.getElementById('chat-mic-btn');
+ if (btn) { btn.textContent = '🎤'; btn.style.borderColor = ''; btn.style.color = ''; }
+ });
+}
+
+function stopChatRecording() {
+ if (_chatMediaRecorder && _chatMediaRecorder.state === 'recording') {
+ _chatMediaRecorder.stop();
+ clearTimeout(_chatRecordingTimeout);
+ }
+ _chatMicRecording = false;
+ var btn = document.getElementById('chat-mic-btn');
+ if (btn) { btn.textContent = '🎤'; btn.style.borderColor = ''; btn.style.color = ''; }
+}
+
+function transcribeChatAudio() {
+ var blob = new Blob(_chatAudioChunks, { type: _chatMediaRecorder ? _chatMediaRecorder.mimeType || 'audio/webm' : 'audio/webm' });
+ if (blob.size < 100) {
+ if (typeof addLog === 'function') addLog('🎤 No audio detected');
+ return;
+ }
+ if (typeof addLog === 'function') addLog('🎤 Transcribing...');
+ var reader = new FileReader();
+ reader.onload = async function() {
+ try {
+ var ctx = new (window.AudioContext || window.webkitAudioContext)();
+ var audioBuffer = await ctx.decodeAudioData(reader.result);
+ var pcmData = chatAudioBufferToWav(audioBuffer);
+ var b64 = btoa(String.fromCharCode.apply(null, new Uint8Array(pcmData)));
+ var resp = await fetch(_chatSTTEndpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ audio: b64, sample_rate: 16000 })
+ });
+ var data = await resp.json();
+ var text = (data.text || '').trim();
+ if (text) {
+ if (typeof addLog === 'function') addLog('🎤 Recognized: "' + text + '"');
+ var input = document.getElementById('chatInput');
+ if (input) { input.value = text; input.focus(); }
+ } else {
+ if (typeof addLog === 'function') addLog('🎤 No speech detected');
+ }
+ } catch (err) {
+ if (typeof addLog === 'function') addLog('🎤 Transcription error: ' + err.message);
+ }
+ };
+ reader.readAsArrayBuffer(blob);
+}
+
+function chatAudioBufferToWav(audioBuffer) {
+ var targetSampleRate = 16000;
+ var src = audioBuffer.getChannelData(0);
+ var srcRate = audioBuffer.sampleRate;
+ var ratio = srcRate / targetSampleRate;
+ var newLen = Math.round(src.length / ratio);
+ var pcm = new Int16Array(newLen);
+ for (var i = 0; i < newLen; i++) {
+ var srcIdx = Math.round(i * ratio);
+ var sample = Math.max(-1, Math.min(1, src[Math.min(srcIdx, src.length - 1)]));
+ pcm[i] = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
+ }
+ var buffer = new ArrayBuffer(44 + pcm.byteLength);
+ var view = new DataView(buffer);
+ var writeStr = function(off, str) { for (var j = 0; j < str.length; j++) view.setUint8(off + j, str.charCodeAt(j)); };
+ writeStr(0, 'RIFF'); view.setUint32(4, 36 + pcm.byteLength, true); writeStr(8, 'WAVE');
+ writeStr(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
+ view.setUint32(24, targetSampleRate, true); view.setUint32(28, targetSampleRate * 2, true);
+ view.setUint16(32, 2, true); view.setUint16(34, 16, true);
+ writeStr(36, 'data'); view.setUint32(40, pcm.byteLength, true);
+ new Uint8Array(buffer, 44).set(new Uint8Array(pcm.buffer));
+ return buffer;
+}
diff --git a/chat/chat-ui.html b/chat/chat-ui.html
index 5b8e421..2f53a3a 100644
--- a/chat/chat-ui.html
+++ b/chat/chat-ui.html
@@ -5,6 +5,8 @@
<span class="header-brand">🤖 WebX11 AI</span>
</div>
<div class="header-actions">
+ <button class="btn btn-secondary btn-sm" id="chat-tts-btn" onclick="toggleChatTTS()" title="Text-to-speech toggle">🔊</button>
+ <button class="btn btn-secondary btn-sm" id="chat-mic-btn" onclick="toggleChatMic()" title="Voice input (speech-to-text)">🎤</button>
<button class="btn btn-secondary btn-sm" onclick="openSettingsModal()" title="Settings">⚙️</button>
<select class="model-select" id="modelSelect" title="Select AI Model">
<option value="">Loading...</option>