Throttle notification sounds so a backlog doesn't play them all at once (#33989)

After waking from sleep (notably macOS Sequoia), the whole sync backlog is delivered in one
batch, so every backlogged notifying event calls playAudioNotification near-simultaneously
and the identical audio buffers superimpose into a single loud "stacked" sound.

Throttle audible plays to at most one per distinct resolved sound within
NOTIFICATION_SOUND_THROTTLE_MS, keyed on the sound so two genuinely different sounds arriving
within the window each still play. The throttle runs only after the existing silencing gate,
so it suppresses redundant audible plays without affecting the gating logic.
This commit is contained in:
hayyaksi
2026-07-06 15:11:58 +00:00
committed by GitHub
parent 47da609f53
commit 3153912cc1
2 changed files with 155 additions and 1 deletions
+45
View File
@@ -60,6 +60,29 @@ import { BackgroundAudio } from "./audio/BackgroundAudio";
const MAX_PENDING_ENCRYPTED = 20;
/**
* Minimum interval (in milliseconds) between two *audible* notification plays.
*
* Coalesces bursts of backlogged notifications into a single sound. This is the
* in-repo remedy for https://github.com/element-hq/element-web/issues/31996: on
* macOS Sequoia, waking from sleep delivers the entire sync backlog in one
* batch, so every backlogged notifying event fires {@link
* NotifierClass.playAudioNotification} near-simultaneously and the identical
* audio buffers superimpose into one loud "stacked" sound.
*
* The throttle is keyed on the resolved sound, so a backlog of identical sounds
* coalesces to one play while two genuinely *different* sounds (e.g. a custom
* per-room sound) arriving within the window each still play. A conservative
* window is intentional: merging a burst of the same sound is preferable to a
* wall of overlapping audio.
*
* NOTE: This only cures the sounds-enabled renderer Web-Audio path (the default
* config). It does NOT fix the variant where macOS Sequoia ignores the OS
* banner's `silent: true` and plays its own coalesced banner sound on wake;
* that is purely OS/Electron behaviour with no in-repo lever.
*/
export const NOTIFICATION_SOUND_THROTTLE_MS = 1000;
/*
Override both the content body and the TextForEvent handler for specific msgtypes, in notifications.
This is useful when the content body contains fallback text that would explain that the client can't handle a particular
@@ -165,6 +188,14 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
private backgroundAudio = new BackgroundAudio();
/**
* Per-sound timestamp (ms, from {@link Date.now}) of the last *audible* notification, keyed by the
* resolved sound (custom sound url, or `"default"`). Used to throttle a burst of backlogged
* notifications of the *same* sound into a single play while letting distinct sounds through -
* see {@link NOTIFICATION_SOUND_THROTTLE_MS}.
*/
private readonly lastAudioNotificationMs = new Map<string, number>();
public notificationMessageForEvent(ev: MatrixEvent): string | null {
const msgType = ev.getContent().msgtype;
if (msgType && msgTypeHandlers.hasOwnProperty(msgType)) {
@@ -278,6 +309,20 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
const sound = this.getSoundForRoom(room.roomId);
logger.log(`Got sound ${sound?.name || "default"} for ${room.roomId}`);
// Throttle audible plays so a burst of backlogged notifications - e.g. the whole sync backlog
// delivered at once when macOS wakes from sleep - produces at most one sound per distinct sound
// within NOTIFICATION_SOUND_THROTTLE_MS, instead of many identical buffers superimposing into one
// loud "stacked" sound (#31996). Keyed on the resolved sound so two *different* sounds within the
// window both still play. Runs only after the silencing gate above, so it suppresses redundant
// *audible* plays, never the gating logic. We bail cleanly (no throw).
const soundKey = sound?.url ?? "default";
const now = Date.now();
const lastPlayed = this.lastAudioNotificationMs.get(soundKey);
if (lastPlayed !== undefined && now - lastPlayed < NOTIFICATION_SOUND_THROTTLE_MS) {
return;
}
this.lastAudioNotificationMs.set(soundKey, now);
if (sound) {
await this.backgroundAudio.play(sound.url);
} else {
+110 -1
View File
@@ -23,7 +23,7 @@ import { CallMembership, type SessionMembershipData, type MatrixRTCSession } fro
import { randomUUID } from "node:crypto";
import type BasePlatform from "../../src/BasePlatform";
import Notifier from "../../src/Notifier";
import Notifier, { NOTIFICATION_SOUND_THROTTLE_MS } from "../../src/Notifier";
import SettingsStore from "../../src/settings/SettingsStore";
import ToastStore from "../../src/stores/ToastStore";
import {
@@ -166,6 +166,12 @@ describe("Notifier", () => {
// @ts-ignore
Notifier.backgroundAudio.audioContext = mockAudioContext;
// Notifier is a singleton, so its audio-notification throttle state
// (see NOTIFICATION_SOUND_THROTTLE_MS) leaks between tests. Reset it so
// each test exercises a clean instance.
// @ts-ignore - lastAudioNotificationMs is private
Notifier.lastAudioNotificationMs.clear();
});
describe("triggering notification from events", () => {
@@ -429,6 +435,109 @@ describe("Notifier", () => {
});
});
// Regression test for https://github.com/element-hq/element-web/issues/31996
// On macOS Sequoia, waking from sleep delivers the whole sync backlog in one
// batch, firing playAudioNotification for every backlogged notifying event
// near-simultaneously. Without throttling, the identical sound buffers
// superimpose into one loud "stacked" sound. We coalesce a burst into at
// most one audible play within NOTIFICATION_SOUND_THROTTLE_MS.
describe("playAudioNotification throttle (macOS wake-from-sleep stacking)", () => {
let playSpy: jest.SpyInstance;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
// Ensure notifications are not silenced so we exercise the throttle,
// not the silencing gate.
accountDataStore = {};
mockClient.setAccountData(accountDataEventKey, { is_silenced: false });
// Default sound path (no custom room sound).
Notifier.getSoundForRoom = jest.fn().mockReturnValue(null);
// @ts-ignore - backgroundAudio is private
playSpy = jest.spyOn(Notifier.backgroundAudio, "pickFormatAndPlay").mockResolvedValue({} as any);
});
afterEach(() => {
playSpy.mockRestore();
jest.useRealTimers();
});
it("plays at most one sound for a burst of notifications within the throttle window", async () => {
// Simulate a backlog of notifications arriving back-to-back on wake.
await Notifier.playAudioNotification(testEvent, testRoom);
await Notifier.playAudioNotification(testEvent, testRoom);
await Notifier.playAudioNotification(testEvent, testRoom);
expect(playSpy).toHaveBeenCalledTimes(1);
});
it("plays again once the throttle window has elapsed", async () => {
await Notifier.playAudioNotification(testEvent, testRoom);
expect(playSpy).toHaveBeenCalledTimes(1);
// Advance the clock just past the throttle window.
jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS + 1);
await Notifier.playAudioNotification(testEvent, testRoom);
expect(playSpy).toHaveBeenCalledTimes(2);
});
it("throttles right up to the window boundary, then plays again (strict `<`)", async () => {
await Notifier.playAudioNotification(testEvent, testRoom);
expect(playSpy).toHaveBeenCalledTimes(1);
// One ms before the window elapses: still throttled.
jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS - 1);
await Notifier.playAudioNotification(testEvent, testRoom);
expect(playSpy).toHaveBeenCalledTimes(1);
// Exactly at the window boundary: plays again (the comparison is a strict `<`).
jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS);
await Notifier.playAudioNotification(testEvent, testRoom);
expect(playSpy).toHaveBeenCalledTimes(2);
});
it("does not coalesce two genuinely different sounds within the window (#31996 per-sound keying)", async () => {
const soundA = { url: "sound-a.mp3", name: "A", type: "audio/mpeg", size: 1 };
const soundB = { url: "sound-b.mp3", name: "B", type: "audio/mpeg", size: 1 };
const otherRoom = new Room("!other:server", mockClient, mockClient.getSafeUserId());
(Notifier.getSoundForRoom as jest.Mock).mockImplementation((roomId: string) =>
roomId === testRoom.roomId ? soundA : soundB,
);
// @ts-ignore - backgroundAudio is private
const customPlaySpy = jest.spyOn(Notifier.backgroundAudio, "play").mockResolvedValue({} as any);
// Two different sounds back-to-back within the window: BOTH must play (only identical
// backlogged sounds are coalesced).
await Notifier.playAudioNotification(testEvent, testRoom);
await Notifier.playAudioNotification(testEvent, otherRoom);
expect(customPlaySpy).toHaveBeenCalledTimes(2);
expect(customPlaySpy).toHaveBeenNthCalledWith(1, soundA.url);
expect(customPlaySpy).toHaveBeenNthCalledWith(2, soundB.url);
customPlaySpy.mockRestore();
});
it("does not play, and does not arm the throttle, when notifications are silenced", async () => {
mockClient.setAccountData(accountDataEventKey, { is_silenced: true });
await Notifier.playAudioNotification(testEvent, testRoom);
await Notifier.playAudioNotification(testEvent, testRoom);
// Silencing gate short-circuits before the sound is played.
expect(playSpy).not.toHaveBeenCalled();
// ...and the silenced calls did NOT arm the throttle: once un-silenced, the next event plays
// immediately (a regression arming the throttle on silenced events would suppress this).
mockClient.setAccountData(accountDataEventKey, { is_silenced: false });
await Notifier.playAudioNotification(testEvent, testRoom);
expect(playSpy).toHaveBeenCalledTimes(1);
});
});
describe("group call notifications", () => {
let callId: string;
beforeEach(() => {