|
341 | 341 | font-family: 'Fira Code', monospace; |
342 | 342 | } |
343 | 343 |
|
| 344 | + /* ── Audio output selector ── */ |
| 345 | + #audio-output-selector { |
| 346 | + display: flex; |
| 347 | + align-items: center; |
| 348 | + gap: 0.4rem; |
| 349 | + margin-top: 0.5rem; |
| 350 | + font-size: 0.7rem; |
| 351 | + color: var(--text-dim); |
| 352 | + font-family: 'Fira Code', monospace; |
| 353 | + } |
| 354 | + #audio-output-selector select { |
| 355 | + background: var(--panel); |
| 356 | + color: var(--text); |
| 357 | + border: 1px solid var(--border); |
| 358 | + border-radius: 4px; |
| 359 | + padding: 0.15rem 0.3rem; |
| 360 | + font-size: 0.7rem; |
| 361 | + font-family: 'Fira Code', monospace; |
| 362 | + max-width: 140px; |
| 363 | + cursor: pointer; |
| 364 | + } |
| 365 | + |
344 | 366 | /* ── Orb tap ripple ── */ |
345 | 367 | .tap-ripple { |
346 | 368 | position: absolute; |
|
413 | 435 | <button class="voice-dot" data-voice="3" title="Atlas — deep and confident" style="--dot-color:#9b59b6"></button> |
414 | 436 | <button class="voice-dot" data-voice="4" title="Iris — bright and cheerful" style="--dot-color:#e74c3c"></button> |
415 | 437 | </div> |
| 438 | + <div id="audio-output-selector"> |
| 439 | + <span id="audio-device-label">Audio: </span> |
| 440 | + <select id="audio-device-select"></select> |
| 441 | + </div> |
416 | 442 | <div id="interrupt-hint">Speak to interrupt</div> |
417 | 443 |
|
418 | 444 | <div id="live-transcript"></div> |
|
457 | 483 | let currentSpeakResolve = null; |
458 | 484 | let speakChain = Promise.resolve(); |
459 | 485 | let streamAbortController = null; |
| 486 | + let audioOutputDevices = []; |
460 | 487 | let audioLevel = 0; |
461 | 488 | let targetAudioLevel = 0; |
462 | 489 |
|
|
993 | 1020 | // Auto-request mic and enumerate audio devices (triggers permission prompts) |
994 | 1021 | if (navigator.mediaDevices) { |
995 | 1022 | navigator.mediaDevices.getUserMedia({ audio: true }).catch(() => {}); |
996 | | - navigator.mediaDevices.enumerateDevices().then(devices => { |
997 | | - const audioOuts = devices.filter(d => d.kind === 'audiooutput' && d.deviceId); |
998 | | - if (liveAudioEl && audioOuts.length > 0 && liveAudioEl.setSinkId) { |
999 | | - liveAudioEl.setSinkId(audioOuts[0].deviceId).catch(() => {}); |
1000 | | - } |
1001 | | - }).catch(() => {}); |
| 1023 | + const setupAudioOutput = () => { |
| 1024 | + navigator.mediaDevices.enumerateDevices().then(devices => { |
| 1025 | + audioOutputDevices = devices.filter(d => d.kind === 'audiooutput'); |
| 1026 | + const sel = document.getElementById('audio-device-select'); |
| 1027 | + if (sel) { |
| 1028 | + const currentId = liveAudioEl?.sinkId || ''; |
| 1029 | + sel.innerHTML = audioOutputDevices.map(d => |
| 1030 | + `<option value="${d.deviceId}" ${d.deviceId === currentId ? 'selected' : ''}>${d.label || d.deviceId.slice(0, 8)}</option>` |
| 1031 | + ).join(''); |
| 1032 | + } |
| 1033 | + // Try to select a non-default audio output (headphones) |
| 1034 | + if (liveAudioEl && liveAudioEl.setSinkId) { |
| 1035 | + const preferred = audioOutputDevices.find(d => d.deviceId !== 'default' && d.deviceId); |
| 1036 | + if (preferred && preferred.deviceId !== liveAudioEl.sinkId) { |
| 1037 | + liveAudioEl.setSinkId(preferred.deviceId).catch(() => {}); |
| 1038 | + } |
| 1039 | + } |
| 1040 | + }).catch(() => {}); |
| 1041 | + }; |
| 1042 | + // Request speaker-selection permission if API exists |
| 1043 | + if (navigator.permissions && navigator.permissions.request) { |
| 1044 | + navigator.permissions.request({ name: 'speaker-selection' }).then(() => { |
| 1045 | + setupAudioOutput(); |
| 1046 | + }).catch(() => setupAudioOutput()); |
| 1047 | + } else { |
| 1048 | + setupAudioOutput(); |
| 1049 | + } |
1002 | 1050 | } |
1003 | 1051 | playTestTone(); |
1004 | 1052 | setVoice(currentVoice); |
|
1210 | 1258 | document.querySelectorAll('.voice-dot').forEach(dot => { |
1211 | 1259 | dot.addEventListener('click', () => setVoice(parseInt(dot.dataset.voice))); |
1212 | 1260 | }); |
| 1261 | + document.getElementById('audio-device-select').addEventListener('change', e => { |
| 1262 | + if (liveAudioEl && liveAudioEl.setSinkId) { |
| 1263 | + liveAudioEl.setSinkId(e.target.value).catch(() => {}); |
| 1264 | + } |
| 1265 | + }); |
1213 | 1266 |
|
1214 | 1267 | // Orb tap to start/stop listening |
1215 | 1268 | canvas.addEventListener('click', e => { |
|
0 commit comments