fixing audio issues

This commit is contained in:
2026-04-30 17:31:18 -04:00
parent 2a4922f6d4
commit e6cb776438
3 changed files with 99 additions and 10 deletions

View File

@@ -192,6 +192,15 @@ async function connect() {
state.username = username;
$('login-error').classList.add('hidden');
// Create the shared AudioContext and start the playback primer SYNCHRONOUSLY,
// before any await. Mobile browsers (Android Chrome, iOS Safari, Firefox Android)
// gate audio playback behind transient user activation that disappears after the
// first await. Doing this here means audio playback for peers works on first join
// without requiring some other media event (like a remote camera turning on) to
// later activate the page's audio session.
getAudioCtx();
startAudioPlaybackPrimer();
try {
// Check for saved device preferences
const savedDevices = loadSavedDevices();
@@ -201,12 +210,6 @@ async function connect() {
state.localStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints, video: false });
// Create + resume the shared AudioContext while we're still inside the Connect
// click's user-gesture window. Mobile browsers (especially iOS) only credit
// AudioContexts created during a gesture; doing this here means every later peer
// inherits a 'running' context and audio plays on the first try.
getAudioCtx();
// Setup local audio analyser for speaking detection
setupLocalAudioAnalyser();
@@ -633,6 +636,15 @@ function handlePageLeave() {
teardownPeerAudio(id);
try { state.peers[id].pc.close(); } catch (e) {}
});
if (state.audioPrimer) {
state.audioPrimer.srcObject = null;
state.audioPrimer.remove();
state.audioPrimer = null;
}
if (state.audioPrimerSource) {
try { state.audioPrimerSource.stop(); } catch (e) {}
state.audioPrimerSource = null;
}
if (state.audioCtx) {
try { state.audioCtx.close(); } catch (e) {}
state.audioCtx = null;

View File

@@ -12,6 +12,12 @@ const state = {
// NOT used for output - peer audio plays via per-peer hidden <audio> elements (see
// setupPeerAudio in webrtc.js) for reliable mobile autoplay.
audioCtx: null,
// Hidden <audio> element kept playing a silent stream for the entire session. Started
// synchronously during the Connect tap so the page's audio session is active when
// peer audio elements get created later. Without this, mobile browsers leave new
// audio elements silent until some other media event activates the session.
audioPrimer: null,
audioPrimerSource: null,
localAnalyser: null,
localAudioSource: null,
screenStream: null,

View File

@@ -21,6 +21,75 @@ function getAudioCtx() {
return state.audioCtx;
}
// Start a hidden <audio> element playing a silent stream from the audio context. This
// activates the page's audio session DURING the Connect tap (transient user activation
// is still valid synchronously), so by the time peer audio elements are created later
// from ontrack events - well outside any user gesture - audio playback Just Works. Without
// this primer, mobile browsers leave new audio elements silent until some other media
// event activates the session (e.g. a remote peer's camera coming on, which creates a
// muted <video> that activates audio playback).
//
// Idempotent. Must be called synchronously inside the user-gesture handler.
function startAudioPlaybackPrimer() {
if (state.audioPrimer) return;
const ctx = getAudioCtx();
if (!ctx) return;
try {
// Silent buffer source piping into a MediaStreamDestination. We use a non-zero
// gain (well below audible) so browsers don't optimize the path away as silent.
const buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
const source = ctx.createBufferSource();
source.buffer = buffer;
source.loop = true;
const gain = ctx.createGain();
gain.gain.value = 0.0001;
const dest = ctx.createMediaStreamDestination();
source.connect(gain);
gain.connect(dest);
source.start();
const primer = document.createElement('audio');
primer.autoplay = true;
primer.playsInline = true;
primer.id = 'audio-playback-primer';
primer.srcObject = dest.stream;
const container = document.getElementById('peer-audio-container') || document.body;
container.appendChild(primer);
primer.play().catch(e => console.warn('[Audio] primer play() rejected:', e?.message || e));
state.audioPrimer = primer;
state.audioPrimerSource = source;
console.log('[Audio] Playback primer started (ctx state:', ctx.state, ')');
} catch (e) {
console.warn('[Audio] startAudioPlaybackPrimer failed:', e?.message || e);
}
}
// Defensive recovery for the case where a peer's <audio>.play() rejected (e.g. the
// primer didn't take, or this device's mobile browser is stricter than expected). On
// the next user tap anywhere in the page, retry play() on every peer audio element.
let pendingAudioPlayRetry = false;
function schedulePeerAudioPlayRetry() {
if (pendingAudioPlayRetry) return;
pendingAudioPlayRetry = true;
const retry = () => {
pendingAudioPlayRetry = false;
document.removeEventListener('click', retry);
document.removeEventListener('touchend', retry);
Object.values(state.peers).forEach(peer => {
if (peer.audioElement && peer.audioElement.paused) {
peer.audioElement.play().catch(() => {});
}
});
if (state.audioPrimer && state.audioPrimer.paused) {
state.audioPrimer.play().catch(() => {});
}
};
document.addEventListener('click', retry, { once: true });
document.addEventListener('touchend', retry, { once: true });
}
function setupLocalAudioAnalyser() {
if (!state.localStream) return;
const ctx = getAudioCtx();
@@ -585,10 +654,12 @@ function setupPeerAudio(peerId, stream) {
const container = document.getElementById('peer-audio-container') || document.body;
container.appendChild(audioEl);
audioEl.srcObject = peer.audioDestination.stream;
// Some browsers need an explicit play() even with autoplay=true. Catch failure
// silently - if it really can't autoplay, the next user click will cover it via
// resumeAllAudioContexts in ui.js.
audioEl.play().catch(e => console.warn(`[Audio] play() rejected for ${peerId}:`, e?.message || e));
audioEl.play().catch(e => {
console.warn(`[Audio] play() rejected for ${peerId}:`, e?.message || e);
// Defensive: queue a retry on next user tap. Should rarely fire because
// startAudioPlaybackPrimer activates the audio session at Connect time.
schedulePeerAudioPlayRetry();
});
peer.audioElement = audioEl;
}