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

View File

@@ -8,8 +8,81 @@ async function cancelStream(){
}catch(e){setStatus('Cancel failed: '+e.message);} }catch(e){setStatus('Cancel failed: '+e.message);}
} }
$('btnSend').onclick=send; $('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();};
$('btnAttach').onclick=()=>$('fileInput').click(); $('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='';}; $('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();}; $('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();};
$('btnDownload').onclick=()=>{ $('btnDownload').onclick=()=>{

View File

@@ -213,6 +213,7 @@
Drop files to upload to workspace Drop files to upload to workspace
</div> </div>
<div class="attach-tray" id="attachTray"></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> <textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
<div class="composer-footer"> <div class="composer-footer">
<div class="composer-left"> <div class="composer-left">
@@ -220,6 +221,14 @@
<button class="icon-btn" id="btnAttach" title="Attach files"> <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> <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>
<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>
<div class="composer-right"> <div class="composer-right">
<button class="send-btn" id="btnSend"> <button class="send-btn" id="btnSend">

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{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{opacity:.75;}
.icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);opacity:1;} .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;} .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{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);} .send-btn:hover{background:linear-gradient(135deg,#7cb9ff,#a0d0ff);transform:translateY(-1px);}

362
tests/test_sprint20.py Normal file
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