zebra-spaces: smooth audio source pivot via worklet GainNode ramps
Single mechanism handling three audible-source transitions, all click-
free via Web Audio linearRampToValueAtTime (100ms by default):
1. SFU → mesh (auto, on mesh PC ontrack):
- Previous: detachListenerStream → hard tear-down of the worklet
- Now: rampWorkletGain(uuid, 0, 100) keeps the worklet decoding in
the background; only the GainNode value moves. Free CPU cost (~3%
on a phone), zero audible click.
2. mesh → SFU (auto, on mesh PC connectionState='failed'):
- Previous: attachCachedSfuStreamFor re-attached the SFU stream
- Now: rampWorkletGain(uuid, 1, 100) restores the path that was
never disconnected. attachCachedSfuStreamFor kept as fallback
for the <audio>-element case (when AudioContext failed at attach).
3. SFU → HTTP /stream (manual, per-speaker toggle button next to mic
state); HTTP → SFU on toggle off:
- Previous: existing mute logic only touched remoteAudio (mesh
<audio> elements). Listeners use listenerAudioNodes (worklet),
so toggle-on left the worklet playing AND started HTTP — double
audio. Fox 2026-06-04: "it was breaking listeners."
- Now: startStream calls rampWorkletGain(uuid, 0, 100) and mutes
the mesh element. stopStream reverses both. Per-speaker toggle
finally works for listeners.
The mesh and HTTP <audio> elements still hard-mute via .muted (cheap,
no Web Audio path for them). Only the SFU worklet path needs the
smooth ramp because it's the one that'd produce a click if cut
mid-sample.
Telemetry will show:
- "mesh audio attached for XXXX — SFU worklet faded out" on mesh ontrack
- existing "stream on for XXXX — DJ mode" on HTTP toggle ON
- no new event on toggle OFF / mesh fail (just the gain ramp)
This commit is contained in:
parent
fd119cf572
commit
8fc5a4c1f8
1 changed files with 69 additions and 16 deletions
|
|
@ -2118,6 +2118,38 @@ function detachListenerStream(uuid){
|
|||
try { node.gain.disconnect(); } catch(_){}
|
||||
listenerAudioNodes.delete(uuid);
|
||||
}
|
||||
|
||||
/* Smooth crossfade for the SFU worklet path. The worklet stays running
|
||||
* (continues decoding samples at small CPU cost); only its GainNode
|
||||
* value moves. We use Web Audio's linearRampToValueAtTime for click-
|
||||
* free transitions — settings audio.volume directly causes audible
|
||||
* zipper noise on Android.
|
||||
*
|
||||
* Called by:
|
||||
* - mesh ontrack → ramp to 0 (mesh <audio> takes over)
|
||||
* - mesh fail → ramp to 1 (SFU comes back, no reattach needed)
|
||||
* - startStream → ramp to 0 (HTTP /stream takes over)
|
||||
* - stopStream → ramp to 1 (back to live SFU)
|
||||
*
|
||||
* Default 100ms ramp = below the gap between syllables, masks the
|
||||
* different time-offsets of the two sources (SFU worklet ~0.5s vs
|
||||
* mesh ~50ms). Listener barely notices the pivot. */
|
||||
function rampWorkletGain(uuid, target, durationMs){
|
||||
const node = listenerAudioNodes.get(uuid);
|
||||
if (!node || !node.gain) return;
|
||||
const dur = (typeof durationMs === 'number' ? durationMs : 100) / 1000;
|
||||
if (!audioCtx){
|
||||
try { node.gain.gain.value = target; } catch(_){}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const now = audioCtx.currentTime;
|
||||
const cur = node.gain.gain.value;
|
||||
node.gain.gain.cancelScheduledValues(now);
|
||||
node.gain.gain.setValueAtTime(cur, now);
|
||||
node.gain.gain.linearRampToValueAtTime(target, now + dur);
|
||||
} catch(_){}
|
||||
}
|
||||
function attachSfuTrack(uuid, stream){
|
||||
/* Every role routes through the AudioContext + worklet path now.
|
||||
* Listener gets 4s buffer (lean-back, latency doesn't matter, ride
|
||||
|
|
@ -5077,15 +5109,14 @@ async function connectToPeer(uuid, weOffer){
|
|||
for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); }
|
||||
setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio'));
|
||||
pc.ontrack = (ev) => {
|
||||
/* Tear down the SFU worklet path for this uuid before mesh's
|
||||
* <audio> takes over. Without this we hear the same voice
|
||||
* through TWO paths simultaneously: SFU mic → AudioContext
|
||||
* worklet (~0.5s delay), and mesh mic → <audio> (~50ms).
|
||||
* Pre-refactor, both paths used <audio> in the remoteAudio map
|
||||
* so the second overwrote the first. After role-aware routing,
|
||||
* SFU lives in listenerAudioNodes and mesh in remoteAudio —
|
||||
* different maps, both play, echo. Fox 2026-06-04. */
|
||||
detachListenerStream(uuid);
|
||||
/* Smooth pivot SFU → mesh. We DON'T tear down the SFU worklet
|
||||
* path — it keeps decoding samples in the background. Instead
|
||||
* we ramp its GainNode to 0 over 100ms while the mesh <audio>
|
||||
* starts unmuted. On mesh failure later, ramping SFU gain back
|
||||
* to 1 restores audio without any reattach work. Fox 2026-06-04:
|
||||
* "a speaker should be able to pivot smoothly between the two
|
||||
* feeds." */
|
||||
rampWorkletGain(uuid, 0, 100);
|
||||
let a = remoteAudio.get(uuid);
|
||||
if (!a){
|
||||
a = document.createElement('audio'); a.autoplay = true;
|
||||
|
|
@ -5099,8 +5130,9 @@ async function connectToPeer(uuid, weOffer){
|
|||
* needed. */
|
||||
try { ev.receiver.playoutDelayHint = SPEAKER_PLAYOUT_DELAY_SEC; } catch(_){}
|
||||
try { ev.receiver.jitterBufferTarget = SPEAKER_PLAYOUT_DELAY_SEC * 1000; } catch(_){}
|
||||
try { a.muted = false; } catch(_){}
|
||||
stopMeter(uuid); startMeter(uuid, a.srcObject);
|
||||
logLine('', 'mesh audio attached for '+uuid.slice(0,4)+' — SFU worklet path detached');
|
||||
logLine('', 'mesh audio attached for '+uuid.slice(0,4)+' — SFU worklet faded out');
|
||||
};
|
||||
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
|
||||
pc.onconnectionstatechange = () => {
|
||||
|
|
@ -5115,10 +5147,14 @@ async function connectToPeer(uuid, weOffer){
|
|||
const attempts = (peerMeshRetries.get(uuid) || 0) + 1;
|
||||
peerMeshRetries.set(uuid, attempts);
|
||||
tearPeer(uuid);
|
||||
/* Mesh PC just died; the audio element for this peer was bound to
|
||||
* the dying mesh stream and won't recover on its own. Switch back
|
||||
* to the cached SFU stream so the user keeps hearing them while
|
||||
* mesh reconnect attempts run in the background. */
|
||||
/* Mesh PC just died. The SFU worklet path was never torn down —
|
||||
* we just faded its GainNode to 0 when mesh took over. Ramp
|
||||
* it back to 1 (100ms) so audio returns smoothly. The legacy
|
||||
* attachCachedSfuStreamFor is still called as a fallback in
|
||||
* case the worklet wasn't present (e.g. AudioContext failed
|
||||
* earlier and we landed on the <audio>-element fallback path
|
||||
* at attach time). */
|
||||
rampWorkletGain(uuid, 1, 100);
|
||||
try { attachCachedSfuStreamFor(uuid); } catch(_){}
|
||||
|
||||
if (attempts >= PEER_MESH_MAX_RETRIES){
|
||||
|
|
@ -5380,6 +5416,17 @@ function streamUrlFor(pubHex){
|
|||
* none should exist. */
|
||||
async function startStream(uuid, pubHex){
|
||||
const wantUrl = streamUrlFor(pubHex);
|
||||
/* Mute the live audio paths so we don't hear them on top of the HTTP
|
||||
* pull. SFU worklet fades to 0 over 100ms (the GainNode handles
|
||||
* click-free transition); mesh <audio> hard-mutes (cheap, no Web
|
||||
* Audio path for those elements). When HTTP toggles OFF, stopStream
|
||||
* reverses both. Previously listeners would hear BOTH the worklet
|
||||
* AND the HTTP pull because the mute logic only touched remoteAudio
|
||||
* — which listeners don't populate. Fox 2026-06-04: "it was breaking
|
||||
* listeners." */
|
||||
rampWorkletGain(uuid, 0, 100);
|
||||
const meshEl = remoteAudio.get(uuid);
|
||||
if (meshEl) try { meshEl.muted = true; } catch(_){}
|
||||
let a = streamAudio.get(uuid);
|
||||
/* Idempotent: second toggle-on for the same pubHex while we're
|
||||
* already loading/playing is a no-op. Resetting .src abort the
|
||||
|
|
@ -5445,6 +5492,12 @@ function stopStream(uuid){
|
|||
* while DJ mode was active. */
|
||||
const w = remoteAudio.get(uuid);
|
||||
if (w) try { w.muted = false; } catch(_){}
|
||||
/* Ramp the SFU worklet back up (100ms). If mesh is currently active
|
||||
* for this peer, the mesh <audio> element above carries the audible
|
||||
* output and the worklet plays into a destination that nobody hears
|
||||
* — same as before, no behavior change. If mesh isn't active, the
|
||||
* SFU worklet IS the live path and the ramp restores audio. */
|
||||
rampWorkletGain(uuid, 1, 100);
|
||||
}
|
||||
function toggleStreamFor(uuid, pubHex){
|
||||
if (streamMode.has(pubHex)){
|
||||
|
|
@ -6381,8 +6434,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
|
|||
|
||||
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-04</span><br>
|
||||
md5 <span class="stamp-md5">ffdaafffe887e5213d2152178708bb3b</span><br>
|
||||
sha256 <span class="stamp-sha">e1f9d34469407f49bc8129eef43cf990c611bd77cd6f8725bd2be8416e5eb644</span><br>
|
||||
md5 <span class="stamp-md5">f77d16e2213f40156ea05cf0983587b4</span><br>
|
||||
sha256 <span class="stamp-sha">2ad85516c4eaee2daa910790f3e9e8fe626339ccbd5704fa4d0a49b429bffba2</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
|
||||
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
||||
</footer>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue