Files

221 lines
12 KiB
JavaScript

// Short recordings use the same server ASR in mobile browsers and embedded browsers.
var ChatVoice = {
create(options){
const fetch = options.fetch || (typeof window.fetch === 'function' ? window.fetch.bind(window) : globalThis.fetch);
const {toggle, hold, panel, label, clock, inputArea, input, text, status} = options;
let session = null, mode = false;
const active = current => session === current;
function stopTracks(current){
if(current.stream) current.stream.getTracks().forEach(track => track.stop());
}
function clearTimers(current){
clearTimeout(current.limitTimer);
clearTimeout(current.permissionTimer);
clearInterval(current.clockTimer);
}
function render(){
toggle.setAttribute('aria-pressed', String(mode));
toggle.setAttribute('aria-label', text(mode ? 'voiceKeyboard' : 'voiceInput'));
toggle.title = text(mode ? 'voiceKeyboard' : 'voiceInput');
toggle.textContent = mode ? '⌨' : '🎙';
inputArea.classList.toggle('voice-mode', mode);
hold.textContent = text(session ? 'voiceRelease' : 'voiceHold');
hold.classList.toggle('is-recording', Boolean(session));
panel.hidden = !session;
}
function cancel(message){
const current = session;
session = null;
if(current){
current.controller.abort();
clearTimers(current);
if(current.recorder && current.recorder.state !== 'inactive'){
try{ current.recorder.stop(); }catch(error){}
}
stopTracks(current);
}
render();
if(message) status(text(message), true);
}
function setMode(on){
cancel();
mode = on;
render();
if(on && input.blur) input.blur();
}
function supported(){
if(window.isSecureContext === false){ status(text('voiceHttps'), true); return false; }
if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || !window.MediaRecorder){
status(text('voiceUnsupported'), true);
return false;
}
return true;
}
async function jsonRequest(current, url, init, timeout){
const timer = setTimeout(function(){ current.timedOut = true; current.controller.abort(); }, timeout);
try{
const response = await fetch(url, Object.assign({}, init, {signal:current.controller.signal}));
const data = await response.json();
if(!response.ok) throw new Error(data.error || data.e || text('voiceFailed'));
return data;
}finally{ clearTimeout(timer); }
}
async function transcribe(current){
if(!active(current)) return;
if(!current.chunks.length){ cancel('voiceEmpty'); return; }
current.phase = 'transcribing';
label.textContent = text('voiceTranscribing');
const blob = new Blob(current.chunks, {type:current.recorder.mimeType || current.mime});
if(!blob.size || blob.size > current.maxBytes){ cancel('voiceTooLarge'); return; }
const form = new FormData();
form.append('audio', blob, 'recording');
form.append('duration', String(Math.min(60, current.duration / 1000)));
form.append('language', options.language());
try{
const data = await jsonRequest(current, '/api/speech/transcribe', {method:'POST',body:form}, 40000);
if(!active(current)) return;
if(current.context !== options.context()){ cancel(); return; }
if(!data.ok || typeof data.text !== 'string' || !data.text.trim()){
cancel('voiceEmpty'); return;
}
session = null;
render();
await options.onText(data.text.trim());
}catch(error){
if(!active(current)) return;
const message = current.timedOut ? text('voiceTimeout') : error.message || text('voiceFailed');
cancel();
status(message, true);
}
}
function finish(current, cancelled){
if(!active(current) || !current.holding) return;
current.holding = false;
if(cancelled || current.cancelled){ cancel(); status(text('voiceCancelled')); return; }
if(current.phase !== 'recording'){ cancel('voiceTooShort'); return; }
current.duration = Date.now() - current.startedAt;
if(current.duration < 400){ cancel('voiceTooShort'); return; }
current.phase = 'stopping';
label.textContent = text('voiceTranscribing');
clearTimers(current);
try{ current.recorder.stop(); }
catch(error){ cancel('voiceFailed'); }
finally{ stopTracks(current); }
}
async function start(id, y){
if(session || !mode) return;
const reason = options.canStart();
if(reason){ status(reason, true); return; }
if(!supported()) return;
const current = {id:id,startY:y,holding:true,chunks:[],bytes:0,phase:'preparing',
controller:new AbortController(),context:options.context()};
session = current;
label.textContent = text('voicePreparing');
clock.textContent = '';
panel.classList.remove('is-cancelling');
status('');
render();
try{
const config = await jsonRequest(current, '/api/speech/config', {}, 5000);
if(!active(current)) return;
if(!config.enabled){ cancel('voiceUnavailable'); return; }
current.maxBytes = Math.min(Number(config.max_bytes) || 5242880, 5242880);
current.permissionTimer = setTimeout(function(){ if(active(current)) cancel('voiceTimeout'); }, 15000);
const stream = await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true},video:false});
clearTimeout(current.permissionTimer);
if(!active(current)){ stream.getTracks().forEach(track => track.stop()); return; }
current.stream = stream;
const formats = ['audio/webm;codecs=opus','audio/mp4','audio/webm','audio/ogg;codecs=opus'];
current.mime = window.MediaRecorder.isTypeSupported
? formats.find(mime => window.MediaRecorder.isTypeSupported(mime)) || '' : '';
current.recorder = new window.MediaRecorder(stream, Object.assign({audioBitsPerSecond:64000},
current.mime ? {mimeType:current.mime} : {}));
current.recorder.ondataavailable = function(event){
if(!active(current) || !event.data || !event.data.size) return;
current.bytes += event.data.size;
if(current.bytes > current.maxBytes){ cancel('voiceTooLarge'); return; }
current.chunks.push(event.data);
};
current.recorder.onerror = function(){ if(active(current)) cancel('voiceFailed'); };
current.recorder.onstop = function(){
if(!active(current)) return;
if(current.phase === 'stopping') transcribe(current);
else cancel('voiceFailed');
};
current.recorder.start(250);
current.startedAt = Date.now();
current.phase = 'recording';
label.textContent = text('voiceCancelTip');
clock.textContent = '0 / 60 s';
current.clockTimer = setInterval(function(){
clock.textContent = Math.min(60, Math.floor((Date.now()-current.startedAt)/1000)) + ' / 60 s';
}, 250);
current.limitTimer = setTimeout(function(){ finish(current, false); }, 60000);
}catch(error){
if(!active(current)) return;
const key = current.timedOut ? 'voiceTimeout'
: ['NotAllowedError','PermissionDeniedError'].includes(error.name) ? 'voicePermission'
: ['NotFoundError','DevicesNotFoundError'].includes(error.name) ? 'voiceNoMic' : 'voiceFailed';
cancel(key);
}
}
function move(id, y){
if(!session || !session.holding || session.id !== id) return;
session.cancelled = session.startY - y > 72;
panel.classList.toggle('is-cancelling', session.cancelled);
if(session.phase === 'recording') label.textContent = text(session.cancelled ? 'voiceCancelRelease' : 'voiceCancelTip');
}
toggle.onclick = function(){
if(!mode && !supported()) return;
setMode(!mode);
if(!mode) input.focus({preventScroll:true});
};
if(window.PointerEvent){
hold.addEventListener('pointerdown', function(event){
if(event.button !== 0) return;
event.preventDefault();
if(hold.setPointerCapture) try{ hold.setPointerCapture(event.pointerId); }catch(error){}
start(event.pointerId, event.clientY);
});
hold.addEventListener('pointermove', event => move(event.pointerId, event.clientY));
hold.addEventListener('pointerup', function(event){
event.preventDefault();
if(session && session.id === event.pointerId) finish(session, false);
});
['pointercancel','lostpointercapture'].forEach(name => hold.addEventListener(name, function(){
if(session && session.holding) finish(session, true);
}));
}else{
hold.addEventListener('touchstart', function(event){
event.preventDefault();
if(event.touches.length !== 1) return;
const touch=event.touches[0]; start(touch.identifier, touch.clientY);
}, {passive:false});
hold.addEventListener('touchmove', function(event){
event.preventDefault();
for(const touch of event.changedTouches) move(touch.identifier, touch.clientY);
}, {passive:false});
hold.addEventListener('touchend', function(event){
event.preventDefault();
for(const touch of event.changedTouches) if(session && session.id === touch.identifier) finish(session, false);
}, {passive:false});
hold.addEventListener('touchcancel', function(){ if(session) finish(session, true); });
}
hold.addEventListener('contextmenu', event => event.preventDefault());
hold.addEventListener('keydown', function(event){
if(![' ','Enter'].includes(event.key) || event.repeat) return;
event.preventDefault(); start('keyboard', 0);
});
hold.addEventListener('keyup', function(event){
if([' ','Enter'].includes(event.key) && session && session.id === 'keyboard'){
event.preventDefault(); finish(session, false);
}
});
document.addEventListener('keydown', event => { if(event.key === 'Escape') cancel(); });
document.addEventListener('visibilitychange', function(){ if(document.hidden) cancel(); });
window.addEventListener('pagehide', function(){ cancel(); });
render();
return {cancel:cancel,setMode:setMode,refresh:render,busy:() => Boolean(session)};
}
};