diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index d4a07e0..310bb33 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -688,6 +688,9 @@ try {
+
+ +
@@ -1285,6 +1288,7 @@ const ID_KEY = 'zebra-spaces-id-v1'; const HANDLE_KEY = 'zebra-spaces-handle-v1'; const MUSIC_MODE_KEY = 'zebra-spaces-music-mode-v1'; const MIC_DEV_KEY = 'zebra-spaces-mic-device-v1'; +const SPK_DEV_KEY = 'zebra-spaces-spk-device-v1'; const CAM_DEV_KEY = 'zebra-spaces-cam-device-v1'; const THEME_KEY = 'zebra-theme-v1'; /* sessionStorage (per-tab) — tracks which call this tab is in so a @@ -1521,9 +1525,10 @@ $('handle').value = myHandle; * last-picked mic + camera deviceIds. Declared up here so the restore * runs before their downstream `let` would put them in the temporal * dead zone; downstream code now reads from these existing bindings. */ -let musicMode = false, micDeviceId = '', cameraDeviceId = ''; +let musicMode = false, micDeviceId = '', speakerDeviceId = '', cameraDeviceId = ''; try { musicMode = localStorage.getItem(MUSIC_MODE_KEY) === '1'; } catch(_){} try { micDeviceId = localStorage.getItem(MIC_DEV_KEY) || ''; } catch(_){} +try { speakerDeviceId = localStorage.getItem(SPK_DEV_KEY) || ''; } catch(_){} try { cameraDeviceId = localStorage.getItem(CAM_DEV_KEY) || ''; } catch(_){} if ($('music-mode')) $('music-mode').checked = musicMode; renderIdentity(); @@ -1592,7 +1597,11 @@ const sfuStreamsByPubHex = new Map(); // pubHex -> MediaStream function attachSfuTrack(uuid, stream){ let a = remoteAudio.get(uuid); - if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); } + if (!a){ + a = document.createElement('audio'); a.autoplay = true; + document.body.appendChild(a); remoteAudio.set(uuid, a); + applySinkTo(a); + } a.srcObject = stream; /* programmatic play in case autoplay policy needs the nudge after a track * swap — caller already had a user gesture (entered the space) */ @@ -2152,7 +2161,7 @@ async function sfuPublish(){ for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); } setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio')); const offer = await pc.createOffer(); - offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000, { music: musicMode }); + offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 510000 : 40000, { music: musicMode }); await pc.setLocalDescription(offer); await waitForIceGathering(pc); const res = await fetch(SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) + '&pub=' + myKeys.pubHex, { @@ -2242,7 +2251,7 @@ async function sfuPublishScreen(){ const offer = await pc.createOffer(); /* screen-share audio is always music-grade — system audio capture is * what users actually broadcast, not voice */ - offer.sdp = preferStereoOpus(offer.sdp, 256000, { music: true }); + offer.sdp = preferStereoOpus(offer.sdp, 510000, { music: true }); await pc.setLocalDescription(offer); await waitForIceGathering(pc); const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) @@ -2257,12 +2266,12 @@ async function sfuPublishScreen(){ await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp }); sfuScreenPC = pc; sfuScreenPeerID = ans.peer_id; /* raise the RTP-level caps: 6 Mbps for video (high-detail 1080p screen), - * 256 kbps for audio (transparent stereo Opus). The codec-level cap was - * already raised via preferStereoOpus(). */ + * 510 kbps for audio (Opus spec ceiling, transparent stereo). The codec-level + * cap was already raised via preferStereoOpus(). */ for (const s of pc.getSenders()){ if (!s.track) continue; if (s.track.kind === 'video') setSenderMaxBitrate(s, 6000000); - if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000); + if (s.track.kind === 'audio') setSenderMaxBitrate(s, 510000); } /* Screen share CANNOT auto-rebuild like mic + camera — getDisplayMedia * requires a fresh user gesture (transient activation). Auto-calling @@ -2334,7 +2343,7 @@ async function sfuPublishGame(iframe){ } stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishGame(); }); const offer = await pc.createOffer(); - offer.sdp = preferStereoOpus(offer.sdp, 256000, { music: true }); + offer.sdp = preferStereoOpus(offer.sdp, 510000, { music: true }); await pc.setLocalDescription(offer); await waitForIceGathering(pc); const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) @@ -2351,7 +2360,7 @@ async function sfuPublishGame(iframe){ for (const s of pc.getSenders()){ if (!s.track) continue; if (s.track.kind === 'video') setSenderMaxBitrate(s, 4000000); - if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000); + if (s.track.kind === 'audio') setSenderMaxBitrate(s, 510000); } /* game-share doesn't auto-rebuild on 'failed' — its capture source is * a user-selected iframe via Region Capture; the user would have to @@ -2798,6 +2807,50 @@ async function refreshMicList(){ else micDeviceId = sel.value; } catch(e){ logLine('err','could not list inputs: '+e.message); } } +/* Speaker output picker — routes peer audio to a specific sink (studio + * monitors, external DAC, headphones). The codec gains stop at the + *