jerry-chat / index.html
zaktree's picture
Update index.html
b6a8307 verified
Raw
History Blame Contribute Delete
25 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat with Me</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d0f; --surface: #16161a; --border: rgba(255,255,255,0.08);
--accent: #c8a96e; --text: #e8e4dc; --muted: rgba(232,228,220,0.45);
--user-bg: #1e2533; --bot-bg: #1a1a1e; --radius: 18px;
--font-head: 'DM Serif Display', Georgia, serif;
--font-body: 'DM Sans', system-ui, sans-serif;
}
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--font-body); font-size: 16px; line-height: 1.6; }
.shell { display: flex; flex-direction: column; height: 100vh; max-width: 900px; margin: 0 auto; }
.topbar { display: flex; align-items: center; gap: 8px; padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border); flex-shrink: 0; white-space: nowrap; overflow: hidden; }
.topbar-spacer { flex: 1; min-width: 4px; }
.bot-name { font-family: var(--font-head); font-size: 0.95rem; color: var(--text); flex-shrink: 0; }
.avatar-mini { width: 52px; height: 62px; border-radius: 8px; overflow: hidden; object-fit: cover; object-position: center; border: 1.5px solid rgba(200,169,110,0.4); background: var(--surface); flex-shrink: 0; }
.bot-tagline { font-family: var(--font-head); font-size: 0.95rem; color: var(--text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.speaking-badge { display: none; align-items: center; gap: 6px; font-size: 11px; color: var(--accent); letter-spacing: 0.08em; text-transform: uppercase; font-weight: 500; flex-shrink: 0; }
.speaking-badge.active { display: flex; }
.speaking-dots { display: flex; gap: 3px; }
.speaking-dots span { width: 4px; height: 4px; border-radius: 50%; background: var(--accent); animation: bounce-dot 1s ease-in-out infinite; }
.speaking-dots span:nth-child(2) { animation-delay: 0.15s; }
.speaking-dots span:nth-child(3) { animation-delay: 0.30s; }
@keyframes bounce-dot { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
.voice-toggle { display: flex; align-items: center; justify-content: center; font-size: 16px; cursor: pointer; user-select: none; width: 30px; height: 30px; border: 1px solid var(--border); border-radius: 50%; transition: border-color 0.2s; flex-shrink: 0; }
.voice-toggle:hover { border-color: var(--accent); }
.voice-toggle input { display: none; }
.voice-toggle.on { border-color: var(--accent); }
.chat-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.messages { flex: 1; overflow-y: auto; padding: 1rem 1.25rem; display: flex; flex-direction: column; gap: 1rem; scroll-behavior: smooth; }
.messages::-webkit-scrollbar { width: 4px; }
.messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.msg { display: flex; flex-direction: column; max-width: 78%; gap: 4px; animation: msg-in 0.25s ease-out both; }
@keyframes msg-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.msg.user { align-self: flex-end; align-items: flex-end; }
.msg.bot { align-self: flex-start; align-items: flex-start; }
.bubble { padding: 0.75rem 1.1rem; border-radius: var(--radius); font-size: 15px; line-height: 1.65; }
.msg.user .bubble { background: var(--user-bg); border-bottom-right-radius: 4px; border: 1px solid rgba(126,159,199,0.2); }
.msg.bot .bubble { background: var(--bot-bg); border-bottom-left-radius: 4px; border: 1px solid var(--border); }
.msg-time { font-size: 11px; color: var(--muted); padding: 0 4px; }
.replay-btn { font-size: 11px; color: var(--accent); background: none; border: 1px solid rgba(200, 169, 110, 0.3); border-radius: 99px; padding: 3px 10px; cursor: pointer; margin-top: 6px; width: fit-content; transition: background 0.15s, border-color 0.15s; display: none; align-items: center; gap: 4px; }
.replay-btn.visible { display: flex; }
.replay-btn:hover { background: rgba(200, 169, 110, 0.1); border-color: var(--accent); }
.typing-bubble { background: var(--bot-bg); border: 1px solid var(--border); border-radius: var(--radius); border-bottom-left-radius: 4px; padding: 0.75rem 1.1rem; display: none; align-items: center; gap: 5px; width: fit-content; }
.typing-bubble.visible { display: flex; }
.typing-bubble span { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); animation: bounce-dot 1s ease-in-out infinite; }
.typing-bubble span:nth-child(2) { animation-delay: 0.15s; }
.typing-bubble span:nth-child(3) { animation-delay: 0.30s; }
.gen-voice { font-size: 12px; color: var(--muted); padding: 4px 8px; display: none; }
.gen-voice.visible { display: block; }
.input-bar { padding: 1.25rem 1.75rem; border-top: 1px solid var(--border); display: flex; gap: 10px; align-items: flex-end; flex-shrink: 0; background: var(--bg); }
.input-wrap { flex: 1; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 0.65rem 1rem; display: flex; align-items: flex-end; gap: 8px; transition: border-color 0.2s; }
.input-wrap:focus-within { border-color: rgba(200,169,110,0.4); }
#user-input { flex: 1; background: none; border: none; outline: none; color: var(--text); font-family: var(--font-body); font-size: 15px; resize: none; max-height: 120px; min-height: 24px; line-height: 1.5; }
#user-input::placeholder { color: var(--muted); }
.send-btn, .mic-btn { width: 38px; height: 38px; border-radius: 10px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: opacity 0.15s, transform 0.1s, background 0.15s; font-size: 18px; }
.send-btn { background: var(--accent); color: #0d0d0f; }
.send-btn:hover { opacity: 0.85; }
.send-btn:active { transform: scale(0.95); }
.send-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.mic-btn { background: var(--surface); color: var(--text); border: 1px solid var(--border); user-select: none; -webkit-user-select: none; touch-action: none; }
.mic-btn:hover { border-color: var(--accent); }
.mic-btn.recording { background: #7a2d2d; border-color: #c8544e; animation: mic-pulse 1s ease-in-out infinite; }
@keyframes mic-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
.mic-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.mic-status { font-size: 12px; color: var(--muted); padding: 4px 8px; display: none; }
.mic-status.visible { display: block; }
.mic-status.fail { color: #e07a75; }
@media (max-width: 380px) {
.bot-tagline { display: none; }
}
</style>
</head>
<body>
<div class="shell">
<div class="topbar">
<span class="bot-name">Papa Jerry</span>
<img class="avatar-mini" src="me.jpg" alt="Papa Jerry" onerror="this.style.visibility='hidden'">
<span class="bot-tagline">I'm ready to chat.</span>
<div class="topbar-spacer"></div>
<div class="speaking-badge" id="speaking-badge">
<div class="speaking-dots"><span></span><span></span><span></span></div>
Speaking
</div>
<label class="voice-toggle on" id="voice-toggle" title="Voice reply generation takes 15-45 seconds">
<input type="checkbox" id="voice-on" checked>
<span id="voice-icon">🔊</span>
</label>
</div>
<main class="chat-panel">
<div class="messages" id="messages">
<div class="typing-bubble" id="typing">
<span></span><span></span><span></span>
</div>
<div class="gen-voice" id="gen-voice">🎙️ Generating voice...</div>
<div class="mic-status" id="mic-status"></div>
</div>
<div class="input-bar">
<button class="mic-btn" id="mic-btn" title="Hold to talk">🎤</button>
<div class="input-wrap">
<textarea id="user-input" rows="1" placeholder="Type a message…"></textarea>
</div>
<button class="send-btn" id="send-btn" title="Send">&#10148;</button>
</div>
</main>
</div>
<audio id="tts-audio" style="display:none"></audio>
<script>
// Silent debug logger - view via browser Console (F12), no on-page UI.
var debugStartTime = null;
function dlog(msg) {
if (debugStartTime === null) debugStartTime = performance.now();
var elapsed = ((performance.now() - debugStartTime) / 1000).toFixed(2);
console.log('[+' + elapsed + 's] ' + msg);
}
window.addEventListener('error', function(e) {
dlog('❌ JS ERROR: ' + e.message);
});
window.addEventListener('unhandledrejection', function(e) {
dlog('❌ UNHANDLED PROMISE REJECTION: ' + (e.reason && e.reason.message ? e.reason.message : e.reason));
});
</script>
<script>
// ============================================================
// CONFIGURE THESE
// ============================================================
var HF_SPACE_URL = 'https://zaktree-papa-jerry.hf.space';
var BASE_SYSTEM_PROMPT = 'You are Papa Jerry, a warm and loving grandfather chatting with family. Speak with warmth, and gentle humor. Because your words are converted to spoken audio and family members are waiting to hear you, always keep replies short — 2 to 3 sentences — even when being thoughtful or wise.';
var SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;
var POLL_INTERVAL = 4000;
// ============================================================
var chatHistory = [];
var busy = false;
var currentBotMsgId = null;
var msgsEl = document.getElementById('messages');
var inputEl = document.getElementById('user-input');
var sendBtn = document.getElementById('send-btn');
var typing = document.getElementById('typing');
var genVoiceEl = document.getElementById('gen-voice');
var voiceOn = document.getElementById('voice-on');
var voiceToggle = document.getElementById('voice-toggle');
var speakBadge = document.getElementById('speaking-badge');
var ttsAudio = document.getElementById('tts-audio');
var micBtn = document.getElementById('mic-btn');
var micStatus = document.getElementById('mic-status');
// Dynamic loader built to ingest 'family' and 'other' data fields safely
function loadFactsFromServer() {
fetch('facts.json')
.then(function(r) { return r.json(); })
.then(function(facts) {
var factsText = "\n\nHere are some facts you know about specific people:\n";
for (var person in facts) {
var location = facts[person].location || "unknown";
var job = facts[person].job || "unknown";
var hobbies = facts[person].hobbies || "unknown";
// Dynamic additions: Append context chunks string conditionally
var familyDetails = facts[person].family ? " Family connections: " + facts[person].family + "." : "";
var otherDetails = facts[person].other ? " Additional information: " + facts[person].other + "." : "";
factsText += "- " + person + " lives in " + location +
", works as a " + job +
", and their hobbies include " + hobbies + "." +
familyDetails + otherDetails + "\n";
}
SYSTEM_PROMPT = BASE_SYSTEM_PROMPT + factsText;
console.log("AI context initialized with multi-field profile definitions.");
})
.catch(function(err) {
console.log("No facts loaded or file empty, applying base prompt fallback.", err);
});
}
loadFactsFromServer();
voiceOn.addEventListener('change', function() {
voiceToggle.classList.toggle('on', voiceOn.checked);
document.getElementById('voice-icon').textContent = voiceOn.checked ? '🔊' : '🔇';
});
inputEl.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
inputEl.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
sendBtn.addEventListener('click', send);
function unlockAudio() {
dlog('unlockAudio() called (click gesture)');
try {
ttsAudio.muted = true;
var p = ttsAudio.play();
if (p && p.catch) p.catch(function(e) { dlog('unlock play() rejected: ' + e.message); });
ttsAudio.pause();
ttsAudio.currentTime = 0;
dlog('unlockAudio() completed OK');
} catch (e) { dlog('unlockAudio() threw: ' + e.message); }
}
function send() {
var text = inputEl.value.trim();
if (!text || busy) return;
debugStartTime = performance.now();
dlog('--- NEW MESSAGE: "' + text + '" ---');
if (voiceOn.checked) unlockAudio();
addMessage('user', text);
chatHistory.push({ role: 'user', content: text });
inputEl.value = '';
inputEl.style.height = 'auto';
setTyping(true);
busy = true;
sendBtn.disabled = true;
currentBotMsgId = 'msg-' + Date.now();
fetch('/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
messages: [{ role: 'system', content: SYSTEM_PROMPT }].concat(chatHistory)
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
setTyping(false);
if (!data.choices || !data.choices[0]) {
addMessage('bot', 'Sorry, I could not get a response. Please try again.');
done(); return;
}
var reply = data.choices[0].message.content;
chatHistory.push({ role: 'assistant', content: reply });
addMessage('bot', reply, currentBotMsgId);
if (voiceOn.checked) {
speak(reply);
} else {
done();
}
})
.catch(function(err) {
setTyping(false);
addMessage('bot', 'Connection error: ' + err.message);
done();
});
}
var voiceStartTime = 0;
var UI_CounterInterval = null;
function startVoiceTimer() {
voiceStartTime = performance.now();
genVoiceEl.textContent = '🎙️ Generating voice... 0s';
genVoiceEl.classList.add('visible');
UI_CounterInterval = setInterval(function() {
var elapsed = Math.round((performance.now() - voiceStartTime) / 1000);
genVoiceEl.textContent = '🎙️ Generating voice... ' + elapsed + 's';
}, 1000);
}
function stopVoiceTimer() {
if (UI_CounterInterval) { clearInterval(UI_CounterInterval); UI_CounterInterval = null; }
genVoiceEl.classList.remove('visible');
}
// Holds each clip's blob URL after its one-and-only fetch, so "Replay
// Voice" reuses it directly instead of ever hitting the network again.
var audioBlobCache = {};
function speak(text) {
startVoiceTimer();
var url = HF_SPACE_URL + '/speak?text=' + encodeURIComponent(text);
dlog('speak(): sending GET /speak');
fetch(url)
.then(function(r) {
dlog('speak response received, HTTP status ' + r.status + ' (server generation: ' + (r.headers.get('X-Generation-Seconds') || '?') + 's)');
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.blob();
})
.then(function(blob) {
dlog('blob received, size ' + blob.size + ' bytes, type ' + blob.type);
var blobUrl = URL.createObjectURL(blob);
// Cached immediately - this is the ONLY fetch of this clip that
// will ever happen. No second request means no second (very
// expensive) generation on the server.
audioBlobCache[currentBotMsgId] = blobUrl;
setupReplayButton(currentBotMsgId, blobUrl);
playAudio(blobUrl);
})
.catch(function(e) { dlog('❌ /speak failed: ' + e.message); stopVoiceTimer(); done(); });
}
function playAudio(src) {
dlog('playAudio() called, src=' + src.slice(0, 80));
if (busy && !ttsAudio.paused) {
ttsAudio.pause();
}
speakBadge.classList.add('active');
ttsAudio.muted = false;
ttsAudio.src = src;
ttsAudio.oncanplay = function() { dlog('audio event: canplay'); };
ttsAudio.onwaiting = function() { dlog('audio event: waiting (buffering)'); };
ttsAudio.onstalled = function() { dlog('audio event: stalled'); };
ttsAudio.onplaying = function() {
dlog('🔊 audio event: PLAYING (actually audible now)');
if (UI_CounterInterval) {
var accurateSeconds = ((performance.now() - voiceStartTime) / 1000).toFixed(1);
stopVoiceTimer();
injectVoiceDuration(currentBotMsgId, accurateSeconds);
}
var targetMsg = document.getElementById(currentBotMsgId);
var replayBtn = targetMsg && targetMsg.querySelector('.replay-btn');
if (replayBtn) replayBtn.classList.add('visible');
};
var playPromise = ttsAudio.play();
if (playPromise && playPromise.then) {
playPromise.then(function() {
dlog('play() promise RESOLVED (browser allowed playback)');
}).catch(function(e) {
dlog('❌ play() promise REJECTED: ' + e.name + ' - ' + e.message);
// Browser blocked autoplay (e.g. Safari, when this was triggered by
// the mic flow rather than a direct tap). onended/onerror will never
// fire since playback never started, so we must unstick busy here or
// every future send/mic press silently no-ops forever.
stopVoiceTimer();
speakBadge.classList.remove('active');
showMicStatus('Voice ready - tap "Replay Voice" to hear it.', false);
done();
});
}
ttsAudio.onended = function() {
dlog('audio event: ended');
speakBadge.classList.remove('active');
done();
};
ttsAudio.onerror = function() { dlog('❌ audio event: error - ' + (ttsAudio.error ? ttsAudio.error.message : 'unknown')); speakBadge.classList.remove('active'); done(); };
}
function done() {
busy = false;
sendBtn.disabled = false;
inputEl.focus();
}
function addMessage(role, text, elementId) {
var msg = document.createElement('div');
msg.className = 'msg ' + role;
if (elementId) msg.id = elementId;
var bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.textContent = text;
var time = document.createElement('span');
time.className = 'msg-time';
time.textContent = _time();
msg.appendChild(bubble);
msg.appendChild(time);
if (role === 'bot') {
var replayBtn = document.createElement('button');
replayBtn.className = 'replay-btn';
replayBtn.innerHTML = '▶️ Replay Voice';
msg.appendChild(replayBtn);
}
msgsEl.insertBefore(msg, typing);
msgsEl.scrollTop = msgsEl.scrollHeight;
}
function injectVoiceDuration(elementId, seconds) {
var targetMsg = document.getElementById(elementId);
if (!targetMsg) return;
var durationEl = document.createElement('div');
durationEl.style.fontSize = '11px';
durationEl.style.color = 'var(--muted)';
durationEl.style.marginTop = '4px';
durationEl.style.padding = '0 4px';
durationEl.textContent = '⏱️ Generated in ' + seconds + 's';
var replayBtn = targetMsg.querySelector('.replay-btn');
if (replayBtn) {
targetMsg.insertBefore(durationEl, replayBtn);
} else {
targetMsg.appendChild(durationEl);
}
msgsEl.scrollTop = msgsEl.scrollHeight;
}
function setupReplayButton(elementId, audioSrc) {
var targetMsg = document.getElementById(elementId);
if (!targetMsg) return;
var btn = targetMsg.querySelector('.replay-btn');
if (!btn) return;
btn.onclick = function() {
speakBadge.classList.add('active');
if (ttsAudio.src === audioSrc) {
// Same clip that's already loaded (e.g. right after autoplay was
// blocked) - just rewind and play. Re-assigning src to the same
// blob makes Safari/iPad re-buffer from scratch, which is the
// pause you'd otherwise see before it starts.
ttsAudio.currentTime = 0;
} else {
ttsAudio.src = audioSrc;
}
ttsAudio.play();
ttsAudio.onended = function() { speakBadge.classList.remove('active'); };
ttsAudio.onerror = function() { speakBadge.classList.remove('active'); };
};
// Not made visible here - the "playing" handler in playAudio() reveals
// it once the clip actually starts sounding, so it doesn't show up
// before there's anything to replay yet.
}
function setTyping(show) {
typing.classList.toggle('visible', show);
msgsEl.scrollTop = msgsEl.scrollHeight;
}
// Global UI clock time builder string
function _time() {
var d = new Date(), h = d.getHours(), m = d.getMinutes();
var ampm = h >= 12 ? 'pm' : 'am';
h = h % 12 || 12;
return h + ':' + (m < 10 ? '0' : '') + m + ' ' + ampm;
}
// ============================================================
// VOICE INPUT (hold-to-talk -> Groq Whisper -> auto-send)
// ============================================================
var micStream = null;
var micRecorder = null;
var micChunks = [];
var micRecording = false;
var micMimeType = 'audio/webm'; // actual format last recorded in - Safari uses mp4, not webm
// Ask the browser what it can actually record in, rather than assuming
// webm (which Safari doesn't support - it records mp4/aac instead).
function pickMicMimeType() {
var candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/aac'];
for (var i = 0; i < candidates.length; i++) {
if (window.MediaRecorder && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(candidates[i])) {
return candidates[i];
}
}
return ''; // let the browser pick its own default
}
function showMicStatus(msg, isFail) {
micStatus.textContent = msg;
micStatus.classList.toggle('fail', !!isFail);
micStatus.classList.add('visible');
msgsEl.scrollTop = msgsEl.scrollHeight;
}
function hideMicStatus() {
micStatus.classList.remove('visible');
}
function startMicRecording() {
if (busy || micRecording) return;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
showMicStatus('Mic not available in this browser context.', true);
return;
}
// Prime the audio element right now, synchronously, while this is still
// a direct user tap. If we wait until the reply comes back later, the
// gesture has expired and Safari silently blocks the unlock too - which
// is why only the *first* mic message needed a manual "Replay Voice" tap.
if (voiceOn.checked) unlockAudio();
navigator.mediaDevices.getUserMedia({ audio: true })
.then(function(stream) {
micStream = stream;
micChunks = [];
var chosenType = pickMicMimeType();
micRecorder = chosenType ? new MediaRecorder(stream, { mimeType: chosenType }) : new MediaRecorder(stream);
micMimeType = micRecorder.mimeType || chosenType || 'audio/webm';
micRecorder.ondataavailable = function(e) { micChunks.push(e.data); };
micRecorder.onstop = handleMicStop;
micRecorder.start();
micRecording = true;
micBtn.classList.add('recording');
showMicStatus('Listening...');
})
.catch(function(err) {
showMicStatus('Mic error: ' + err.name + ' - ' + err.message, true);
});
}
function stopMicRecording() {
if (!micRecording) return;
micRecording = false;
micBtn.classList.remove('recording');
if (micRecorder && micRecorder.state !== 'inactive') {
micRecorder.stop();
}
if (micStream) {
micStream.getTracks().forEach(function(t) { t.stop(); });
}
}
function handleMicStop() {
var blob = new Blob(micChunks, { type: micMimeType });
if (blob.size < 500) {
// Too short to be real speech (e.g. an accidental tap) - skip the
// round trip entirely rather than sending near-silence to Whisper.
hideMicStatus();
return;
}
showMicStatus('Transcribing...');
micBtn.disabled = true;
var ext = micMimeType.indexOf('mp4') !== -1 ? 'mp4'
: micMimeType.indexOf('aac') !== -1 ? 'aac'
: 'webm';
var form = new FormData();
form.append('file', blob, 'speech.' + ext);
fetch('/transcribe', { method: 'POST', body: form })
.then(function(r) {
if (!r.ok) return r.text().then(function(t) { throw new Error('HTTP ' + r.status + ' - ' + t); });
return r.json();
})
.then(function(data) {
var text = (data.text || '').trim();
micBtn.disabled = false;
if (!text) {
showMicStatus('Did not catch that - try again.', true);
return;
}
hideMicStatus();
inputEl.value = text;
send();
})
.catch(function(err) {
micBtn.disabled = false;
showMicStatus('Transcription failed: ' + err.message, true);
});
}
// Mouse (desktop) and touch (mobile/VR) hold-to-talk.
micBtn.addEventListener('mousedown', function(e) { e.preventDefault(); startMicRecording(); });
micBtn.addEventListener('mouseup', stopMicRecording);
micBtn.addEventListener('mouseleave', function() { if (micRecording) stopMicRecording(); });
micBtn.addEventListener('touchstart', function(e) { e.preventDefault(); startMicRecording(); }, { passive: false });
micBtn.addEventListener('touchend', function(e) { e.preventDefault(); stopMicRecording(); }, { passive: false });
micBtn.addEventListener('touchcancel', function(e) { e.preventDefault(); stopMicRecording(); }, { passive: false });
</script>
</body>
</html>