// Natural turn-taking for chat.hermes.bstein.dev using the private Jetsons. (function(){ 'use strict'; const modeBtn=document.getElementById('btnVoiceMode'); const bar=document.getElementById('voiceModeBar'); const indicator=document.getElementById('voiceModeIndicator'); const label=document.getElementById('voiceModeLabel'); const composer=document.getElementById('msg'); if(!modeBtn||!bar||!indicator||!label||!composer||!navigator.mediaDevices||!window.MediaRecorder) return; let ready=false; let active=false; let state='idle'; let generation=0; let recorder=null; let stream=null; let audioContext=null; let vadTimer=null; let currentAudio=null; let thinkingSession=null; const originalAutoRead=window.autoReadLastAssistant; const originalApplyPreference=window._applyVoiceModePref; function toast(message){ if(typeof window.showToast==='function') window.showToast(message,3000); } function setState(next, customLabel){ state=next; indicator.className='voice-mode-indicator '+next; label.textContent=customLabel||(next==='listening'?'Listening…':next==='speaking'?'Speaking…':next==='thinking'?'Thinking…':''); bar.style.display=active&&next!=='idle'?'':'none'; } function stopCapture(){ if(vadTimer){clearInterval(vadTimer);vadTimer=null;} if(recorder&&recorder.state!=='inactive'){ try{recorder.stop();}catch(_){ } } recorder=null; if(stream){stream.getTracks().forEach(function(track){track.stop();});stream=null;} if(audioContext){try{audioContext.close();}catch(_){ }audioContext=null;} } function stopPlayback(){ if(!currentAudio) return; try{currentAudio.pause();currentAudio.currentTime=0;}catch(_){ } currentAudio=null; } function deactivate(showMessage){ generation+=1; active=false; state='idle'; thinkingSession=null; stopCapture(); stopPlayback(); modeBtn.classList.remove('active'); bar.style.display='none'; if(showMessage) toast('Hands-free voice mode off'); } function restartSoon(token, delay){ window.setTimeout(function(){ if(active&&token===generation) startListening(token); },delay||500); } function sendTranscript(transcript, token){ if(!active||token!==generation) return; const text=String(transcript||'').trim(); if(!text){restartSoon(token,350);return;} composer.value=text; if(typeof window.autoResize==='function') window.autoResize(); thinkingSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null; setState('thinking'); if(typeof window.send==='function') window.send(); } async function transcribe(blob, token){ if(!active||token!==generation) return; setState('thinking','Transcribing…'); const ext=(blob.type||'').indexOf('ogg')>=0?'ogg':'webm'; const form=new FormData(); form.append('file',new File([blob],'voice-input.'+ext,{type:blob.type||'audio/'+ext})); try{ const response=await fetch('/api/transcribe',{method:'POST',body:form}); const payload=await response.json().catch(function(){return {};}); if(!response.ok) throw new Error(payload.error||('Whisper request failed: '+response.status)); sendTranscript(payload.transcript,token); }catch(error){ if(!active||token!==generation) return; deactivate(false); toast((error&&error.message)||'Private Whisper is unavailable'); // If the browser supplies its own recognizer, hand control back to the // upstream voice implementation until the Jetson becomes healthy again. if(window.SpeechRecognition||window.webkitSpeechRecognition){ modeBtn.removeEventListener('click',onVoiceClick,true); window.setTimeout(function(){modeBtn.click();},50); } } } async function startListening(token){ if(!active||token!==generation) return; stopCapture(); setState('listening'); try{ const capture=await navigator.mediaDevices.getUserMedia({ audio:(function(){ const constraints={echoCancellation:true,noiseSuppression:true,autoGainControl:true}; const supported=navigator.mediaDevices.getSupportedConstraints?navigator.mediaDevices.getSupportedConstraints():{}; if(supported.voiceIsolation) constraints.voiceIsolation=true; return constraints; })(), }); if(!active||token!==generation){capture.getTracks().forEach(function(track){track.stop();});return;} stream=capture; const Context=window.AudioContext||window.webkitAudioContext; audioContext=new Context(); const analyser=audioContext.createAnalyser(); analyser.fftSize=2048; const highpass=audioContext.createBiquadFilter(); highpass.type='highpass'; highpass.frequency.value=140; highpass.Q.value=0.7; audioContext.createMediaStreamSource(stream).connect(highpass); highpass.connect(analyser); const samples=new Uint8Array(analyser.fftSize); const mimeTypes=['audio/webm;codecs=opus','audio/ogg;codecs=opus','audio/webm']; const mime=mimeTypes.find(function(value){return MediaRecorder.isTypeSupported(value);})||''; const chunks=[]; const preRoll=[]; let heardSpeech=false; let voiceFrames=0; let noiseFloor=0.008; let lastSpeech=Date.now(); const started=Date.now(); recorder=new MediaRecorder(stream,mime?{mimeType:mime}:undefined); recorder.ondataavailable=function(event){ if(!event.data||!event.data.size) return; if(heardSpeech){chunks.push(event.data);return;} preRoll.push(event.data); while(preRoll.length>3) preRoll.shift(); }; recorder.onstop=function(){ if(vadTimer){clearInterval(vadTimer);vadTimer=null;} const recordedStream=stream; stream=null; if(recordedStream) recordedStream.getTracks().forEach(function(track){track.stop();}); if(audioContext){try{audioContext.close();}catch(_){ }audioContext=null;} recorder=null; if(!active||token!==generation) return; if(!heardSpeech||!chunks.length){restartSoon(token,300);return;} transcribe(new Blob(chunks,{type:mime||'audio/webm'}),token); }; recorder.start(250); const silenceMs=Math.max(900,parseInt(localStorage.getItem('hermes-voice-silence-ms')||'1600',10)||1600); vadTimer=window.setInterval(function(){ if(!active||token!==generation||!recorder||recorder.state==='inactive') return; analyser.getByteTimeDomainData(samples); let energy=0; for(let i=0;ispeechThreshold; if(!heardSpeech&&!voiceNow){noiseFloor=(noiseFloor*0.94)+(rms*0.06);} voiceFrames=voiceNow?Math.min(voiceFrames+1,5):Math.max(voiceFrames-1,0); if(!heardSpeech&&voiceFrames>=3){ heardSpeech=true; lastSpeech=now; while(preRoll.length) chunks.push(preRoll.shift()); }else if(heardSpeech&&voiceNow){ lastSpeech=now; } const finished=heardSpeech&&(now-lastSpeech)>=silenceMs; const timedOut=now-started>=90000; const idle=(!heardSpeech)&&(now-started)>=20000; if(finished||timedOut||idle){ if(vadTimer){clearInterval(vadTimer);vadTimer=null;} try{recorder.stop();}catch(_){ } } },100); }catch(error){ if(!active||token!==generation) return; deactivate(false); toast((error&&error.message)||'Microphone permission is required'); } } function cleanForSpeech(text){ if(typeof window._stripForTTS==='function') return window._stripForTTS(text); return String(text||'').replace(/```[\s\S]*?```/g,' code block ').replace(/\s+/g,' ').trim(); } function playBlob(blob, token){ return new Promise(function(resolve,reject){ if(!active||token!==generation){resolve();return;} const url=URL.createObjectURL(blob); const audio=new Audio(url); currentAudio=audio; function cleanup(){ if(currentAudio===audio) currentAudio=null; URL.revokeObjectURL(url); } audio.onended=function(){cleanup();resolve();}; audio.onerror=function(){cleanup();reject(new Error('Local speech playback failed'));}; audio.play().catch(function(error){cleanup();reject(error);}); }); } async function fetchSpeech(chunk){ const response=await fetch('/api/tts',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({text:chunk,engine:'atlas'}), }); if(!response.ok){ const payload=await response.json().catch(function(){return {};}); throw new Error(payload.error||('Local speech request failed: '+response.status)); } return response.blob(); } async function speakResponse(token){ if(!active||token!==generation) return; const currentSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null; if(thinkingSession&¤tSession&&thinkingSession!==currentSession){ thinkingSession=null; restartSoon(token,250); return; } thinkingSession=null; const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]'); if(!rows.length){restartSoon(token,250);return;} const text=cleanForSpeech(rows[rows.length-1].dataset.rawText||''); if(!text){restartSoon(token,250);return;} setState('speaking'); const chunks=typeof window._splitForTTS==='function'?window._splitForTTS(text,280):[text]; try{ let pending=fetchSpeech(chunks[0]); for(let index=0;index