From efb7293ae8d1938ee2883773df5e063c62da9e32 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 14:04:03 +0000 Subject: [PATCH] feat: add voice input mic button via Web Speech API - index.html: add #btnMic (hidden by default, shown if browser supports SpeechRecognition) and #micStatus listening indicator inside .composer-box - boot.js: IIFE-scoped mic handler wired to Web Speech API * recognition.continuous=false (auto-stops after ~2s silence) * recognition.interimResults=true (live transcript preview in textarea) * Toggles .recording class + shows #micStatus while active * Handles 'not-allowed', 'no-speech', 'network' errors via showToast() * btnSend.onclick stops active recognition before sending * Entire feature disabled/hidden gracefully when API unavailable - style.css: .mic-btn, .mic-btn.recording (red pulse animation), .mic-status, .mic-dot, @keyframes mic-pulse - tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules, JS logic, error handling, and regression checks (376 total, all pass) No API keys, no external libraries, no server changes. Browser-only. Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button). --- static/boot.js | 75 ++++++++- static/index.html | 9 + static/style.css | 5 + tests/test_sprint20.py | 362 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 tests/test_sprint20.py diff --git a/static/boot.js b/static/boot.js index f0a8450..5c5c10f 100644 --- a/static/boot.js +++ b/static/boot.js @@ -8,8 +8,81 @@ async function cancelStream(){ }catch(e){setStatus('Cancel failed: '+e.message);} } -$('btnSend').onclick=send; +$('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();}; $('btnAttach').onclick=()=>$('fileInput').click(); + +// ── Voice input (Web Speech API) ───────────────────────────────────────── +(function(){ + const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition; + if(!SpeechRecognition) return; // Browser unsupported — mic button stays hidden + + const btn=$('btnMic'); + const status=$('micStatus'); + const ta=$('msg'); + btn.style.display=''; // Show button — browser supports speech + + const recognition=new SpeechRecognition(); + recognition.continuous=false; + recognition.interimResults=true; + recognition.lang='en-US'; + + let _finalText=''; + + function _setRecording(on){ + window._micActive=on; + btn.classList.toggle('recording',on); + status.style.display=on?'':'none'; + if(!on) _finalText=''; + } + + recognition.onstart=()=>{ _finalText=''; }; + + recognition.onresult=(event)=>{ + let interim=''; + let final=_finalText; + for(let i=event.resultIndex;i{ + _setRecording(false); + // Ensure textarea has the committed final text (handles auto-stop on silence) + if(_finalText) ta.value=_finalText; + autoResize(); + }; + + recognition.onerror=(event)=>{ + _setRecording(false); + const msgs={ + 'not-allowed':'Microphone access denied. Check browser permissions.', + 'no-speech':'No speech detected. Try again.', + 'network':'Speech recognition unavailable.', + }; + showToast(msgs[event.error]||'Voice input error: '+event.error); + }; + + function _stopMic(){ + if(window._micActive){ recognition.stop(); } + } + window._stopMic=_stopMic; // expose for send-guard above + + btn.onclick=()=>{ + if(window._micActive){ + recognition.stop(); + // _setRecording(false) will be called by onend + } else { + _finalText=''; + recognition.start(); + _setRecording(true); + } + }; +})(); +window._micActive=window._micActive||false; $('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';}; $('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();}; $('btnDownload').onclick=()=>{ diff --git a/static/index.html b/static/index.html index 68bdb03..dddd6af 100644 --- a/static/index.html +++ b/static/index.html @@ -213,6 +213,7 @@ Drop files to upload to workspace
+