// 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)}; } };