fix: prime audio playback on mobile to unblock HAL voice reply
Browse files- static/index.html +44 -4
static/index.html
CHANGED
|
@@ -106,6 +106,29 @@
|
|
| 106 |
let recorder = null;
|
| 107 |
let chunks = [];
|
| 108 |
let busy = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
function setState(state) {
|
| 111 |
eye.classList.remove('listening', 'thinking', 'speaking');
|
|
@@ -175,11 +198,28 @@
|
|
| 175 |
updateLog(userText, halText);
|
| 176 |
|
| 177 |
const audioBlob = await res.blob();
|
| 178 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
audio.onended = () => { setState('idle'); busy = false; };
|
| 180 |
audio.onerror = () => { setState('idle'); busy = false; };
|
| 181 |
setState('speaking');
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
} catch (err) {
|
| 184 |
console.error('request error', err);
|
| 185 |
setState('idle');
|
|
@@ -187,10 +227,10 @@
|
|
| 187 |
}
|
| 188 |
}
|
| 189 |
|
| 190 |
-
eye.addEventListener('mousedown', e => { e.preventDefault(); startRecording(); });
|
| 191 |
eye.addEventListener('mouseup', e => { e.preventDefault(); stopAndSend(); });
|
| 192 |
eye.addEventListener('mouseleave', () => { if (recorder && recorder.state === 'recording') stopAndSend(); });
|
| 193 |
-
eye.addEventListener('touchstart', e => { e.preventDefault(); startRecording(); }, { passive: false });
|
| 194 |
eye.addEventListener('touchend', e => { e.preventDefault(); stopAndSend(); }, { passive: false });
|
| 195 |
eye.addEventListener('touchcancel', e => { e.preventDefault(); stopAndSend(); }, { passive: false });
|
| 196 |
eye.addEventListener('contextmenu', e => e.preventDefault());
|
|
|
|
| 106 |
let recorder = null;
|
| 107 |
let chunks = [];
|
| 108 |
let busy = false;
|
| 109 |
+
let audioCtx = null;
|
| 110 |
+
let primedAudio = null;
|
| 111 |
+
|
| 112 |
+
// iOS/Safari require audio playback to be initiated from a user gesture.
|
| 113 |
+
// Prime an <audio> element and an AudioContext on first touch so later
|
| 114 |
+
// programmatic play() calls are allowed.
|
| 115 |
+
function primePlayback() {
|
| 116 |
+
try {
|
| 117 |
+
if (!audioCtx) {
|
| 118 |
+
const Ctx = window.AudioContext || window.webkitAudioContext;
|
| 119 |
+
if (Ctx) audioCtx = new Ctx();
|
| 120 |
+
}
|
| 121 |
+
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
|
| 122 |
+
if (!primedAudio) {
|
| 123 |
+
primedAudio = new Audio();
|
| 124 |
+
primedAudio.playsInline = true;
|
| 125 |
+
primedAudio.setAttribute('playsinline', '');
|
| 126 |
+
primedAudio.muted = true;
|
| 127 |
+
primedAudio.src = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=';
|
| 128 |
+
primedAudio.play().catch(() => {});
|
| 129 |
+
}
|
| 130 |
+
} catch (e) {}
|
| 131 |
+
}
|
| 132 |
|
| 133 |
function setState(state) {
|
| 134 |
eye.classList.remove('listening', 'thinking', 'speaking');
|
|
|
|
| 198 |
updateLog(userText, halText);
|
| 199 |
|
| 200 |
const audioBlob = await res.blob();
|
| 201 |
+
const url = URL.createObjectURL(audioBlob);
|
| 202 |
+
const audio = primedAudio || new Audio();
|
| 203 |
+
audio.muted = false;
|
| 204 |
+
audio.playsInline = true;
|
| 205 |
+
audio.setAttribute('playsinline', '');
|
| 206 |
+
audio.src = url;
|
| 207 |
audio.onended = () => { setState('idle'); busy = false; };
|
| 208 |
audio.onerror = () => { setState('idle'); busy = false; };
|
| 209 |
setState('speaking');
|
| 210 |
+
try {
|
| 211 |
+
await audio.play();
|
| 212 |
+
} catch (err) {
|
| 213 |
+
console.error('playback blocked', err);
|
| 214 |
+
log.innerHTML += '<div class="hal">[tap eye to hear reply]</div>';
|
| 215 |
+
const resume = async () => {
|
| 216 |
+
eye.removeEventListener('touchend', resume);
|
| 217 |
+
eye.removeEventListener('click', resume);
|
| 218 |
+
try { await audio.play(); } catch (e) { setState('idle'); busy = false; }
|
| 219 |
+
};
|
| 220 |
+
eye.addEventListener('touchend', resume, { once: true });
|
| 221 |
+
eye.addEventListener('click', resume, { once: true });
|
| 222 |
+
}
|
| 223 |
} catch (err) {
|
| 224 |
console.error('request error', err);
|
| 225 |
setState('idle');
|
|
|
|
| 227 |
}
|
| 228 |
}
|
| 229 |
|
| 230 |
+
eye.addEventListener('mousedown', e => { e.preventDefault(); primePlayback(); startRecording(); });
|
| 231 |
eye.addEventListener('mouseup', e => { e.preventDefault(); stopAndSend(); });
|
| 232 |
eye.addEventListener('mouseleave', () => { if (recorder && recorder.state === 'recording') stopAndSend(); });
|
| 233 |
+
eye.addEventListener('touchstart', e => { e.preventDefault(); primePlayback(); startRecording(); }, { passive: false });
|
| 234 |
eye.addEventListener('touchend', e => { e.preventDefault(); stopAndSend(); }, { passive: false });
|
| 235 |
eye.addEventListener('touchcancel', e => { e.preventDefault(); stopAndSend(); }, { passive: false });
|
| 236 |
eye.addEventListener('contextmenu', e => e.preventDefault());
|