387 lines
15 KiB
JavaScript
387 lines
15 KiB
JavaScript
// Natural turn-taking for chat.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;
|
|
let errorTimer=null;
|
|
let visualInputLevel=0;
|
|
const reducedMotion=window.matchMedia?window.matchMedia('(prefers-reduced-motion: reduce)'):{matches:false};
|
|
const ERROR_VISIBLE_MS=3200;
|
|
const STATE_LABELS={
|
|
listening:'Listening',
|
|
transcribing:'Transcribing…',
|
|
thinking:'Thinking…',
|
|
speaking:'Speaking',
|
|
error:'Voice unavailable',
|
|
idle:'',
|
|
};
|
|
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;
|
|
bar.dataset.voiceState=next;
|
|
bar.setAttribute('aria-busy',next==='transcribing'||next==='thinking'?'true':'false');
|
|
label.textContent=customLabel||STATE_LABELS[next]||'';
|
|
bar.style.display=(active&&next!=='idle')||next==='error'?'':'none';
|
|
resetInputLevel();
|
|
}
|
|
|
|
function resetInputLevel(){
|
|
visualInputLevel=0;
|
|
indicator.style.setProperty('--voice-ripple-scale','1.035');
|
|
indicator.style.setProperty('--voice-ripple-opacity','0.3');
|
|
}
|
|
|
|
function updateInputLevel(rms){
|
|
if(reducedMotion.matches||state!=='listening') return;
|
|
const target=Math.max(0,Math.min(1,(rms-0.01)/0.18));
|
|
visualInputLevel=(visualInputLevel*0.72)+(target*0.28);
|
|
indicator.style.setProperty('--voice-ripple-scale',(1.035+(visualInputLevel*0.16)).toFixed(3));
|
|
indicator.style.setProperty('--voice-ripple-opacity',(0.26+(visualInputLevel*0.48)).toFixed(3));
|
|
}
|
|
|
|
function clearErrorTimer(){
|
|
if(errorTimer){window.clearTimeout(errorTimer);errorTimer=null;}
|
|
}
|
|
|
|
function errorMessage(error, fallback){
|
|
return String((error&&error.message)||fallback||'Voice unavailable').trim();
|
|
}
|
|
|
|
function showUnavailable(message){
|
|
generation+=1;
|
|
active=false;
|
|
thinkingSession=null;
|
|
stopCapture();
|
|
stopPlayback();
|
|
modeBtn.classList.remove('active');
|
|
setState('error',message);
|
|
clearErrorTimer();
|
|
errorTimer=window.setTimeout(function(){
|
|
errorTimer=null;
|
|
if(!active&&state==='error'&&indicator.classList.contains('error')) setState('idle');
|
|
},ERROR_VISIBLE_MS);
|
|
}
|
|
|
|
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;
|
|
indicator.classList.remove('is-playing');
|
|
}
|
|
|
|
function deactivate(showMessage){
|
|
generation+=1;
|
|
active=false;
|
|
thinkingSession=null;
|
|
clearErrorTimer();
|
|
stopCapture();
|
|
stopPlayback();
|
|
modeBtn.classList.remove('active');
|
|
setState('idle');
|
|
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('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;
|
|
const message=errorMessage(error,'Private Whisper is unavailable');
|
|
showUnavailable(message);
|
|
toast(message);
|
|
// 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;i<samples.length;i++){
|
|
const normalized=(samples[i]-128)/128;
|
|
energy+=normalized*normalized;
|
|
}
|
|
const rms=Math.sqrt(energy/samples.length);
|
|
updateInputLevel(rms);
|
|
const now=Date.now();
|
|
const speechThreshold=Math.max(0.04,noiseFloor*2.4+0.006);
|
|
const voiceNow=rms>speechThreshold;
|
|
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;
|
|
const message=errorMessage(error,'Microphone permission is required');
|
|
showUnavailable(message);
|
|
toast(message);
|
|
}
|
|
}
|
|
|
|
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;
|
|
indicator.classList.remove('is-playing');
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
audio.onended=function(){cleanup();resolve();};
|
|
audio.onerror=function(){cleanup();reject(new Error('Local speech playback failed'));};
|
|
audio.play().then(function(){
|
|
if(active&&token===generation&¤tAudio===audio) indicator.classList.add('is-playing');
|
|
}).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<chunks.length;index+=1){
|
|
if(!active||token!==generation) return;
|
|
const blob=await pending;
|
|
if(index+1<chunks.length) pending=fetchSpeech(chunks[index+1]);
|
|
await playBlob(blob,token);
|
|
}
|
|
}catch(error){
|
|
if(active&&token===generation){
|
|
const message=errorMessage(error,'Local speech is unavailable');
|
|
setState('error',message);
|
|
toast(message);
|
|
}
|
|
}
|
|
restartSoon(token,450);
|
|
}
|
|
|
|
function activate(){
|
|
generation+=1;
|
|
const token=generation;
|
|
active=true;
|
|
clearErrorTimer();
|
|
modeBtn.classList.add('active');
|
|
toast('Hands-free private voice mode on');
|
|
if(typeof window.stopTTS==='function') window.stopTTS();
|
|
if(typeof S!=='undefined'&&S.busy){setState('thinking');return;}
|
|
startListening(token);
|
|
}
|
|
|
|
function onVoiceClick(event){
|
|
if(!ready) return;
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
if(active) deactivate(true); else activate();
|
|
}
|
|
|
|
async function initialize(){
|
|
try{
|
|
const response=await fetch('/api/transcribe/capability',{cache:'no-store'});
|
|
const capability=await response.json().catch(function(){return {};});
|
|
if(!response.ok||!capability.available||capability.provider!=='local_command') return;
|
|
ready=true;
|
|
if(localStorage.getItem('hermes-atlas-voice-initialized')!=='1'){
|
|
localStorage.setItem('hermes-atlas-voice-initialized','1');
|
|
localStorage.setItem('hermes-voice-mode-button','true');
|
|
localStorage.setItem('hermes-tts-engine','atlas');
|
|
localStorage.setItem('hermes-tts-enabled','true');
|
|
localStorage.setItem('hermes-voice-silence-ms','1600');
|
|
}
|
|
const selector=document.getElementById('settingsTtsEngine');
|
|
if(selector&&!selector.querySelector('option[value="atlas"]')){
|
|
const option=document.createElement('option');
|
|
option.value='atlas';
|
|
option.textContent='Atlas Jetson (private)';
|
|
selector.insertBefore(option,selector.firstChild);
|
|
}
|
|
modeBtn.style.display=localStorage.getItem('hermes-voice-mode-button')==='false'?'none':'';
|
|
modeBtn.addEventListener('click',onVoiceClick,true);
|
|
window._applyVoiceModePref=function(){
|
|
if(typeof originalApplyPreference==='function') originalApplyPreference();
|
|
if(ready){
|
|
const enabled=localStorage.getItem('hermes-voice-mode-button')!=='false';
|
|
modeBtn.style.display=enabled?'':'none';
|
|
if(!enabled&&active) deactivate(false);
|
|
}
|
|
};
|
|
window.autoReadLastAssistant=function(){
|
|
if(active&&state==='thinking'){speakResponse(generation);return;}
|
|
if(typeof originalAutoRead==='function') originalAutoRead.apply(this,arguments);
|
|
};
|
|
window._voiceModeActive=function(){return active;};
|
|
window._voiceModeDeactivate=function(){deactivate(false);};
|
|
window._voiceModeImmediateSend=function(){
|
|
if(active&&recorder&&recorder.state!=='inactive') recorder.stop();
|
|
};
|
|
}catch(_){
|
|
// The upstream browser voice implementation remains available as fallback.
|
|
}
|
|
}
|
|
|
|
initialize();
|
|
})();
|