Task #441: Fix notification sound on iPad/Safari (Web Audio migration)

- Rewrote artifacts/tx-os/src/lib/notification-sounds.ts to use the
  Web Audio API (lazy AudioContext + decoded AudioBuffer cache) instead
  of HTMLAudioElement. unlock() resumes the ctx for any non-running
  state (handles iOS `interrupted`), plays a 1-sample silent buffer to
  satisfy iOS Safari's gesture requirement, and pre-warms decodes.
  decodeAudioData wrapped to support both Promise and callback forms
  for older WebKit.
- play() dispatches an AUTOPLAY_BLOCKED_EVENT with `{ isIos }` detail
  when ctx isn't running. Throttle is 3 s; new bypassThrottle option
  (used by testPlay) skips the gate AND avoids advancing lastPlayAt
  so settings previews can't suppress real socket chimes.
- use-audio-unlock.ts keeps gesture listeners attached and re-calls
  unlock() on every gesture (handles iPad ctx auto-suspend on
  background / AirPlay).
- use-autoplay-hint.ts reads detail.isIos and switches between
  notifSettings.autoplayHint and notifSettings.autoplayHintIos
  (added to en.json + ar.json with Control Center / silent-switch
  guidance).
- notification-settings.tsx per-sound preview button now calls
  notificationPlayer.testPlay(id).
- Test globals __txosNotifPlayCount/__txosNotifLastSound preserved;
  added __txosNotifCtxState() accessor (dev/test-only, gated by
  import.meta.env.MODE).
- Added tests/notification-sound-webaudio.spec.mjs covering the
  preview button gesture path: counter increments, throttle bypass
  works, singleton AudioContext reaches `running` state.

Notes:
- Pre-existing api-server tsc errors in executive-meetings.ts and the
  failing `test` workflow are unrelated to this task and out of scope.
