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
+ *