| index 258b39f..5ce6733 100644 |
| --- a/internal/calls/static/room.js |
| +++ b/internal/calls/static/room.js |
| @@ -213,6 +213,34 @@ |
| // squeezed down a pipe built for speech. It is a ceiling and not a |
| // floor — the encoder still spends only what the link will carry. |
| const RAW_AUDIO_BITRATE = 128000; |
| + // §15.20: the hearing watch. How often the levels are read — five |
| + // times a second, which is as fast as a bar is worth moving and slow |
| + // enough that one small getStats per connection costs nothing a |
| + // person could notice. |
| + const LEVEL_EVERY = 200; |
| + // An audioLevel below this is silence: -80 dBFS, well under any room's |
| + // own noise floor, so a microphone reading it is not hearing the room |
| + // at all. Anything at or above it counts as sound. |
| + const FLAT_LEVEL = 0.0001; |
| + // How long a stream that is arriving must stay that flat before it is |
| + // called silent — long enough to cover somebody simply not talking in |
| + // a quiet room, short enough to catch "can you hear me?" early. |
| + const NO_SOUND_AFTER = 5000; |
| + // Packets count as arriving if one came this recently. Wider than a |
| + // poll, because a sender using DTX sends a packet every 400 ms or so |
| + // through silence, and silence with packets is exactly the case the |
| + // watch is for. |
| + const ARRIVING_WINDOW = 1500; |
| + // The speaking highlight, with hysteresis: on at SPEAK_ON, and off only |
| + // after SPEAK_HOLD polls below SPEAK_OFF, so a tile does not flicker |
| + // between syllables. |
| + const SPEAK_ON = 0.04; |
| + const SPEAK_OFF = 0.015; |
| + const SPEAK_HOLD = 4; |
| + // How many bars a meter has. |
| + const METER_STEPS = 5; |
| + // §4.3's limit on the list the hub will take. |
| + const MAX_NO_SOUND = 15; |
| // §5.4: the tile key a peer's screen gets, built by shareKey. |
| const SHARE_SUFFIX = '#share'; |
| // §5.4, sending: "Optimise shared screen for". What a screen gives up |
| @@ -267,6 +295,28 @@ |
| // The state this tab last put on the broadcast lane, as mediaKey |
| // writes it, so a toggle that changes nothing sends nothing. |
| let mediaSent = ''; |
| + // §15.20: whether the report may carry the two optional keys. A server |
| + // from before them answers 400 to anything but the two booleans, and |
| + // the first such answer turns them off for the life of the page. |
| + let mediaExtras = true; |
| + // §15.20: peer id -> what this tab is hearing from that peer, as |
| + // pollLevels reads it (hearingRecord). selfHearing is the same record |
| + // for our own microphone, read off the sender. |
| + const hearing = new Map(); |
| + const selfHearing = hearingRecord(); |
| + // Tile keys whose element the browser refused to play. Non-empty is |
| + // `audioBlocked`: the page banner shows, and the report says so. |
| + const refusedTiles = new Set(); |
| + let audioBlocked = false; |
| + // The autoplay policy as the browser states it in advance, where it |
| + // will (Firefox). A hint that puts the banner up early; the refusal |
| + // is still the authority, and a play that succeeds takes it down. |
| + let autoplayDoubted = autoplayPolicyDoubtful(); |
| + let soundBanner = null; // {box, text, button} once built |
| + let levelTimer = 0; |
| + let levelsBusy = false; |
| + // The noSoundFrom list as last computed, joined, so a change is seen. |
| + let noSoundKey = ''; |
| |
| // §5.2's list and §5.3's clocks. `expiresAt`, `renewAt`, `fetchedAt` |
| // and `lastFetchAt` are all milliseconds since the epoch: the server |
| @@ -2045,6 +2095,10 @@ |
| // rebuilt under a new generation has renewed nothing yet, and a |
| // record left behind would say it had. |
| renewals.delete(id); |
| + // §15.20: what was heard belongs to a connection too. A rebuilt one |
| + // starts its counters again, and a silence timed on the old one is |
| + // not a silence on the new. |
| + hearing.delete(id); |
| if (forget) { |
| stale.delete(id); |
| gen.delete(id); |
| @@ -3182,11 +3236,14 @@ |
| // broadcast queues one untargeted message and resolves when the hub |
| // has answered for it: true if it was accepted, false for every other |
| // ending, including a deadline that ran out with nothing decided. |
| - function broadcast(type, payload) { |
| + // `onStatus`, if given, hears the status of the answer that settled it |
| + // — the one thing the boolean cannot say, and what §15.20's report |
| + // needs to tell an old server's 400 from a network that failed. |
| + function broadcast(type, payload, onStatus) { |
| if (!state.peer || state.stopped) return Promise.resolve(false); |
| return new Promise((resolve) => { |
| const q = queue.get(BROADCAST) || []; |
| - q.push({ body: broadcastBody(state.peer, type, payload), resolve }); |
| + q.push({ body: broadcastBody(state.peer, type, payload), resolve, onStatus }); |
| queue.set(BROADCAST, q); |
| runBroadcast(); |
| }); |
| @@ -3238,6 +3295,7 @@ |
| return; |
| } |
| if (verdict === 'sent' || verdict === 'drop') { |
| + if (typeof head.onStatus === 'function') head.onStatus(res.status); |
| settleBroadcast(q, accepted(verdict)); |
| wait = BACKOFF_MIN; |
| continue; |
| @@ -4038,21 +4096,43 @@ |
| // peer that never reports — a client from before this — is unknown, |
| // and unknown is drawn as neither muted nor camera-off. |
| |
| - // mediaFrom accepts exactly the two booleans and nothing looser, for a |
| - // roster entry or a `media` payload alike. Pure, and lifted by name in |
| + // mediaFrom accepts the two booleans and nothing looser, for a roster |
| + // entry or a `media` payload alike: either is missing or not a |
| + // boolean, and the whole report is unknown. §15.20's two optional keys |
| + // are read more forgivingly, because they are news about hearing and |
| + // not about who is muted: an `audioBlocked` that is not exactly true, |
| + // or a `noSoundFrom` entry that is not a peer id, is dropped on its |
| + // own and the rest of the report stands. Pure, and lifted by name in |
| // the node test. |
| function mediaFrom(value) { |
| if (!value || typeof value !== 'object') return null; |
| if (typeof value.muted !== 'boolean' || typeof value.cameraOff !== 'boolean') return null; |
| - return { muted: value.muted, cameraOff: value.cameraOff }; |
| + const out = { muted: value.muted, cameraOff: value.cameraOff }; |
| + if (value.audioBlocked === true) out.audioBlocked = true; |
| + if (Array.isArray(value.noSoundFrom)) { |
| + const ids = []; |
| + for (const id of value.noSoundFrom) { |
| + if (ids.length >= 15) break; |
| + if (typeof id === 'string' && /^[0-9a-f]{32}$/.test(id) && !ids.includes(id)) ids.push(id); |
| + } |
| + if (ids.length) out.noSoundFrom = ids; |
| + } |
| + return out; |
| } |
| |
| function sameMedia(a, b) { |
| - return !!a && !!b && a.muted === b.muted && a.cameraOff === b.cameraOff; |
| + return !!a && !!b && mediaKey(a) === mediaKey(b); |
| } |
| |
| + // mediaKey is one string per distinct report, the optional keys |
| + // included, so a change in what this tab can hear is a change that is |
| + // sent. The list is sorted here: the same peers in another order are |
| + // the same report. |
| function mediaKey(m) { |
| - return (m.muted ? 'm' : '-') + (m.cameraOff ? 'c' : '-'); |
| + let key = (m.muted ? 'm' : '-') + (m.cameraOff ? 'c' : '-'); |
| + if (m.audioBlocked) key += 'b'; |
| + if (m.noSoundFrom && m.noSoundFrom.length) key += ':' + m.noSoundFrom.slice().sort().join(','); |
| + return key; |
| } |
| |
| // localMedia is what this tab is actually sending. No microphone at |
| @@ -4064,19 +4144,45 @@ |
| return { muted: !!prefs.muted || !hasAudio, cameraOff: !!prefs.cameraOff || !hasVideo }; |
| } |
| |
| + // mediaPayload is the report itself: §15.19's two booleans, and |
| + // §15.20's two keys only when they carry bad news — so the ordinary |
| + // report is still exactly the two-key object every server takes. |
| + // `plain` is a server that has refused the extras once. Pure, and |
| + // lifted by name in the node test. |
| + function mediaPayload(mine, blocked, silentFrom, plain) { |
| + const out = { muted: !!mine.muted, cameraOff: !!mine.cameraOff }; |
| + if (plain) return out; |
| + if (blocked) out.audioBlocked = true; |
| + if (silentFrom && silentFrom.length) out.noSoundFrom = silentFrom.slice(0, 15); |
| + return out; |
| + } |
| + |
| // reportMedia puts our state on the broadcast lane, once per change, |
| // and only once admitted: before that the hub refuses it, and the |
| // roster that admits us is where it is sent from. A refusal forgets |
| // what was sent, so the next toggle or roster tries again — which |
| // against a server from before §15.19 is a quiet 400 each time. |
| + // |
| + // A 400 for a report that carried §15.20's keys is a server from |
| + // between the two, and the answer is to stop sending them and say the |
| + // two booleans at once, for the rest of this page's life: being heard |
| + // as muted or not matters more than being heard as unable to hear. |
| function reportMedia() { |
| if (!state.admitted || state.stopped || !state.peer) return; |
| - const now = localMedia(); |
| + const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras); |
| const key = mediaKey(now); |
| if (key === mediaSent) return; |
| mediaSent = key; |
| - broadcast('media', now).then((ok) => { |
| + const extras = key !== mediaKey(mediaPayload(now, false, null, true)); |
| + let fallBack = false; |
| + broadcast('media', now, (status) => { |
| + if (status === 400 && extras && mediaExtras) { |
| + mediaExtras = false; |
| + fallBack = true; |
| + } |
| + }).then((ok) => { |
| if (!ok && mediaSent === key) mediaSent = ''; |
| + if (fallBack) reportMedia(); |
| }); |
| } |
| |
| @@ -4091,7 +4197,7 @@ |
| learnMedia(rp.peer, rp.media); |
| continue; |
| } |
| - const now = localMedia(); |
| + const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras); |
| mediaSent = sameMedia(mediaFrom(rp.media), now) ? mediaKey(now) : ''; |
| } |
| reportMedia(); |
| @@ -4198,8 +4304,475 @@ |
| t.muted.hidden = !(media && media.muted); |
| t.off.hidden = !(media && media.cameraOff); |
| t.figure.classList.toggle('calls-tile-camera-off', !!(media && media.cameraOff)); |
| + drawHearing(key); |
| + } |
| + |
| + // --------------------------------------------------------------- |
| + // Hearing (§15.20): what this tab can hear, and what it is sending |
| + // --------------------------------------------------------------- |
| + // |
| + // Written after a call on 2026-09-25 in which one side could not hear |
| + // the other and neither could tell why. §15.19 says who is muted; this |
| + // says whether sound is actually arriving. Every tile carries a meter |
| + // for the audio behind it, read from getStats and never from Web Audio |
| + // (an AudioContext made without a gesture starts suspended and reads |
| + // nothing), and three things are said in words: |
| + // |
| + // - a stream that is arriving but has been flat for five seconds |
| + // from someone who says they are not muted: "No sound from …", |
| + // and the same list goes back to the hub as `noSoundFrom`, so the |
| + // person who cannot be heard is told so on their own screen; |
| + // - a browser that refused to play: "Sound blocked", a page banner, |
| + // and `audioBlocked` in the report, for the same reason; |
| + // - our own microphone flat for five seconds while unmuted. |
| + // |
| + // Everything here is about sound that has already reached a browser. |
| + // Nothing about it leaves this tab except the two keys above. |
| + |
| + // hearingRecord is one stream's running state between polls. |
| + function hearingRecord() { |
| + return { |
| + level: 0, // the latest level, 0..1 |
| + peak: 0, // the loudest level seen, for the gate |
| + energy: -1, // totalAudioEnergy at the last poll |
| + duration: -1, // totalSamplesDuration at the last poll |
| + packets: -1, // packetsReceived at the last poll |
| + packetAt: 0, // when packetsReceived last grew |
| + sampled: false, // a report has been read at all |
| + quietSince: 0, // when the current flat stretch began, or 0 |
| + silent: false, // flat for NO_SOUND_AFTER while it counted |
| + speaking: false, |
| + speakHold: 0, |
| + notice: '', // the remote notice last drawn, for the live region |
| + }; |
| + } |
| + |
| + // audioSample reads one audio stats report against the record's last |
| + // one: the level to draw, whether it was sound, and the counters for |
| + // next time. `audioLevel` is the browser's own reading; where it is |
| + // missing, the root mean square since the last poll stands in for it, |
| + // from totalAudioEnergy over totalSamplesDuration. Sound is either one |
| + // at or over FLAT_LEVEL — the energy half is what catches a beep that |
| + // fell between two polls. A report carrying neither is `measurable: |
| + // false`: a browser that does not say how loud a stream is has not |
| + // said it is silent, and the five-second rule must not read it so. |
| + // Pure, and lifted by name in the node test. |
| + function audioSample(prev, r) { |
| + const energy = typeof r.totalAudioEnergy === 'number' ? r.totalAudioEnergy : -1; |
| + const duration = typeof r.totalSamplesDuration === 'number' ? r.totalSamplesDuration : -1; |
| + const packets = typeof r.packetsReceived === 'number' ? r.packetsReceived : -1; |
| + let rms = -1; |
| + if (prev && prev.energy >= 0 && energy >= prev.energy && duration > prev.duration && prev.duration >= 0) { |
| + rms = Math.sqrt((energy - prev.energy) / (duration - prev.duration)); |
| + } |
| + let level = typeof r.audioLevel === 'number' ? r.audioLevel : rms; |
| + if (!(level >= 0)) level = 0; |
| + level = Math.min(1, level); |
| + return { |
| + level, |
| + measurable: typeof r.audioLevel === 'number' || energy >= 0, |
| + heard: level >= FLAT_LEVEL || rms >= FLAT_LEVEL, |
| + energy, |
| + duration, |
| + packets, |
| + grew: !!prev && packets >= 0 && prev.packets >= 0 && packets > prev.packets, |
| + }; |
| + } |
| + |
| + // judgeSilence is the five-second rule. It counts only while the |
| + // stream is `eligible` (the sender says it is unmuted, and this tab is |
| + // in a position to hear it) and `arriving` (packets are coming in); a |
| + // stream that is not arriving is a connection's problem, not a |
| + // microphone's, and says nothing here. Any sound ends a flat stretch |
| + // at once, which is the hysteresis: on only after five quiet seconds, |
| + // off at the first sound. Pure over the record, and lifted by name in |
| + // the node test. |
| + function judgeSilence(rec, now, eligible, arriving, heard) { |
| + if (!eligible || !arriving || heard) { |
| + rec.quietSince = 0; |
| + rec.silent = false; |
| + return false; |
| + } |
| + if (!rec.quietSince) rec.quietSince = now; |
| + rec.silent = now - rec.quietSince >= NO_SOUND_AFTER; |
| + return rec.silent; |
| + } |
| + |
| + // judgeSpeaking is the tile's highlight: on above SPEAK_ON, off after |
| + // SPEAK_HOLD polls under SPEAK_OFF, and held in between. Pure over the |
| + // record, and lifted by name in the node test. |
| + function judgeSpeaking(rec, level) { |
| + if (level >= SPEAK_ON) { |
| + rec.speaking = true; |
| + rec.speakHold = SPEAK_HOLD; |
| + } else if (rec.speaking && level < SPEAK_OFF) { |
| + rec.speakHold -= 1; |
| + if (rec.speakHold <= 0) rec.speaking = false; |
| + } |
| + return rec.speaking; |
| + } |
| + |
| + // levelStep turns a level into how many of the meter's bars are lit. |
| + // Decibels, because that is how loudness is heard: -60 dBFS and under |
| + // is one bar, 0 dBFS is all of them, and silence is none — so any |
| + // sound at all lights something, and a flat stream is visibly flat. |
| + // Pure, and lifted by name in the node test. |
| + function levelStep(level) { |
| + if (!(level >= FLAT_LEVEL)) return 0; |
| + const db = 20 * Math.log10(Math.min(1, level)); |
| + return Math.max(1, Math.min(METER_STEPS, Math.ceil((db + 60) / 12))); |
| + } |
| + |
| + // hearingShown is what a tile says about its audio, in order: the |
| + // Muted badge, when the person says so (a meter beside it would only |
| + // argue); "Sound blocked", when this browser will not play; "No sound", |
| + // when the five-second rule says so; and otherwise the meter. The same |
| + // order for our own tile, which is never blocked. Pure, and lifted by |
| + // name in the node test. |
| + function hearingShown(media, blocked, silent) { |
| + if (media && media.muted) return 'muted'; |
| + if (blocked) return 'blocked'; |
| + if (silent) return 'silent'; |
| + return 'meter'; |
| + } |
| + |
| + // remoteNotice is what a peer's own report says about hearing us: that |
| + // their browser is refusing sound, or that they are getting none from |
| + // us while we are not muted. Pure, and lifted by name in the node test. |
| + function remoteNotice(name, media, me, muted) { |
| + if (!media) return ''; |
| + if (media.audioBlocked) { |
| + return name + '’s browser is blocking sound — they need to click to hear you.'; |
| + } |
| + if (!muted && me && Array.isArray(media.noSoundFrom) && media.noSoundFrom.includes(me)) { |
| + return name + ' isn’t getting any sound from you.'; |
| + } |
| + return ''; |
| + } |
| + |
| + // noSoundList is the `noSoundFrom` this tab would report now: every |
| + // peer the five-second rule has judged silent, sorted, and never more |
| + // than the hub will take. |
| + function noSoundList() { |
| + const out = []; |
| + for (const [id, rec] of hearing) if (rec.silent) out.push(id); |
| + return out.sort().slice(0, MAX_NO_SOUND); |
| + } |
| + |
| + // The words and the explanation behind the ⓘ, for each thing a tile |
| + // can say. The explanations are what someone who has never used this |
| + // app needs in order to do something about it. |
| + const HEAR_SELF_SILENT = 'Your microphone isn’t picking anything up'; |
| + const HEAR_SELF_WHY = 'Calls is sending your microphone, but it has been silent for a few seconds, ' + |
| + 'so the others hear nothing. Check the microphone chosen under Devices, or on the holding screen, ' + |
| + 'and the input your computer’s sound settings are using. A microphone with its own mute ' + |
| + 'switch, or an audio interface sending a different input, looks exactly like this.'; |
| + const HEAR_BLOCKED = 'Sound blocked — click to hear'; |
| + const HEAR_BLOCKED_WHY = 'This browser will not play sound until you interact with the page. ' + |
| + 'Click anywhere, or press any key, and the call’s sound comes on.'; |
| + const HEAR_SILENT_WHY = 'Their audio is reaching this browser, but it has been silent for a few ' + |
| + 'seconds while they are not muted. Their microphone may not be picking anything up: the wrong ' + |
| + 'input chosen, a microphone muted on the device itself, or an audio interface sending a ' + |
| + 'different input. Calls lets them know.'; |
| + |
| + // hearingMarks builds one tile's hearing parts: the meter beside the |
| + // name, the sentence with its ⓘ, and the line for a peer's notice. |
| + // The meter is aria-hidden — a level moving five times a second is |
| + // noise to a screen reader — and everything it means that matters is |
| + // said in the sentence instead. |
| + function hearingMarks() { |
| + const meter = document.createElement('span'); |
| + meter.className = 'calls-meter'; |
| + meter.setAttribute('aria-hidden', 'true'); |
| + meter.dataset.level = '0'; |
| + meter.hidden = true; |
| + for (let i = 0; i < METER_STEPS; i++) meter.append(document.createElement('i')); |
| + const box = document.createElement('div'); |
| + box.className = 'calls-tile-hear'; |
| + box.hidden = true; |
| + const text = document.createElement('span'); |
| + const info = document.createElement('details'); |
| + info.className = 'calls-info'; |
| + const summary = document.createElement('summary'); |
| + summary.setAttribute('aria-label', 'What this means'); |
| + summary.textContent = 'ⓘ'; |
| + const why = document.createElement('p'); |
| + info.append(summary, why); |
| + box.append(text, info); |
| + const notice = document.createElement('span'); |
| + notice.className = 'calls-tile-notice'; |
| + notice.hidden = true; |
| + return { meter, box, text, why, notice, kind: '' }; |
| + } |
| + |
| + // drawHearing brings one tile's hearing parts up to date. Called from |
| + // markTile and from every level poll, so it writes nothing that has |
| + // not changed: a details element someone has opened stays open, and |
| + // the live region hears a notice once. |
| + function drawHearing(key) { |
| + const t = tiles.get(key); |
| + if (!t || !t.hear) return; |
| + const h = t.hear; |
| + const self = key === 'self'; |
| + const rec = self ? selfHearing : hearing.get(key) || null; |
| + const media = self ? localMedia() : remoteMedia.get(key) || null; |
| + const blocked = !self && refusedTiles.has(key); |
| + const shown = hearingShown(media, blocked, !!(rec && rec.silent)); |
| + t.hearShown = shown; |
| + h.meter.hidden = shown !== 'meter'; |
| + const step = String(shown === 'meter' && rec ? levelStep(rec.level) : 0); |
| + if (h.meter.dataset.level !== step) h.meter.dataset.level = step; |
| + t.figure.classList.toggle('calls-tile-speaking', shown === 'meter' && !!(rec && rec.speaking)); |
| + const name = self ? '' : names.get(key) || 'Someone'; |
| + let kind = ''; |
| + let text = ''; |
| + let why = ''; |
| + if (shown === 'blocked') { |
| + kind = 'blocked'; |
| + text = HEAR_BLOCKED; |
| + why = HEAR_BLOCKED_WHY; |
| + } else if (shown === 'silent') { |
| + kind = self ? 'self' : 'silent:' + name; |
| + text = self ? HEAR_SELF_SILENT : 'No sound from ' + name; |
| + why = self ? HEAR_SELF_WHY : HEAR_SILENT_WHY; |
| + } |
| + if (h.kind !== kind) { |
| + h.kind = kind; |
| + h.text.textContent = text; |
| + h.why.textContent = why; |
| + h.box.hidden = !kind; |
| + } |
| + if (self) return; |
| + const notice = remoteNotice(name, media, state.peer, localMedia().muted); |
| + if (h.notice.textContent !== notice) { |
| + h.notice.textContent = notice; |
| + h.notice.hidden = !notice; |
| + } |
| + if (rec && rec.notice !== notice) { |
| + rec.notice = notice; |
| + announceMedia(notice); |
| + } |
| + } |
| + |
| + // audioPart finds a connection's microphone sender or receiver — the |
| + // camera m-line's audio, which is the only audio a connection carries |
| + // (§5.4's screen has none). |
| + function audioPart(pc, which) { |
| + try { |
| + return pc[which]().find((x) => x.track && x.track.kind === 'audio') || null; |
| + } catch { |
| + return null; |
| + } |
| + } |
| + |
| + // audioReport is the one report of `type` in a sender's or receiver's |
| + // own stats, which are a few objects rather than the connection's |
| + // whole walk. |
| + async function audioReport(part, type) { |
| + const stats = await part.getStats(); |
| + let found = null; |
| + stats.forEach((r) => { |
| + if (!found && r.type === type && (r.kind === 'audio' || r.mediaType === 'audio')) found = r; |
| + }); |
| + return found; |
| + } |
| + |
| + // sampleInto takes one report into one record, and returns the sample. |
| + function sampleInto(rec, report, now) { |
| + const s = audioSample(rec.sampled ? rec : null, report); |
| + rec.sampled = true; |
| + rec.level = s.level; |
| + rec.peak = Math.max(rec.peak, s.level); |
| + rec.energy = s.energy; |
| + rec.duration = s.duration; |
| + rec.packets = s.packets; |
| + if (s.grew) rec.packetAt = now; |
| + judgeSpeaking(rec, s.level); |
| + return s; |
| + } |
| + |
| + // pollLevels is the hearing watch: one receiver's stats per connection, |
| + // and one sender's for our own microphone, five times a second. Never |
| + // two at once — a getStats that takes longer than the interval would |
| + // otherwise stack. |
| + async function pollLevels() { |
| + if (levelsBusy || state.stopped) return; |
| + levelsBusy = true; |
| + try { |
| + const now = Date.now(); |
| + let selfReport = null; |
| + for (const [id, entry] of pcs) { |
| + let rec = hearing.get(id); |
| + if (!rec) { |
| + rec = hearingRecord(); |
| + hearing.set(id, rec); |
| + } |
| + const receiver = audioPart(entry.pc, 'getReceivers'); |
| + let report = null; |
| + try { |
| + report = receiver ? await audioReport(receiver, 'inbound-rtp') : null; |
| + } catch { |
| + // A closing connection: nothing heard. |
| + } |
| + if (!report) { |
| + rec.level = 0; |
| + judgeSilence(rec, now, false, false, false); |
| + } else { |
| + const s = sampleInto(rec, report, now); |
| + const t = tiles.get(id); |
| + const media = remoteMedia.get(id) || null; |
| + // Eligible: the peer has said it is unmuted, and this tab is |
| + // playing it. A paused element pulls no audio through the |
| + // decoder, so every level reads flat, and that is this |
| + // browser's silence and not the sender's. |
| + const eligible = !!(s.measurable && media && !media.muted && t && t.video && !t.video.paused && |
| + !refusedTiles.has(id) && entry.pc.connectionState === 'connected'); |
| + judgeSilence(rec, now, eligible, now - rec.packetAt < ARRIVING_WINDOW, s.heard); |
| + } |
| + if (!selfReport) { |
| + const sender = audioPart(entry.pc, 'getSenders'); |
| + try { |
| + selfReport = sender ? await audioReport(sender, 'media-source') : null; |
| + } catch { |
| + selfReport = null; |
| + } |
| + } |
| + } |
| + const track = local && local.getAudioTracks()[0]; |
| + if (selfReport && track && track.readyState === 'live') { |
| + const s = sampleInto(selfHearing, selfReport, now); |
| + judgeSilence(selfHearing, now, s.measurable && !localMedia().muted, true, s.heard); |
| + } else { |
| + selfHearing.level = 0; |
| + selfHearing.speaking = false; |
| + judgeSilence(selfHearing, now, false, false, false); |
| + } |
| + const key = noSoundList().join(','); |
| + if (key !== noSoundKey) { |
| + noSoundKey = key; |
| + reportMedia(); |
| + } |
| + for (const k of tiles.keys()) drawHearing(k); |
| + } finally { |
| + levelsBusy = false; |
| + } |
| + } |
| + |
| + // --------------------------------------------------------------- |
| + // Sound the browser refused to play (§15.20) |
| + // --------------------------------------------------------------- |
| + // |
| + // Chromium plays a MediaStream without a gesture whatever its autoplay |
| + // policy says, but Safari, Firefox and a site set to "Block" in Edge |
| + // may not. Each refused tile already offers its own Play; this is the |
| + // page-level half, because a first-time visitor looking at a paused |
| + // face does not know that the silence and the picture are the same |
| + // problem. Any click or key anywhere is the gesture, and it resumes |
| + // every tile at once — shouldPlay's rule still holds, because this is |
| + // the person's gesture and not a retry behind their back. |
| + |
| + // autoplayPolicyDoubtful asks the browser in advance, where it will |
| + // say (Firefox's getAutoplayPolicy). Only a hint: the refusal itself |
| + // is what decides. |
| + function autoplayPolicyDoubtful() { |
| + try { |
| + const policy = typeof navigator !== 'undefined' && typeof navigator.getAutoplayPolicy === 'function' |
| + ? navigator.getAutoplayPolicy('mediaelement') |
| + : ''; |
| + return policy === 'disallowed' || policy === 'allowed-muted'; |
| + } catch { |
| + return false; |
| + } |
| } |
| |
| + // soundRefused records one tile's refusal, or its end, and puts the |
| + // consequences everywhere they go: the banner, the report, the tile. |
| + function soundRefused(key, refused) { |
| + const had = refusedTiles.has(key); |
| + if (refused) refusedTiles.add(key); |
| + else refusedTiles.delete(key); |
| + if (had === refused) return; |
| + const blocked = refusedTiles.size > 0; |
| + if (blocked !== audioBlocked) { |
| + audioBlocked = blocked; |
| + reportMedia(); |
| + } |
| + drawBanner(); |
| + drawHearing(key); |
| + } |
| + |
| + function bannerShown() { |
| + if (audioBlocked) return true; |
| + if (!autoplayDoubted) return false; |
| + for (const t of tiles.values()) if (!t.self && t.stream) return true; |
| + return false; |
| + } |
| + |
| + // soundBannerEl builds the banner now, empty and hidden, for the reason |
| + // warningEl gives: a live region inserted along with its text is one |
| + // nobody was watching. |
| + function soundBannerEl() { |
| + if (soundBanner) return soundBanner; |
| + const box = document.createElement('div'); |
| + box.id = 'sound-banner'; |
| + box.className = 'calls-sound-banner'; |
| + box.hidden = true; |
| + const text = document.createElement('p'); |
| + text.setAttribute('role', 'status'); |
| + const button = document.createElement('button'); |
| + button.type = 'button'; |
| + button.setAttribute('rst-btn', 'primary'); |
| + button.textContent = 'Turn on sound'; |
| + // The click reaches the page-wide listener below as well; the button |
| + // is here for a keyboard, which needs something to land on. |
| + box.append(text, button); |
| + root.prepend(box); |
| + soundBanner = { box, text, button }; |
| + return soundBanner; |
| + } |
| + |
| + function drawBanner() { |
| + const b = soundBannerEl(); |
| + const show = bannerShown() && !state.stopped; |
| + if (show === !b.box.hidden) return; |
| + if (show) { |
| + b.box.hidden = false; |
| + b.text.textContent = 'Click anywhere to turn on sound'; |
| + return; |
| + } |
| + b.text.textContent = ''; |
| + b.box.hidden = true; |
| + } |
| + |
| + // resumeSound is the gesture's work: every remote tile that is not |
| + // playing is asked again, and the answers decide whether the banner |
| + // stays. |
| + function resumeSound() { |
| + autoplayDoubted = false; |
| + for (const [key, t] of tiles) { |
| + if (t.self || !t.stream || !t.video) continue; |
| + if (!t.video.paused && !refusedTiles.has(key)) continue; |
| + if (t.play) t.play.hidden = true; |
| + startPlaying(key, t); |
| + } |
| + drawBanner(); |
| + } |
| + |
| + // A click or a key anywhere, while the banner is up. Capture, so a |
| + // control that stops propagation still counts as the gesture it was. |
| + // Space would also toggle the microphone below, and turning on sound |
| + // is what that first key was for — so that one press is kept from it. |
| + function onGesture(ev) { |
| + if (!bannerShown()) return; |
| + if (ev.type === 'keydown' && spaceKeyAction(ev, ev.target) === 'toggle') { |
| + ev.preventDefault(); |
| + ev.stopImmediatePropagation(); |
| + } |
| + resumeSound(); |
| + } |
| + document.addEventListener('click', onGesture, true); |
| + document.addEventListener('keydown', onGesture, true); |
| + |
| // --------------------------------------------------------------- |
| // Terminal states (§4.5, §4.2) |
| // --------------------------------------------------------------- |
| @@ -4234,6 +4807,7 @@ |
| clearTimeout(reconnectTimer); |
| clearTimeout(fullTimer); |
| clearInterval(statsTimer); |
| + clearInterval(levelTimer); |
| stopIceTimers(); |
| closeStream(); |
| closeAllPCs(); |
| @@ -4244,6 +4818,7 @@ |
| stopSharing(); |
| remoteMedia.clear(); |
| renderTiles(); |
| + drawBanner(); |
| iceWarning(false); |
| controlsEnabled(false); |
| // §6: nothing can be sent from here, so the composer says so. The |
| @@ -4349,6 +4924,8 @@ |
| if (!wanted.includes(key)) { |
| tiles.get(key).figure.remove(); |
| tiles.delete(key); |
| + // A tile that has gone cannot be blocked any more. |
| + soundRefused(key, false); |
| } |
| } |
| if (local) { |
| @@ -4443,9 +5020,15 @@ |
| // §15.19's two marks, hidden until markTile says otherwise. A |
| // screen has neither: it has no microphone, and it is never "off". |
| const marks = key.slice(-SHARE_SUFFIX.length) === SHARE_SUFFIX ? null : tileMarks(); |
| + // §15.20's hearing parts go with them, on the same tiles and for |
| + // the same reason: a screen carries no audio to meter. |
| + const hear = marks ? hearingMarks() : null; |
| if (marks) { |
| nameEl.after(marks.muted); |
| figure.append(marks.off); |
| + marks.muted.after(hear.meter); |
| + // After Play, so the caption's buttons keep their order. |
| + label.append(hear.box, hear.notice); |
| } |
| tilesEl.append(figure); |
| // `self` is kept on the record because rearmFrames reads it: the |
| @@ -4454,6 +5037,7 @@ |
| t = { |
| figure, video, label, nameEl, note, retry, play, |
| muted: marks && marks.muted, off: marks && marks.off, |
| + hear, hearShown: '', |
| remove: null, stream: null, track: null, self: isSelf, |
| // What judgeRender remembers between polls: the element's own |
| // clock, the decoder's count, and how long the two have |
| @@ -4511,14 +5095,20 @@ |
| |
| // startPlaying asks the element to play and, if the browser refuses |
| // (an autoplay policy that wants a gesture first), offers the gesture |
| - // rather than leaving a still frame nobody can explain. |
| + // rather than leaving a still frame nobody can explain. A refusal by |
| + // policy — NotAllowedError, and nothing else — is also §15.20's |
| + // `audioBlocked`: the page banner, and the report that tells the |
| + // people this tab cannot hear. |
| function startPlaying(key, t) { |
| const attempt = t.video.play(); |
| if (!attempt || typeof attempt.catch !== 'function') return; |
| attempt.then(() => { |
| if (t.play) t.play.hidden = true; |
| - }).catch(() => { |
| + if (!t.self) autoplayDoubted = false; |
| + soundRefused(key, false); |
| + }).catch((err) => { |
| if (t.play) t.play.hidden = false; |
| + if (err && err.name === 'NotAllowedError' && tiles.get(key) === t) soundRefused(key, true); |
| }); |
| } |
| |
| @@ -4825,6 +5415,7 @@ |
| clearTimeout(reconnectTimer); |
| clearTimeout(fullTimer); |
| clearInterval(statsTimer); |
| + clearInterval(levelTimer); |
| stopIceTimers(); |
| if (state.peer) postSignal({ peer: state.peer, type: 'leave' }, { keepalive: true }); |
| closeStream(); |
| @@ -5314,6 +5905,25 @@ |
| } |
| } |
| |
| + function hearingState() { |
| + const out = {}; |
| + for (const [key, t] of tiles) { |
| + if (!t.hear) continue; |
| + const rec = key === 'self' ? selfHearing : hearing.get(key) || null; |
| + out[key] = { |
| + shown: t.hearShown, |
| + level: rec ? rec.level : 0, |
| + peak: rec ? rec.peak : 0, |
| + bars: Number(t.hear.meter.dataset.level) || 0, |
| + silent: !!(rec && rec.silent), |
| + speaking: t.figure.classList.contains('calls-tile-speaking'), |
| + text: t.hear.box.hidden ? '' : t.hear.text.textContent, |
| + notice: t.hear.notice.hidden ? '' : t.hear.notice.textContent, |
| + }; |
| + } |
| + return out; |
| + } |
| + |
| function candidateState() { |
| const out = {}; |
| for (const [id, entry] of pcs) { |
| @@ -5503,6 +6113,17 @@ |
| // peer id. Peers that never reported are absent. |
| media: localMedia(), |
| remoteMedia: Object.fromEntries(remoteMedia), |
| + // §15.20, by tile key ('self' for ours): what the tile is saying |
| + // about its audio ('muted', 'blocked', 'silent' or 'meter'), the |
| + // level as last read and the loudest seen, the meter's bars, the |
| + // speaking highlight, and the notice a peer's own report put |
| + // there. Levels are numbers between 0 and 1 and say nothing |
| + // about what was said. |
| + hearing: hearingState(), |
| + audioBlocked, |
| + soundBanner: !!(soundBanner && !soundBanner.box.hidden), |
| + noSoundFrom: noSoundList(), |
| + mediaExtras, |
| }; |
| }, |
| }; |
| @@ -5519,6 +6140,7 @@ |
| // inserted along with its own text is a region the reader was not |
| // watching, and the change goes unannounced. |
| warningEl(); |
| + soundBannerEl(); |
| renderStatus(); |
| renderQueue(); |
| await startLocal(); |
| @@ -5528,6 +6150,13 @@ |
| // camera that stopped encoding (Codex S4-5), so the walk runs |
| // either way and does whichever halves apply. |
| statsTimer = setInterval(pollFrames, 1000); |
| + // §15.20's meters, five times a second and cheaper per turn: one |
| + // receiver's and one sender's own stats, not the connection's walk. |
| + levelTimer = setInterval(() => { |
| + pollLevels().catch((err) => { |
| + console.error('meet: levels', err); |
| + }); |
| + }, LEVEL_EVERY); |
| connectStream(); |
| } |
| |