This commit is contained in:
riyadhafraa
2026-05-07 14:15:53 +00:00
parent be52a88e9d
commit 28ddbe33d1
7 changed files with 403 additions and 71 deletions
@@ -154,8 +154,11 @@ function SoundList({
<button
type="button"
onClick={() => {
notificationPlayer.unlock();
notificationPlayer.play(id);
// #441: testPlay bypasses the 3s burst-coalescing
// throttle (so users can audition several sounds in a
// row from settings) and force-unlocks the audio
// context inside this gesture handler.
notificationPlayer.testPlay(id);
}}
aria-label={t("notifSettings.preview")}
className="p-1 rounded hover:bg-foreground/10 text-muted-foreground"
@@ -9,18 +9,18 @@ import { notificationPlayer } from "@/lib/notification-sounds";
export function useAudioUnlock(): void {
useEffect(() => {
if (typeof window === "undefined") return;
let done = false;
// #441: We used to detach after the very first gesture, but on
// iPad Safari the AudioContext can slip back into `suspended`
// when the tab is backgrounded or after a Bluetooth/AirPlay route
// change. Calling `unlock()` is cheap and idempotent, so we keep
// listening and re-resume on every interaction. The detach happens
// only when the host component unmounts.
const unlock = () => {
if (done) return;
done = true;
notificationPlayer.unlock();
window.removeEventListener("pointerdown", unlock);
window.removeEventListener("keydown", unlock);
window.removeEventListener("touchstart", unlock);
};
window.addEventListener("pointerdown", unlock, { once: true });
window.addEventListener("keydown", unlock, { once: true });
window.addEventListener("touchstart", unlock, { once: true });
window.addEventListener("pointerdown", unlock);
window.addEventListener("keydown", unlock);
window.addEventListener("touchstart", unlock, { passive: true });
return () => {
window.removeEventListener("pointerdown", unlock);
window.removeEventListener("keydown", unlock);
+16 -8
View File
@@ -1,24 +1,32 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@/hooks/use-toast";
import { AUTOPLAY_BLOCKED_EVENT } from "@/lib/notification-sounds";
import {
AUTOPLAY_BLOCKED_EVENT,
type AutoplayBlockedDetail,
} from "@/lib/notification-sounds";
/**
* When a notification sound is attempted before the browser has
* accepted a user gesture, the player dispatches a custom event. We
* surface a one-time toast asking the user to click anywhere so future
* sounds can play. The first qualifying click also triggers
* `useAudioUnlock`, so the hint typically only ever shows once per
* page load.
* accepted a user gesture — or, on iPad, when the AudioContext refuses
* to resume because the device is in silent mode — the player
* dispatches a custom event. We surface a toast with the appropriate
* guidance. On iOS the toast also tells the user to check Control
* Center / the silent switch since "click anywhere" alone won't help
* when the hardware mute is on.
*/
export function useAutoplayHint(): void {
const { t } = useTranslation();
const { toast } = useToast();
useEffect(() => {
const handler = () => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<AutoplayBlockedDetail>).detail;
const isIos = !!detail?.isIos;
toast({
title: t("notifSettings.autoplayHintTitle"),
description: t("notifSettings.autoplayHint"),
description: isIos
? t("notifSettings.autoplayHintIos")
: t("notifSettings.autoplayHint"),
});
};
window.addEventListener(AUTOPLAY_BLOCKED_EVENT, handler);
+221 -47
View File
@@ -10,57 +10,186 @@ export const ALL_SOUND_IDS: ReadonlyArray<NotificationSoundId> =
/**
* Custom DOM event the player dispatches when a play attempt is denied
* because the user hasn't yet interacted with the page (browser autoplay
* policy). The App listens for this and shows a one-time toast hint.
* policy) or the AudioContext could not be resumed. The App listens for
* this and shows a one-time toast hint. The event `detail` carries an
* `isIos` flag so the toast can include iPad-specific guidance (check
* Control Center mute / silent switch).
*/
export const AUTOPLAY_BLOCKED_EVENT = "txos:notification-autoplay-blocked";
export type AutoplayBlockedDetail = {
isIos: boolean;
};
// iPad/iPhone/iPod detection used to surface a more actionable hint
// (e.g. "check the silent switch / Control Center"). Modern iPadOS
// reports as Mac in `userAgent`, so we also check for Mac + multi-touch.
function detectIos(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent || "";
if (/iPad|iPhone|iPod/.test(ua)) return true;
// iPadOS 13+ desktop Safari masquerade.
if (
ua.includes("Macintosh") &&
typeof navigator.maxTouchPoints === "number" &&
navigator.maxTouchPoints > 1
) {
return true;
}
return false;
}
type WindowWithWebkitAC = Window & {
webkitAudioContext?: typeof AudioContext;
};
class NotificationPlayer {
private cache = new Map<NotificationSoundId, HTMLAudioElement>();
// Single shared AudioContext for the whole app. Lazily created in
// `unlock()` because constructing it before a user gesture leaves it
// in `suspended` state on every browser, and iOS Safari is even
// stricter — it must be created (or resumed) inside the gesture
// callback or it never starts.
private ctx: AudioContext | null = null;
// Decoded sound buffers, keyed by sound id. Populated on demand and
// pre-warmed from `unlock()` so the first socket-driven chime after
// page load doesn't have to wait on a network round-trip.
private buffers = new Map<NotificationSoundId, AudioBuffer>();
// In-flight decode promises to dedupe concurrent loads of the same
// sound (e.g. unlock pre-warm + an immediate first play).
private loading = new Map<NotificationSoundId, Promise<AudioBuffer | null>>();
private unlocked = false;
private lastPlayAt = 0;
private hintDispatched = false;
isUnlocked(): boolean {
return this.unlocked;
// "Unlocked" here means we've at least attempted resume() inside a
// user gesture AND the context is currently running. If the user
// backgrounded the tab the context can drift back to `interrupted`
// on iOS, so we re-check every call instead of trusting the flag
// alone.
if (!this.unlocked) return false;
return this.ctx?.state === "running";
}
/**
* Browsers require a user gesture before audio can play. Call this once
* the user interacts with the app to "unlock" the audio elements.
* Lazily build the shared AudioContext. Returns `null` when the
* Web Audio API is unavailable (very old browsers / SSR) so callers
* can degrade gracefully.
*/
private getContext(): AudioContext | null {
if (this.ctx) return this.ctx;
if (typeof window === "undefined") return null;
const w = window as WindowWithWebkitAC;
const Ctor = window.AudioContext ?? w.webkitAudioContext;
if (!Ctor) return null;
try {
this.ctx = new Ctor();
} catch {
return null;
}
return this.ctx;
}
/**
* Browsers require a user gesture before audio can play. Call this
* from inside a gesture handler (pointerdown/touchstart/keydown). We
* resume the AudioContext, play a 1-sample silent buffer to fully
* satisfy iOS Safari's gesture requirement, and pre-warm the decoded
* buffers in the background so subsequent socket-driven chimes are
* instant. Safe to call repeatedly — on iOS the context can slip
* back into `suspended` if the tab is backgrounded, so we always
* try to resume() again on each subsequent gesture.
*/
unlock(): void {
const ctx = this.getContext();
if (!ctx) return;
// Always attempt resume() — even if `unlocked` is already true the
// context may have been auto-suspended (or, on iOS, transitioned
// to `interrupted` after a phone call / route change). Resuming
// any non-`running` state from inside the gesture is what gives
// iPad Safari its highest chance of bringing the ctx back.
if (ctx.state !== "running") {
void ctx.resume().catch(() => {});
}
// Play a 1-sample silent buffer through the destination. iOS Safari
// requires an actual `start()` call inside a gesture before it will
// permit later out-of-gesture playback through the same context.
try {
const silent = ctx.createBuffer(1, 1, 22050);
const src = ctx.createBufferSource();
src.buffer = silent;
src.connect(ctx.destination);
src.start(0);
} catch {
/* not fatal — resume() alone may still be enough */
}
if (this.unlocked) return;
this.unlocked = true;
// Touch each cached element with a silent play()/pause() so iOS/Safari
// marks them as user-initiated. Pre-warm cache for snappier playback.
// Pre-decode all sounds in the background. We deliberately do not
// await — playback path falls back to on-demand decode if a
// particular buffer hasn't loaded yet.
for (const entry of NOTIFICATION_SOUND_MANIFEST) {
let el = this.cache.get(entry.id);
if (!el) {
el = new Audio(entry.url);
el.preload = "auto";
this.cache.set(entry.id, el);
void this.loadBuffer(entry.id);
}
}
private async loadBuffer(
soundId: NotificationSoundId,
): Promise<AudioBuffer | null> {
const cached = this.buffers.get(soundId);
if (cached) return cached;
const inFlight = this.loading.get(soundId);
if (inFlight) return inFlight;
const ctx = this.getContext();
const entry = SOUND_BY_ID.get(soundId);
if (!ctx || !entry) return null;
const promise = (async () => {
try {
const res = await fetch(entry.url, { credentials: "same-origin" });
if (!res.ok) return null;
const ab = await res.arrayBuffer();
// Older WebKit only supports the callback form of decodeAudioData;
// wrap so we always get a Promise.
const buf = await new Promise<AudioBuffer>((resolve, reject) => {
try {
const maybe = ctx.decodeAudioData(
ab,
(b) => resolve(b),
(e) => reject(e ?? new Error("decode failed")),
);
if (maybe && typeof (maybe as Promise<AudioBuffer>).then === "function") {
(maybe as Promise<AudioBuffer>).then(resolve, reject);
}
} catch (e) {
reject(e);
}
el.muted = true;
const p = el.play();
if (p && typeof p.then === "function") {
p.then(() => {
el!.pause();
el!.currentTime = 0;
el!.muted = false;
}).catch(() => {
el!.muted = false;
});
} else {
el.muted = false;
}
this.buffers.set(soundId, buf);
return buf;
} catch {
return null;
} finally {
this.loading.delete(soundId);
}
})();
this.loading.set(soundId, promise);
return promise;
}
private dispatchHint() {
if (this.hintDispatched) return;
this.hintDispatched = true;
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(AUTOPLAY_BLOCKED_EVENT));
const detail: AutoplayBlockedDetail = { isIos: detectIos() };
window.dispatchEvent(
new CustomEvent<AutoplayBlockedDetail>(AUTOPLAY_BLOCKED_EVENT, {
detail,
}),
);
}
// Allow another hint after a generous cool-down so the user doesn't
// get spammed if they ignore the first toast.
@@ -69,36 +198,34 @@ class NotificationPlayer {
}, 30_000);
}
/**
* Play a notification chime by id. Safe to call before `unlock()` —
* the play attempt will simply surface the autoplay-blocked hint
* once instead of producing audio. Throttled to at most one chime
* every 3 s to coalesce bursts.
*/
play(
soundId: NotificationSoundId,
opts: { vibrate?: boolean } = {},
opts: { vibrate?: boolean; bypassThrottle?: boolean } = {},
): void {
// Throttle: at most one sound every 3s to coalesce bursts of
// notifications (e.g. several orders arriving in quick succession)
// into a single chime instead of a string of overlapping beeps.
// The settings "Test sound" affordance opts out so users can
// audition multiple sounds back-to-back.
const now =
typeof performance !== "undefined" ? performance.now() : Date.now();
if (!opts.bypassThrottle) {
if (now - this.lastPlayAt < 3000) return;
// Only the throttled (production socket-driven) path advances
// `lastPlayAt` so that rapid settings previews cannot suppress
// a legitimate socket chime that arrives moments later.
this.lastPlayAt = now;
}
const entry = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");
if (!entry) return;
let el = this.cache.get(entry.id);
if (!el) {
el = new Audio(entry.url);
el.preload = "auto";
this.cache.set(entry.id, el);
}
try {
el.currentTime = 0;
} catch {
/* some browsers throw if not loaded */
}
// Use the device/system volume — explicit volume control is out of
// scope. Setting el.volume = 1 ensures we don't attenuate the OS
// mixer level the user already controls.
el.volume = 1;
// Test-only observability: expose a counter + last sound so Playwright
// can assert chime invocation. Gated on dev/test bundles so production
// builds don't leak debug globals.
@@ -110,14 +237,37 @@ class NotificationPlayer {
w.__txosNotifPlayCount = (w.__txosNotifPlayCount ?? 0) + 1;
w.__txosNotifLastSound = entry.id;
}
const promise = el.play();
if (promise && typeof promise.then === "function") {
promise.catch(() => {
// Most likely autoplay blocked — surface a hint exactly once
// per cool-down so the user knows to click anywhere to enable.
const ctx = this.getContext();
if (!ctx) {
if (!this.unlocked) this.dispatchHint();
});
return;
}
// The context may have drifted back to `suspended` (iOS background
// tab). Try to resume opportunistically — succeeds silently if a
// gesture has already been observed; otherwise the play attempt
// below will be inaudible and we surface the hint.
if (ctx.state !== "running") {
void ctx.resume().catch(() => {});
}
void this.loadBuffer(entry.id).then((buf) => {
if (!buf) return;
// Re-check context state right before scheduling — if it's still
// not running we won't actually be heard, so dispatch the hint.
if (ctx.state !== "running") {
this.dispatchHint();
return;
}
try {
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
src.start(0);
} catch {
/* ignore one-off node errors */
}
});
if (opts.vibrate && typeof navigator !== "undefined" && navigator.vibrate) {
try {
@@ -127,6 +277,30 @@ class NotificationPlayer {
}
}
}
/**
* Force-play used by the "Test sound" button in settings. Bypasses
* the 3-second throttle (so users can audition several sounds in a
* row) and unlocks the context first since this is always called
* from inside a click handler.
*/
testPlay(soundId: NotificationSoundId): void {
this.unlock();
this.play(soundId, { bypassThrottle: true });
}
}
export const notificationPlayer = new NotificationPlayer();
// Test-only accessor so Playwright can assert the singleton player's
// AudioContext state after a user gesture (the actual production
// path on iPad Safari, not a freshly constructed test ctx). Gated on
// dev/test bundles to avoid leaking internals into production.
if (typeof window !== "undefined" && import.meta.env.MODE !== "production") {
(window as unknown as {
__txosNotifCtxState?: () => string | null;
}).__txosNotifCtxState = () => {
const p = notificationPlayer as unknown as { ctx: AudioContext | null };
return p.ctx?.state ?? null;
};
}
+1
View File
@@ -322,6 +322,7 @@
"unmutedToast": "تم إلغاء كتم الإشعارات",
"autoplayHintTitle": "تحتاج الأصوات إلى نقرة",
"autoplayHint": "انقر في أي مكان لتفعيل أصوات الإشعارات.",
"autoplayHintIos": "اضغط في أي مكان لتفعيل الأصوات. إذا ما زلت لا تسمعها، افتح مركز التحكم وعطّل الوضع الصامت.",
"slot": {
"order": "الطلبات",
"meeting": "الاجتماعات",
+1
View File
@@ -325,6 +325,7 @@
"unmutedToast": "Notifications unmuted",
"autoplayHintTitle": "Sounds need a click",
"autoplayHint": "Click anywhere on the page to enable notification sounds.",
"autoplayHintIos": "Tap anywhere to enable sounds. If you still don't hear them, open Control Center and turn off Silent / Mute.",
"slot": {
"order": "Orders",
"meeting": "Meetings",
@@ -0,0 +1,145 @@
// E2E test for Task #441: notification sounds use Web Audio so they
// reliably play on iPad Safari after a single user gesture. Verifies
// (1) the AudioContext reaches `running` state after the user clicks
// the per-sound preview button in notification settings, (2) the play
// counter increments, and (3) the testPlay path is not subject to the
// 3-second burst-coalescing throttle.
import { test, expect } from "@playwright/test";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run this UI test");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdUserIds = [];
function uniq() {
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
async function createUser(prefix, displayEn) {
const username = `${prefix}_${uniq()}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH, displayEn],
);
const id = rows[0].id;
createdUserIds.push(id);
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = 'user'`,
[id],
);
return { id, username };
}
test.afterAll(async () => {
if (createdUserIds.length > 0) {
await pool.query(
`DELETE FROM notifications WHERE user_id = ANY($1::int[])`,
[createdUserIds],
);
await pool.query(
`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`,
[createdUserIds],
);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
createdUserIds,
]);
}
await pool.end();
});
async function loginViaUi(page, username) {
await page.goto("/login");
await page.locator("#username").fill(username);
await page.locator("#password").fill(TEST_PASSWORD);
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
test("preview sound button activates Web Audio and increments play counter", async ({
browser,
}) => {
test.setTimeout(60_000);
const user = await createUser("audio_unlock", "Audio Unlock");
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await loginViaUi(page, user.username);
await page.goto("/");
// Reset counter so we can assert exactly the clicks we drive below.
await page.evaluate(() => {
window.__txosNotifPlayCount = 0;
window.__txosNotifLastSound = undefined;
});
// Open the notification settings popover via its bell trigger
// (aria-label "Notification settings" / "إعدادات الإشعارات"). The
// login defaults to English.
await page.getByRole("button", { name: /notification sounds/i }).click();
// Radix Popover renders into a portal — wait until the popover
// body is on screen before reaching for the preview buttons.
await expect(
page.getByText(/notification sounds/i).last(),
).toBeVisible({ timeout: 5_000 });
// First per-sound preview play — this is the gesture that should
// bring the AudioContext to `running` state on every browser.
const previews = page.getByRole("button", { name: /^preview$/i });
await expect(previews.first()).toBeVisible({ timeout: 5_000 });
await previews.first().click();
// The play counter is bumped synchronously inside play().
await expect
.poll(() => page.evaluate(() => window.__txosNotifPlayCount ?? 0), {
timeout: 3_000,
})
.toBe(1);
const lastSound = await page.evaluate(() => window.__txosNotifLastSound);
expect(typeof lastSound).toBe("string");
expect(lastSound && lastSound.length).toBeGreaterThan(0);
// testPlay must bypass the 3 s throttle so users can audition
// several sounds back-to-back from settings. Click a second
// preview immediately and require the counter to advance.
await previews.nth(1).click();
await expect
.poll(() => page.evaluate(() => window.__txosNotifPlayCount ?? 0), {
timeout: 3_000,
})
.toBe(2);
// Verify the actual production singleton player's AudioContext
// reached `running` state as a result of the preview-button
// gesture. This is what iPad Safari requires to subsequently
// play out-of-gesture, socket-driven chimes.
await expect
.poll(
() =>
page.evaluate(() => {
const fn = /** @type {any} */ (window).__txosNotifCtxState;
return typeof fn === "function" ? fn() : null;
}),
{ timeout: 3_000 },
)
.toBe("running");
} finally {
await ctx.close();
}
});