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).
This commit is contained in:
Nathan Esquenazi
2026-04-03 14:04:03 +00:00
parent 44aa538b7c
commit efb7293ae8
4 changed files with 450 additions and 1 deletions
+74 -1
View File
@@ -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<event.results.length;i++){
const t=event.results[i][0].transcript;
if(event.results[i].isFinal){ final+=t; _finalText=final; }
else{ interim+=t; }
}
ta.value=final||interim;
autoResize();
};
recognition.onend=()=>{
_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=()=>{
+9
View File
@@ -213,6 +213,7 @@
Drop files to upload to workspace
</div>
<div class="attach-tray" id="attachTray"></div>
<div class="mic-status" id="micStatus" style="display:none"><span class="mic-dot"></span> Listening…</div>
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
<div class="composer-footer">
<div class="composer-left">
@@ -220,6 +221,14 @@
<button class="icon-btn" id="btnAttach" title="Attach files">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
<button class="icon-btn mic-btn" id="btnMic" title="Voice input" style="display:none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="1" width="6" height="12" rx="3"/>
<path d="M5 10a7 7 0 0 0 14 0"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</button>
</div>
<div class="composer-right">
<button class="send-btn" id="btnSend">
+5
View File
@@ -187,6 +187,11 @@
.icon-btn{width:34px;height:34px;border-radius:8px;background:none;border:none;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:all .15s;}
.icon-btn{opacity:.75;}
.icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);opacity:1;}
.mic-btn{transition:color .15s,background .15s;}
.mic-btn.recording{color:#e94560;background:rgba(233,69,96,.12);animation:mic-pulse 1.2s ease-in-out infinite;}
@keyframes mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(233,69,96,.3);}50%{box-shadow:0 0 0 6px rgba(233,69,96,0);}}
.mic-status{font-size:11px;color:#e94560;padding:4px 12px;display:flex;align-items:center;gap:6px;}
.mic-dot{width:6px;height:6px;border-radius:50%;background:#e94560;animation:mic-pulse 1.2s ease-in-out infinite;flex-shrink:0;}
.status-text{font-size:11px;color:var(--muted);padding-left:4px;}
.send-btn{padding:7px 18px;border-radius:10px;font-size:13px;font-weight:600;background:linear-gradient(135deg,#5ba8f5,#7cb9ff);border:none;color:#0a1628;cursor:pointer;display:flex;align-items:center;gap:6px;transition:all .15s;flex-shrink:0;letter-spacing:.01em;}
.send-btn:hover{background:linear-gradient(135deg,#7cb9ff,#a0d0ff);transform:translateY(-1px);}
+362
View File
@@ -0,0 +1,362 @@
"""
Sprint 20 Tests: Voice input (mic button) via Web Speech API.
These tests verify the static assets contain the correct HTML structure,
CSS rules, and JS logic for the mic feature all of which runs purely in
the browser with no server-side component.
"""
import re
import urllib.request
import json
BASE = "http://127.0.0.1:8788"
def get_text(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return r.read().decode(), r.status
# ── index.html ────────────────────────────────────────────────────────────
def test_mic_button_present_in_html():
"""index.html must contain the mic button with id='btnMic'."""
html, status = get_text("/")
assert status == 200
assert 'id="btnMic"' in html
def test_mic_button_has_mic_btn_class():
"""btnMic must carry the mic-btn CSS class for styling hooks."""
html, _ = get_text("/")
assert 'class="icon-btn mic-btn"' in html
def test_mic_button_hidden_by_default():
"""btnMic starts hidden (display:none) — JS shows it only if supported."""
html, _ = get_text("/")
# The button element should have display:none in its style attribute
assert 'id="btnMic"' in html
btn_match = re.search(r'id="btnMic"[^>]*>', html)
assert btn_match, "btnMic element not found"
assert 'display:none' in btn_match.group(0)
def test_mic_button_has_title():
"""btnMic should have a descriptive title for accessibility."""
html, _ = get_text("/")
btn_match = re.search(r'id="btnMic"[^>]*>', html)
assert btn_match
assert 'title=' in btn_match.group(0)
def test_mic_status_div_present():
"""index.html must contain the #micStatus listening indicator."""
html, _ = get_text("/")
assert 'id="micStatus"' in html
def test_mic_status_hidden_by_default():
"""#micStatus starts hidden — only shown during active recording."""
html, _ = get_text("/")
status_match = re.search(r'id="micStatus"[^>]*>', html)
assert status_match, "#micStatus element not found"
assert 'display:none' in status_match.group(0)
def test_mic_status_has_mic_dot():
"""#micStatus must contain a .mic-dot element for the pulse animation."""
html, _ = get_text("/")
# mic-dot should appear after micStatus
idx_status = html.find('id="micStatus"')
idx_dot = html.find('mic-dot', idx_status)
assert idx_status != -1 and idx_dot != -1
assert idx_dot > idx_status
def test_mic_status_has_listening_text():
"""#micStatus should display a 'Listening' label."""
html, _ = get_text("/")
assert 'Listening' in html
def test_mic_button_svg_microphone_shape():
"""btnMic SVG must include the rect (mic body) and path (mic arc)."""
html, _ = get_text("/")
# Find mic button section
btn_start = html.find('id="btnMic"')
btn_end = html.find('</button>', btn_start) + len('</button>')
btn_html = html[btn_start:btn_end]
assert '<rect' in btn_html, "mic SVG missing rect (mic body)"
assert '<path' in btn_html, "mic SVG missing path (arc)"
assert '<line' in btn_html, "mic SVG missing line (stand)"
def test_mic_button_inside_composer_left():
"""btnMic must be inside .composer-left, next to the attach button."""
html, _ = get_text("/")
composer_left_start = html.find('class="composer-left"')
composer_left_end = html.find('</div>', composer_left_start)
section = html[composer_left_start:composer_left_end]
assert 'btnAttach' in section
assert 'btnMic' in section
# ── style.css ────────────────────────────────────────────────────────────
def test_mic_btn_css_rule_exists():
"""style.css must define .mic-btn rule."""
css, status = get_text("/static/style.css")
assert status == 200
assert '.mic-btn' in css
def test_mic_btn_recording_state_css():
""".mic-btn.recording must be defined for active recording visual state."""
css, _ = get_text("/static/style.css")
assert '.mic-btn.recording' in css
def test_mic_recording_color_red():
""".mic-btn.recording must use the red accent color #e94560."""
css, _ = get_text("/static/style.css")
recording_idx = css.find('.mic-btn.recording')
# Find the rule block after the selector
brace_open = css.find('{', recording_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
assert '#e94560' in rule or 'e94560' in rule
def test_mic_recording_has_animation():
""".mic-btn.recording must use an animation for the pulse effect."""
css, _ = get_text("/static/style.css")
recording_idx = css.find('.mic-btn.recording')
brace_open = css.find('{', recording_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
assert 'animation' in rule
def test_mic_pulse_keyframes_defined():
"""@keyframes mic-pulse must be defined for the pulsing animation."""
css, _ = get_text("/static/style.css")
assert 'mic-pulse' in css
assert '@keyframes' in css
def test_mic_status_css_rule_exists():
"""style.css must define .mic-status rule."""
css, _ = get_text("/static/style.css")
assert '.mic-status' in css
def test_mic_dot_css_rule_exists():
"""style.css must define .mic-dot rule with animation."""
css, _ = get_text("/static/style.css")
assert '.mic-dot' in css
dot_idx = css.find('.mic-dot')
brace_open = css.find('{', dot_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
assert 'animation' in rule
def test_mic_btn_has_transition():
""".mic-btn must define a transition for smooth state changes."""
css, _ = get_text("/static/style.css")
mic_btn_idx = css.find('.mic-btn{')
if mic_btn_idx == -1:
mic_btn_idx = css.find('.mic-btn ')
brace_open = css.find('{', mic_btn_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
assert 'transition' in rule
# ── boot.js ──────────────────────────────────────────────────────────────
def test_boot_js_serves_ok():
"""boot.js must be served successfully."""
_, status = get_text("/static/boot.js")
assert status == 200
def test_boot_js_speech_recognition_check():
"""boot.js must check for SpeechRecognition (with webkit fallback)."""
js, _ = get_text("/static/boot.js")
assert 'SpeechRecognition' in js
assert 'webkitSpeechRecognition' in js
def test_boot_js_recognition_config():
"""boot.js must configure recognition.continuous, interimResults, and lang."""
js, _ = get_text("/static/boot.js")
assert 'recognition.continuous' in js
assert 'recognition.interimResults' in js
assert 'recognition.lang' in js
def test_boot_js_recognition_not_continuous():
"""recognition.continuous must be false (auto-stop after silence)."""
js, _ = get_text("/static/boot.js")
assert 'recognition.continuous=false' in js or 'recognition.continuous = false' in js
def test_boot_js_recognition_interim_results():
"""recognition.interimResults must be true (live transcription preview)."""
js, _ = get_text("/static/boot.js")
assert 'recognition.interimResults=true' in js or 'recognition.interimResults = true' in js
def test_boot_js_recognition_lang_en():
"""recognition.lang must be set to en-US."""
js, _ = get_text("/static/boot.js")
assert "recognition.lang='en-US'" in js or 'recognition.lang = "en-US"' in js or "recognition.lang='en-US'" in js
def test_boot_js_onresult_handler():
"""boot.js must define recognition.onresult to handle transcription."""
js, _ = get_text("/static/boot.js")
assert 'recognition.onresult' in js
def test_boot_js_onend_handler():
"""boot.js must define recognition.onend to reset state when recording stops."""
js, _ = get_text("/static/boot.js")
assert 'recognition.onend' in js
def test_boot_js_onerror_handler():
"""boot.js must define recognition.onerror for graceful error handling."""
js, _ = get_text("/static/boot.js")
assert 'recognition.onerror' in js
def test_boot_js_not_allowed_error_message():
"""onerror must handle 'not-allowed' with a user-friendly message."""
js, _ = get_text("/static/boot.js")
assert 'not-allowed' in js
assert 'permission' in js.lower() or 'denied' in js.lower() or 'access' in js.lower()
def test_boot_js_no_speech_error_message():
"""onerror must handle 'no-speech' with a user-friendly message."""
js, _ = get_text("/static/boot.js")
assert 'no-speech' in js
def test_boot_js_network_error_message():
"""onerror must handle 'network' error."""
js, _ = get_text("/static/boot.js")
assert "'network'" in js or '"network"' in js
def test_boot_js_mic_active_flag():
"""boot.js must track recording state via _micActive flag."""
js, _ = get_text("/static/boot.js")
assert '_micActive' in js
def test_boot_js_mic_recording_class_toggle():
"""boot.js must toggle 'recording' CSS class on the mic button."""
js, _ = get_text("/static/boot.js")
assert "'recording'" in js or '"recording"' in js
def test_boot_js_mic_status_toggle():
"""boot.js must show/hide #micStatus during recording."""
js, _ = get_text("/static/boot.js")
assert 'micStatus' in js
def test_boot_js_send_stops_mic():
"""btnSend onclick must stop mic before sending (send guard)."""
js, _ = get_text("/static/boot.js")
# The send button onclick should check _micActive and stop recording
send_onclick_idx = js.find("$('btnSend').onclick")
assert send_onclick_idx != -1
# Find the handler code — check that _micActive check appears near send assignment
handler_end = js.find(';', send_onclick_idx)
handler = js[send_onclick_idx:handler_end + 1]
assert '_micActive' in handler or 'stopMic' in handler.lower()
def test_boot_js_btn_mic_onclick():
"""boot.js must attach an onclick handler to btnMic."""
js, _ = get_text("/static/boot.js")
assert 'btn.onclick' in js or "btnMic.onclick" in js or "$('btnMic').onclick" in js
def test_boot_js_recognition_start():
"""boot.js must call recognition.start() to begin recording."""
js, _ = get_text("/static/boot.js")
assert 'recognition.start()' in js
def test_boot_js_recognition_stop():
"""boot.js must call recognition.stop() to end recording."""
js, _ = get_text("/static/boot.js")
assert 'recognition.stop()' in js
def test_boot_js_iife_guard():
"""Mic logic must be wrapped in an IIFE so it doesn't pollute global scope."""
js, _ = get_text("/static/boot.js")
# IIFE pattern: (function(){...})() or (() => {...})()
assert '(function(){' in js or '(function () {' in js
def test_boot_js_browser_unsupported_return():
"""boot.js must bail out (return) early when SpeechRecognition is unavailable."""
js, _ = get_text("/static/boot.js")
# The IIFE should have an early return when SpeechRecognition is falsy
assert 'if(!SpeechRecognition)' in js or 'if (!SpeechRecognition)' in js
def test_boot_js_shows_mic_button_when_supported():
"""boot.js must set display='' on btnMic when SpeechRecognition is available."""
js, _ = get_text("/static/boot.js")
assert "btn.style.display=''" in js or 'btn.style.display = ""' in js
def test_boot_js_show_toast_on_error():
"""boot.js must call showToast() for mic errors."""
js, _ = get_text("/static/boot.js")
assert 'showToast' in js
def test_boot_js_autoresize_called():
"""boot.js must call autoResize() after updating textarea from transcript."""
js, _ = get_text("/static/boot.js")
assert 'autoResize()' in js
# ── Regression: existing behaviour unchanged ──────────────────────────────
def test_attach_button_still_wired():
"""btnAttach onclick must still be wired up (no regression)."""
js, _ = get_text("/static/boot.js")
assert "$('btnAttach').onclick" in js
def test_file_input_onchange_still_wired():
"""fileInput onchange must still be wired up (no regression)."""
js, _ = get_text("/static/boot.js")
assert "$('fileInput').onchange" in js
def test_index_html_still_has_send_button():
"""btnSend must still be present in index.html (no regression)."""
html, _ = get_text("/")
assert 'id="btnSend"' in html
def test_index_html_still_has_attach_button():
"""btnAttach must still be present in index.html (no regression)."""
html, _ = get_text("/")
assert 'id="btnAttach"' in html