Refactor notification sound playback to use per-bucket throttling, adjust socket warmup, and ensure push notifications are sent even if the client is considered connected.
Original task: After #461 added an HTMLAudioElement playback path for
iOS, users still reported no sound on iPad. #462 identifies and fixes
three bugs in that earlier attempt.
Root causes addressed:
1. iOS unlocks ONE element per gesture, not all 8. The previous loop
over 8 separate Audio elements left 7 of them gesture-locked.
2. `crossOrigin = "anonymous"` flipped same-origin .wav requests into
CORS mode, causing silent load failure in Safari.
3. `el.volume = 0` is read-only on iOS, so the "silent priming" idea
in #461 would have played 8 real chimes at once on first tap.
Implementation:
- Replaced the 8-element Map with a SINGLE shared HTMLAudioElement on
iOS; rotate `.src` per play (iOS unlock is element-bound, not URL).
- Added `SILENT_WAV_DATA_URL`: a 60-byte inline silent WAV used to
prime the element in `unlock()` — no network, no audible output.
- Removed `crossOrigin = "anonymous"`.
- `testPlay()` on iOS now calls `play()` directly (skipping the
silent-prime) so the gesture-bound `play()` lands on the actual
sound. Non-iOS keeps the original `unlock()`-then-`play()` order.
- `unlock()` is a no-op once primed; deliberately does NOT pause/reset
the silent clip in its resolve handler — letting the ~30ms silence
end naturally avoids racing with a `playIos()` `src` swap from a
click that fires immediately after pointerdown.
Public API unchanged: play / testPlay / unlock / isUnlocked /
AUTOPLAY_BLOCKED_EVENT all preserve their signatures and observable
behavior. Test globals (__txosNotifPlayCount, __txosNotifLastSound,
__txosNotifCtxState) preserved.
Locale strings (`notifSettings.autoplayHintIos`) already focused on
media volume from #461 — no change needed.
Validation: `tsc --noEmit` clean. The pre-existing failures in the
api-server / test workflow (executive-meetings.ts) are unrelated and
explicitly out of scope per the plan. Manual iPad verification is
required to confirm the fix in production.
Files changed:
- artifacts/tx-os/src/lib/notification-sounds.ts (rewritten)
Refactor the `NotificationPlayer` class to correctly handle audio playback initiation on iOS, ensuring that sounds play reliably after user gestures by properly managing the unlocked state based on successful priming of HTMLAudioElements.
Problem: User reported zero sound on iPad — even the "Test sound"
button in notification settings produced silence. YouTube and other
videos worked fine on the same iPad.
Root cause: The app's notification player used the Web Audio API
(AudioContext + BufferSource). On iOS Safari (iPad/iPhone), Web Audio
plays through the **ringer channel**, which is silenced by:
- The hardware silent switch (older iPads),
- Control Center's mute icon,
- Or simply having ringer volume at 0 (independent of media volume).
HTMLAudioElement (the API behind <video> and YouTube) plays through
the **media channel**, which is what users actually have turned up.
Fix in artifacts/tx-os/src/lib/notification-sounds.ts:
- Added an HTMLAudioElement playback engine alongside the existing
Web Audio engine. Engine selection is one-time per session via the
existing `detectIos()` helper (handles iPadOS-as-Mac masquerade).
- iOS path: lazy `new Audio(url)` per sound id with preload="auto",
primed in `unlock()` by play()-then-pause() at volume 0 inside the
user gesture so each element is permanently allowed to play later
out-of-gesture (iOS requirement). Real `play()` resets currentTime
and plays at full volume; rejection of the play promise dispatches
the existing autoplay-blocked hint.
- Non-iOS path: unchanged Web Audio behavior preserved verbatim.
- All public API preserved: `unlock()`, `play()`, `testPlay()`,
`isUnlocked()`, `AUTOPLAY_BLOCKED_EVENT`. No changes needed to
use-audio-unlock, use-autoplay-hint, use-notifications-socket, or
notification-settings.
- Throttling (3s burst-coalesce), bypass for testPlay, vibration,
test-only observability globals, and isIos hint flag all preserved.
- `__txosNotifCtxState` test hook now reports "running"/"suspended"
on iOS based on `unlocked` flag (no AudioContext exists there).
Locale updates (ar.json + en.json):
- `notifSettings.autoplayHintIos` rewritten: now tells users to raise
the **media** volume (same one that controls YouTube) instead of
the old "turn off Silent Mute" advice — matches the new playback
channel.
Verification: tsc --noEmit clean. Pre-existing `test` workflow failure
on unrelated executive-meetings.ts type errors is out of scope.
Out of scope per task: no PWA, no push notifications, no settings UI
redesign, no new sound files. Manual iPad QA needed before merging.
- 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.
User reported a flood of chimes when opening Tx OS while several
notifications were pending. Two complementary fixes:
1. Player throttle bumped from 600 ms → 3000 ms in
artifacts/tx-os/src/lib/notification-sounds.ts. Bursts of real-time
events now produce a single chime instead of several overlapping ones.
2. Socket warmup gate added in
artifacts/tx-os/src/hooks/use-notifications-socket.ts:
- Track connectedAtRef (useRef<number>), set on effect mount and on
each socket "connect" event using performance.now().
- In the notification_created handler, after mute / per-channel
gates but before notificationPlayer.play(...), suppress
audio + vibration when within the 3 s warmup window.
- UI/badge invalidation still runs — only sound is silenced during
the warmup, so the user sees notifications appear without an
alarm-like welcome.
All existing gates preserved (global mute, per-channel sound/vibration,
autoplay-blocked toast hint, per-meeting playedRef dedupe).
No deviations. tx-os typecheck passes.
DB
- Added user pref columns; vibration is per-channel
(vibrationEnabledOrder + vibrationEnabledMeeting). Defaults: ding/chime
sounds, vibration on, mute off. Volume uses the device system level —
no persisted volume field.
API
- PATCH /auth/me/notification-preferences (requireAuth, whitelisted partial
update, integer guard for volume). buildAuthUser exposes all new fields.
- OpenAPI spec updated; orval client + zod regenerated.
Frontend
- Static sound library: 8 short WAV assets under public/sounds/ + manifest
(notification-sound-manifest.ts) mapping id -> labelKey -> URL.
- HTMLAudio-based player (notification-sounds.ts) with throttle, unlock,
cache, and AUTOPLAY_BLOCKED_EVENT dispatch when play is denied pre-gesture.
- Settings popover: global mute + slot tabs (orders/meetings) with
per-channel sound + per-channel vibration + per-channel toggle, sound list
with one-tap previews. Concurrency-safe optimistic updates (monotonic seq
counter). RTL-correct toggle knob transforms.
- QuickMuteButton: one-tap topbar mute control (Volume2/VolumeX) with
confirmation toast.
- useAudioUnlock: unlocks audio on first interaction.
- useAutoplayHint: toasts a localized "click anywhere to enable" hint when
the player reports a blocked play attempt.
- Socket hook plays the right per-channel sound + vibration on
notification_created (orders, executive_meeting) when the tab is hidden.
- UpcomingMeetingAlert: plays meeting reminder once per new eligible
meeting (deduped by meetingId, survives 30s polling refetches). Skipped
when the tab is currently visible — sound only fires when backgrounded.
- AR/EN translations added.
Pre-existing TS errors in executive-meetings.ts (font_settings) and
pre-existing test workflow failures are unrelated.
DB
- Added 7 user pref columns; vibration is now per-channel
(vibrationEnabledOrder + vibrationEnabledMeeting). Defaults: ding/chime
sounds, vibration on, mute off, volume 70.
API
- PATCH /auth/me/notification-preferences (requireAuth, whitelisted partial
update, integer guard for volume). buildAuthUser exposes all new fields.
- OpenAPI spec updated; orval client + zod regenerated.
Frontend
- Static sound library: 8 short WAV assets under public/sounds/ + manifest
(notification-sound-manifest.ts) mapping id -> labelKey -> URL.
- HTMLAudio-based player (notification-sounds.ts) with throttle, unlock,
cache, and AUTOPLAY_BLOCKED_EVENT dispatch when play is denied pre-gesture.
- Settings popover: global mute, volume, slot tabs (orders/meetings) with
per-channel sound + per-channel vibration + per-channel toggle, sound list
with one-tap previews. Concurrency-safe optimistic updates (monotonic seq
counter). RTL-correct toggle knob transforms.
- QuickMuteButton: one-tap topbar mute control (Volume2/VolumeX) with
confirmation toast.
- useAudioUnlock: unlocks audio on first interaction.
- useAutoplayHint: toasts a localized "click anywhere to enable" hint when
the player reports a blocked play attempt.
- Socket hook plays the right per-channel sound + vibration on
notification_created (orders, executive_meeting) when the tab is hidden.
- UpcomingMeetingAlert: plays meeting reminder once per new eligible
meeting (deduped by meetingId, survives 30s polling refetches).
- AR/EN translations added.
Pre-existing TS errors in executive-meetings.ts (font_settings) and
pre-existing test workflow failures are unrelated.
- DB: 7 new user prefs (sound per slot, per-type toggles, vibration, mute, volume).
- API: PATCH /auth/me/notification-preferences with whitelisted partial update,
integer guard for volume, and hydrated AuthUser response. AuthUser now exposes
the 7 new fields.
- Frontend: Web Audio synth library (8 sounds), settings popover with global
mute/vibration/volume/per-type toggles, slot tabs, sound preview buttons,
shift+click on bell for quick mute. Concurrency-safe optimistic updates with
monotonic seq counter. RTL-correct toggle knob transforms.
- Socket hook plays sound on notification_created (orders/meetings only) when
tab is hidden, respecting global mute and per-type toggles.
- Audio unlock hook mounted globally to satisfy autoplay restrictions.
- AR/EN translations added.
Pre-existing TS errors in executive-meetings.ts (font_settings) and pre-existing
test failures in test workflow are unrelated to this task.