diff --git a/public/app.js b/public/app.js index 7e8bdbc..9cc0a1e 100644 --- a/public/app.js +++ b/public/app.js @@ -9,6 +9,7 @@ const SPATIAL_SCALE = 0.12; // room units -> audio world units const FALLBACK_ICE = { iceServers: [{ urls: ['stun:turn.k6n.net:3478', 'stun:stun.l.google.com:19302'] }] }; const LS_NAME = 'voicepoc.nick'; const LS_ICE = 'voicepoc.iceOverride'; +const LS_AUDIO_INPUT = 'voicepoc.audioInput'; const S = { ws: null, @@ -69,6 +70,46 @@ function toast(msg, kind = '') { setTimeout(() => t.remove(), 5000); } +function playRetroCue(name) { + const ctx = S.audioCtx; + if (!ctx) return; + ctx.resume().catch(() => {}); + + const cues = { + join: [ + { frequency: 659, offset: 0, duration: 0.09, type: 'square' }, + { frequency: 988, offset: 0.08, duration: 0.16, type: 'square' }, + ], + leave: [ + { frequency: 523, offset: 0, duration: 0.11, type: 'triangle' }, + { frequency: 392, offset: 0.09, duration: 0.18, type: 'triangle' }, + ], + share: [ + { frequency: 523, offset: 0, duration: 0.08, type: 'square' }, + { frequency: 659, offset: 0.07, duration: 0.08, type: 'square' }, + { frequency: 784, offset: 0.14, duration: 0.08, type: 'square' }, + { frequency: 1047, offset: 0.21, duration: 0.22, type: 'triangle' }, + ], + }; + const cue = cues[name]; + if (!cue) return; + + const start = ctx.currentTime; + for (const note of cue) { + const oscillator = ctx.createOscillator(); + const gain = ctx.createGain(); + const noteStart = start + note.offset; + oscillator.type = note.type; + oscillator.frequency.setValueAtTime(note.frequency, noteStart); + gain.gain.setValueAtTime(0.0001, noteStart); + gain.gain.exponentialRampToValueAtTime(0.055, noteStart + 0.008); + gain.gain.exponentialRampToValueAtTime(0.0001, noteStart + note.duration); + oscillator.connect(gain).connect(ctx.destination); + oscillator.start(noteStart); + oscillator.stop(noteStart + note.duration + 0.02); + } +} + const PEER_COLORS = ['#5865f2', '#23a55a', '#f0b232', '#eb459e', '#3ba55c', '#faa61a', '#e67e22', '#9b59b6']; function colorFor(id) { let h = 0; @@ -88,6 +129,60 @@ function effectiveIce() { return S.iceServers || FALLBACK_ICE; } +function microphoneConstraints(deviceId = localStorage.getItem(LS_AUDIO_INPUT)) { + const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; + if (deviceId) audio.deviceId = { exact: deviceId }; + return { audio }; +} + +async function populateAudioInputs() { + const select = $('#audio-input'); + if (!select || !navigator.mediaDevices?.enumerateDevices) return; + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const inputs = devices.filter((device) => device.kind === 'audioinput'); + const selected = localStorage.getItem(LS_AUDIO_INPUT) || ''; + select.replaceChildren(new Option('System default', '')); + inputs.forEach((device, index) => { + select.add(new Option(device.label || `Microphone ${index + 1}`, device.deviceId)); + }); + select.value = inputs.some((device) => device.deviceId === selected) ? selected : ''; + if (selected && !select.value) localStorage.removeItem(LS_AUDIO_INPUT); + } catch (err) { + console.warn('enumerating audio inputs failed', err); + } +} + +async function switchAudioInput(deviceId) { + const previousDeviceId = localStorage.getItem(LS_AUDIO_INPUT) || ''; + if (deviceId) localStorage.setItem(LS_AUDIO_INPUT, deviceId); + else localStorage.removeItem(LS_AUDIO_INPUT); + if (!S.room || !S.micStream) return; + + let nextStream; + try { + nextStream = await navigator.mediaDevices.getUserMedia(microphoneConstraints(deviceId)); + } catch (err) { + if (previousDeviceId) localStorage.setItem(LS_AUDIO_INPUT, previousDeviceId); + else localStorage.removeItem(LS_AUDIO_INPUT); + $('#audio-input').value = previousDeviceId; + toast(`Could not use that microphone: ${err.message || err}`, 'error'); + return; + } + + const nextTrack = nextStream.getAudioTracks()[0]; + nextTrack.enabled = !S.muted; + await Promise.all([...S.peers.values()].map(async (peer) => { + if (!peer.pc) return; + const sender = peer.pc.getSenders().find((item) => item.track && item.track.kind === 'audio'); + if (sender) await sender.replaceTrack(nextTrack); + })); + S.micStream.getTracks().forEach((track) => track.stop()); + S.micStream = nextStream; + updateMicButton(); + toast('Microphone changed', 'ok'); +} + // --------------------------------------------------------------------------- // join / leave // --------------------------------------------------------------------------- @@ -113,9 +208,8 @@ async function joinVoice(room) { window.addEventListener('keydown', resumeCtx, { passive: true }); try { - S.micStream = await navigator.mediaDevices.getUserMedia({ - audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, - }); + S.micStream = await navigator.mediaDevices.getUserMedia(microphoneConstraints()); + populateAudioInputs(); } catch { S.micStream = null; toast('No microphone available — joining in listen-only mode', 'warn'); @@ -323,12 +417,14 @@ function handleSignal(msg) { } case 'joined': S.peers.set(msg.peer.id, makePeerRecord(msg.peer)); + playRetroCue('join'); renderAll(); toast(`${msg.peer.name} joined`, 'ok'); break; case 'left': { const peer = S.peers.get(msg.id); if (peer) { + playRetroCue('leave'); destroyPeer(peer); S.peers.delete(msg.id); renderAll(); @@ -530,11 +626,13 @@ function onRemoteTrack(peer, ev) { const stream = (ev.streams && ev.streams[0]) ? ev.streams[0] : new MediaStream([track]); if (track.kind === 'video') { + const isNewScreenShare = peer.screenStream !== stream; peer.screenStream = stream; track.addEventListener('ended', () => { if (peer.screenStream === stream) { peer.screenStream = null; renderScreens(); } }); renderScreens(); + if (isNewScreenShare) playRetroCue('share'); console.log('remote screen share from', peer.name); } else if (track.kind === 'audio') { peer.micStream = stream; @@ -648,6 +746,7 @@ async function startScreenShare() { } S.screenStream = stream; S.sharing = true; + playRetroCue('share'); send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: true } }); for (const peer of S.peers.values()) addScreenToPeer(peer); @@ -1769,11 +1868,13 @@ window.addEventListener('keydown', (ev) => { if (ev.key === 's') (S.sharing ? stopScreenShare() : startScreenShare()); }); window.addEventListener('beforeunload', () => { try { cancelReconnect(); leaveVoice(); } catch {} }); +navigator.mediaDevices?.addEventListener('devicechange', populateAudioInputs); // --------------------------------------------------------------------------- // settings modal (custom STUN/TURN) // --------------------------------------------------------------------------- function openSettings() { + populateAudioInputs(); const override = localStorage.getItem(LS_ICE); $('#override-enabled').checked = !!override; const current = override ? JSON.parse(override) : effectiveIce(); @@ -1784,6 +1885,7 @@ function openSettings() { ); $('#modal-backdrop').classList.remove('hidden'); } +$('#audio-input').onchange = (ev) => switchAudioInput(ev.target.value); $('#ice-cancel').onclick = () => $('#modal-backdrop').classList.add('hidden'); $('#modal-backdrop').onclick = (ev) => { if (ev.target === $('#modal-backdrop')) $('#modal-backdrop').classList.add('hidden'); diff --git a/public/index.html b/public/index.html index e061558..41afb78 100644 --- a/public/index.html +++ b/public/index.html @@ -131,6 +131,12 @@
Changes apply immediately when connected, or the next time you join.
Overrides the server-provided ICE configuration (stored in localStorage, applied on next join).