2026-05-27 13:14:46 -07:00
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
|
|
|
|
|
<title>Voice → Kiro</title>
|
|
|
|
|
<style>
|
|
|
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
|
|
|
body{font-family:system-ui;background:#1a1a2e;color:#eee;display:flex;flex-direction:column;height:100dvh;padding:0}
|
2026-05-27 13:20:48 -07:00
|
|
|
#output{flex:1;width:100%;background:#0f0f1a;padding:.5rem;overflow-y:auto;font-family:monospace;font-size:.7rem;white-space:pre-wrap;color:#ccc;-webkit-user-select:text;user-select:text}
|
2026-05-27 13:14:46 -07:00
|
|
|
#bottom{width:100%;padding:.4rem;background:#16213e;display:flex;gap:.4rem;align-items:center}
|
|
|
|
|
#status{font-size:.75rem;color:#aaa;padding:0 .4rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40%}
|
|
|
|
|
#textinput{flex:1;padding:.5rem;border-radius:6px;border:1px solid #333;background:#0f0f1a;color:#eee;font-size:.9rem}
|
|
|
|
|
.fab{position:fixed;border-radius:50%;border:none;color:#fff;font-size:.7rem;cursor:pointer;touch-action:none;user-select:none;z-index:100;display:flex;align-items:center;justify-content:center;text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.5)}
|
|
|
|
|
#btn{width:70px;height:70px;background:#16213e;bottom:4rem;right:1rem;font-size:.8rem}
|
|
|
|
|
#btn.recording{background:#e94560;transform:scale(1.1)}
|
2026-05-27 13:20:48 -07:00
|
|
|
#cancelBtn{width:50px;height:50px;background:#e94560;top:1rem;right:1rem;font-size:.65rem}
|
2026-05-27 13:14:46 -07:00
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div id="output"></div>
|
|
|
|
|
<div id="bottom">
|
|
|
|
|
<span id="status"></span>
|
|
|
|
|
<form id="textform" autocomplete="off" style="flex:1;display:flex"><input id="textinput" type="text" placeholder="Message..." autocomplete="off"></form>
|
|
|
|
|
</div>
|
|
|
|
|
<button id="btn" class="fab">Hold<br>to Talk</button>
|
|
|
|
|
<button id="cancelBtn" class="fab">⌃C</button>
|
|
|
|
|
<script>
|
|
|
|
|
const btn=document.getElementById('btn'),status=document.getElementById('status'),output=document.getElementById('output');
|
|
|
|
|
let mediaRec,chunks=[];
|
|
|
|
|
|
|
|
|
|
function start(){
|
|
|
|
|
navigator.mediaDevices.getUserMedia({audio:true}).then(stream=>{
|
|
|
|
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/mp4';
|
|
|
|
|
mediaRec=new MediaRecorder(stream,{mimeType});
|
|
|
|
|
chunks=[];
|
|
|
|
|
mediaRec.ondataavailable=e=>chunks.push(e.data);
|
|
|
|
|
mediaRec.onstop=()=>{
|
|
|
|
|
stream.getTracks().forEach(t=>t.stop());
|
|
|
|
|
const ext = mediaRec.mimeType.includes('mp4') ? 'mp4' : 'webm';
|
|
|
|
|
send(new Blob(chunks,{type:mediaRec.mimeType}), ext);
|
|
|
|
|
};
|
|
|
|
|
mediaRec.start();
|
|
|
|
|
btn.classList.add('recording');
|
|
|
|
|
btn.textContent='●';
|
|
|
|
|
status.textContent='';
|
|
|
|
|
}).catch(e=>{status.textContent='Mic: '+e.message});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stop(){
|
|
|
|
|
if(mediaRec&&mediaRec.state==='recording'){
|
|
|
|
|
mediaRec.stop();
|
|
|
|
|
btn.classList.remove('recording');
|
|
|
|
|
btn.innerHTML='Hold<br>to Talk';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function send(blob, ext){
|
|
|
|
|
status.textContent='Transcribing...';
|
|
|
|
|
const fd=new FormData();
|
|
|
|
|
fd.append('audio',blob,'audio.'+ext);
|
|
|
|
|
fetch('/send',{method:'POST',body:fd})
|
|
|
|
|
.then(r=>r.json())
|
|
|
|
|
.then(d=>{
|
|
|
|
|
if(d.error){status.textContent='Err: '+d.error}
|
|
|
|
|
else{status.textContent='✓ '+d.text.slice(0,30); pollOutput()}
|
|
|
|
|
})
|
|
|
|
|
.catch(e=>{status.textContent='Fail: '+e.message});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pollOutput(){
|
2026-05-27 13:20:48 -07:00
|
|
|
// Don't update if user has text selected (iOS copy flow)
|
|
|
|
|
const sel = window.getSelection();
|
|
|
|
|
if(sel && sel.toString().length > 0) return;
|
2026-05-27 13:14:46 -07:00
|
|
|
fetch('/output').then(r=>r.json()).then(d=>{
|
|
|
|
|
const atBottom=output.scrollHeight-output.scrollTop-output.clientHeight<50;
|
|
|
|
|
output.textContent=d.output;
|
|
|
|
|
if(atBottom)output.scrollTop=output.scrollHeight;
|
|
|
|
|
}).catch(()=>{});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setInterval(pollOutput, 1000);
|
|
|
|
|
pollOutput();
|
|
|
|
|
|
|
|
|
|
document.getElementById('textform').addEventListener('submit',e=>{
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const inp=document.getElementById('textinput');
|
|
|
|
|
const t=inp.value.trim();
|
|
|
|
|
if(!t)return;
|
|
|
|
|
inp.value='';
|
|
|
|
|
status.textContent='Sending...';
|
|
|
|
|
fetch('/text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:t})})
|
|
|
|
|
.then(r=>r.json())
|
|
|
|
|
.then(d=>{
|
|
|
|
|
if(d.error){status.textContent='Err: '+d.error}
|
|
|
|
|
else{status.textContent='✓ '+d.text.slice(0,30); pollOutput()}
|
|
|
|
|
})
|
|
|
|
|
.catch(e=>{status.textContent='Fail: '+e.message});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById('cancelBtn').addEventListener('click',()=>{
|
|
|
|
|
fetch('/cancel',{method:'POST'}).then(()=>{status.textContent='⌃C sent'}).catch(()=>{});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-27 13:25:25 -07:00
|
|
|
btn.addEventListener('pointerdown',e=>{if(e.pointerId!==undefined)btn.setPointerCapture(e.pointerId);e.preventDefault();btn._startX=e.clientX;btn._startY=e.clientY;btn._dragged=false;btn._holdTimer=setTimeout(()=>{if(!btn._dragged)start();},200);});
|
|
|
|
|
btn.addEventListener('pointermove',e=>{
|
|
|
|
|
const dx=e.clientX-btn._startX,dy=e.clientY-btn._startY;
|
|
|
|
|
if(Math.abs(dx)+Math.abs(dy)>10){btn._dragged=true;clearTimeout(btn._holdTimer);}
|
|
|
|
|
});
|
|
|
|
|
btn.addEventListener('pointerup',()=>{clearTimeout(btn._holdTimer);if(!btn._dragged)stop();});
|
|
|
|
|
|
|
|
|
|
// Stop recording on pointerup anywhere if recording
|
|
|
|
|
document.addEventListener('pointerup',()=>{if(mediaRec&&mediaRec.state==='recording')stop();});
|
2026-05-27 13:14:46 -07:00
|
|
|
|
|
|
|
|
// Draggable FABs
|
|
|
|
|
document.querySelectorAll('.fab').forEach(fab=>{
|
|
|
|
|
let dragging=false,ox,oy,sx,sy;
|
|
|
|
|
fab.addEventListener('pointerdown',e=>{
|
|
|
|
|
ox=e.clientX;oy=e.clientY;
|
|
|
|
|
sx=fab.offsetLeft;sy=fab.offsetTop;
|
|
|
|
|
dragging=false;
|
|
|
|
|
});
|
|
|
|
|
fab.addEventListener('pointermove',e=>{
|
|
|
|
|
const dx=e.clientX-ox,dy=e.clientY-oy;
|
|
|
|
|
if(!dragging&&Math.abs(dx)+Math.abs(dy)>10)dragging=true;
|
|
|
|
|
if(dragging){
|
|
|
|
|
fab.style.left=(sx+dx)+'px';
|
|
|
|
|
fab.style.top=(sy+dy)+'px';
|
|
|
|
|
fab.style.right='auto';
|
|
|
|
|
fab.style.bottom='auto';
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
fab.addEventListener('pointerup',e=>{
|
|
|
|
|
if(dragging)e.stopImmediatePropagation();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|