From 5d646add36dffb3b959fd0520e68ad9f6861fd52 Mon Sep 17 00:00:00 2001 From: olegberman Date: Tue, 28 Jul 2026 14:53:25 -0400 Subject: [PATCH] Fix iPhone notification audio --- CHANGELOG.md | 2 + app/lib/notification-sounds.ts | 119 +++++++++++++++++----- package.json | 1 - pnpm-lock.yaml | 18 ---- public/sounds/incoming-email.wav | Bin 0 -> 12616 bytes public/sounds/outgoing-email.wav | Bin 0 -> 7982 bytes public/sounds/toast-error.wav | Bin 0 -> 7542 bytes public/sounds/toast-information.wav | Bin 0 -> 4454 bytes public/sounds/toast-success.wav | Bin 0 -> 10188 bytes public/sounds/toast-warning.wav | Bin 0 -> 7766 bytes public/sounds/unlock.wav | Bin 0 -> 1368 bytes scripts/build-pwa.mjs | 1 + scripts/generate-notification-sounds.mjs | 82 +++++++++++++++ scripts/test-pwa.mjs | 2 + test/unit/app/notification-sounds.test.ts | 71 +++++++------ test/unit/scripts/pwa-build.test.mjs | 1 + 16 files changed, 222 insertions(+), 75 deletions(-) create mode 100644 public/sounds/incoming-email.wav create mode 100644 public/sounds/outgoing-email.wav create mode 100644 public/sounds/toast-error.wav create mode 100644 public/sounds/toast-information.wav create mode 100644 public/sounds/toast-success.wav create mode 100644 public/sounds/toast-warning.wav create mode 100644 public/sounds/unlock.wav create mode 100644 scripts/generate-notification-sounds.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a84f79e..f158065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Include the selected message as quoted HTML and plain text in replies so email clients can collapse and expand the previous content. +- Restore notification sounds on iPhone by unlocking a local audio player during the first + interaction and reusing it for mail and toast sounds. ## 0.1.15 diff --git a/app/lib/notification-sounds.ts b/app/lib/notification-sounds.ts index 28d58c3..7680897 100644 --- a/app/lib/notification-sounds.ts +++ b/app/lib/notification-sounds.ts @@ -1,5 +1,3 @@ -import { tiks } from "@rexa-developer/tiks"; - export type NotificationSound = | "incoming-email" | "outgoing-email" @@ -18,7 +16,21 @@ export type ToastSoundType = | "success" | "warning"; +const SOUND_VOLUME = 0.55; +const UNLOCK_SOURCE = "/sounds/unlock.wav"; +const UNLOCK_EVENTS = ["touchend", "click", "keydown"] as const; +const SOUND_SOURCES: Record = { + "incoming-email": "/sounds/incoming-email.wav", + "outgoing-email": "/sounds/outgoing-email.wav", + "toast-error": "/sounds/toast-error.wav", + "toast-information": "/sounds/toast-information.wav", + "toast-success": "/sounds/toast-success.wav", + "toast-warning": "/sounds/toast-warning.wav" +}; + let initialized = false; +let unlockListenersBound = false; +let player: HTMLAudioElement | null = null; export function notificationSoundForToastType( type: ToastSoundType | undefined @@ -40,37 +52,90 @@ export function notificationSoundForToast( } export function initializeNotificationSounds(): void { - try { - if (!initialized) { - tiks.init({ - theme: "soft", - volume: 0.18, - respectReducedMotion: true - }); - initialized = true; - } - } catch { - // Audible feedback must never interrupt application startup. - } + if (initialized || typeof document === "undefined" || typeof window === "undefined") return; + initialized = true; + bindUnlockListeners(); + + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") resetPlayer(); + }); + window.addEventListener("pageshow", resetPlayer); } export function playNotificationSound(sound: NotificationSound): void { try { initializeNotificationSounds(); - if (sound === "incoming-email") { - tiks.notify(); - } else if (sound === "outgoing-email") { - tiks.swoosh(); - } else if (sound === "toast-success") { - tiks.success(); - } else if (sound === "toast-warning") { - tiks.warning(); - } else if (sound === "toast-error") { - tiks.error(); - } else { - tiks.pop(); - } + if (!player || prefersReducedMotion()) return; + + player.pause(); + player.src = SOUND_SOURCES[sound]; + player.currentTime = 0; + player.volume = SOUND_VOLUME; + void player.play().catch(() => { + // The next explicit interaction can unlock playback again. + bindUnlockListeners(); + }); } catch { // Audible feedback must never interrupt the underlying action. } } + +function bindUnlockListeners(): void { + if (unlockListenersBound || typeof document === "undefined") return; + unlockListenersBound = true; + for (const event of UNLOCK_EVENTS) { + document.addEventListener(event, unlockPlayer, { capture: true, passive: true }); + } +} + +function unbindUnlockListeners(): void { + if (!unlockListenersBound || typeof document === "undefined") return; + unlockListenersBound = false; + for (const event of UNLOCK_EVENTS) { + document.removeEventListener(event, unlockPlayer, true); + } +} + +function unlockPlayer(): void { + try { + if (typeof Audio === "undefined") return; + player ??= new Audio(UNLOCK_SOURCE); + player.preload = "auto"; + player.src = UNLOCK_SOURCE; + player.currentTime = 0; + + void player.play().then( + () => { + player?.pause(); + if (player) { + player.currentTime = 0; + player.volume = SOUND_VOLUME; + } + unbindUnlockListeners(); + }, + () => { + // Keep listeners attached so the next interaction can retry. + } + ); + } catch { + // Keep listeners attached so the next interaction can retry. + } +} + +function resetPlayer(): void { + try { + player?.pause(); + } catch { + // Resetting audio must never interrupt returning to the application. + } + player = null; + bindUnlockListeners(); +} + +function prefersReducedMotion(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} diff --git a/package.json b/package.json index 62eaa45..ac23702 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,6 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", - "@rexa-developer/tiks": "^0.3.0", "@tiptap/extension-link": "^3.27.3", "@tiptap/extension-placeholder": "^3.27.3", "@tiptap/react": "^3.27.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 643df97..a8ae33d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,9 +56,6 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.7 version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@rexa-developer/tiks': - specifier: ^0.3.0 - version: 0.3.0(react@19.2.7) '@tiptap/extension-link': specifier: ^3.27.3 version: 3.27.3(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3) @@ -1355,17 +1352,6 @@ packages: '@radix-ui/rect@1.1.2': resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} - '@rexa-developer/tiks@0.3.0': - resolution: {integrity: sha512-7tW/lb53Nr8U0dn9ZlWOvDXtsZ7fK3dZN90Xvp/S4AkjruopuP8992+3KhFCrIQ7bXFEsaVFUZsm/+NC3ufA1w==} - peerDependencies: - react: '>=18.0.0' - vue: '>=3.0.0' - peerDependenciesMeta: - react: - optional: true - vue: - optional: true - '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -4103,10 +4089,6 @@ snapshots: '@radix-ui/rect@1.1.2': {} - '@rexa-developer/tiks@0.3.0(react@19.2.7)': - optionalDependencies: - react: 19.2.7 - '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.62.2': diff --git a/public/sounds/incoming-email.wav b/public/sounds/incoming-email.wav new file mode 100644 index 0000000000000000000000000000000000000000..e49bebd6141bb25fb163ca093604cfdf619cc503 GIT binary patch literal 12616 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 a%N)wsGEJa7XeRUnGze-2DZw{!2>L&BVQn4& literal 0 HcmV?d00001 diff --git a/public/sounds/toast-error.wav b/public/sounds/toast-error.wav new file mode 100644 index 0000000000000000000000000000000000000000..3c924a5c5198502c10b0d395e211605bc80218d1 GIT binary patch literal 7542 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 literal 0 HcmV?d00001 diff --git a/public/sounds/toast-warning.wav b/public/sounds/toast-warning.wav new file mode 100644 index 0000000000000000000000000000000000000000..1c5a14059ead90e27607cb3b4cbfae985bed4d0c GIT binary patch literal 7766 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 E52E@W-v9sr literal 0 HcmV?d00001 diff --git a/public/sounds/unlock.wav b/public/sounds/unlock.wav new file mode 100644 index 0000000000000000000000000000000000000000..ba30b2c8c360cb1e050e02483a4b6caeec5f5af8 GIT binary patch literal 1368 zcmWIYbaM+}Wnc(*40BD(Em06)U|?VbLZvVU2A4Gq3``6H3@M2vi8??vqhK@yMnhmU L1V%%Ea0mbZCvOLC literal 0 HcmV?d00001 diff --git a/scripts/build-pwa.mjs b/scripts/build-pwa.mjs index df82734..8be6164 100644 --- a/scripts/build-pwa.mjs +++ b/scripts/build-pwa.mjs @@ -6,6 +6,7 @@ const allowedPrecachePaths = [ /^\/assets\//, /^\/fonts\//, /^\/icons\//, + /^\/sounds\//, /^\/logo\.svg$/, /^\/manifest\.webmanifest$/, /^\/offline\.html$/ diff --git a/scripts/generate-notification-sounds.mjs b/scripts/generate-notification-sounds.mjs new file mode 100644 index 0000000..36f8de6 --- /dev/null +++ b/scripts/generate-notification-sounds.mjs @@ -0,0 +1,82 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const sampleRate = 22_050; +const outputDirectory = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../public/sounds" +); + +const sounds = { + "incoming-email": [ + { from: 660, to: 660, duration: 90 }, + { gap: 45 }, + { from: 880, to: 880, duration: 150 } + ], + "outgoing-email": [{ from: 420, to: 920, duration: 180 }], + "toast-error": [{ from: 260, to: 170, duration: 170 }], + "toast-information": [{ from: 700, to: 700, duration: 100 }], + "toast-success": [ + { from: 520, to: 520, duration: 75 }, + { gap: 35 }, + { from: 780, to: 780, duration: 120 } + ], + "toast-warning": [ + { from: 440, to: 440, duration: 65 }, + { gap: 45 }, + { from: 440, to: 440, duration: 65 } + ], + unlock: [{ gap: 30 }] +}; + +function renderPcm(segments) { + const samples = []; + for (const segment of segments) { + const sampleCount = Math.ceil(((segment.duration ?? segment.gap ?? 0) / 1000) * sampleRate); + for (let index = 0; index < sampleCount; index += 1) { + if ("gap" in segment) { + samples.push(0); + continue; + } + + const progress = index / Math.max(1, sampleCount - 1); + const frequency = segment.from + (segment.to - segment.from) * progress; + const seconds = index / sampleRate; + const fade = Math.min(1, progress * 14, (1 - progress) * 10); + samples.push(Math.sin(2 * Math.PI * frequency * seconds) * fade * 0.24); + } + } + return samples; +} + +function encodeWav(samples) { + const bytesPerSample = 2; + const buffer = Buffer.alloc(44 + samples.length * bytesPerSample); + buffer.write("RIFF", 0); + buffer.writeUInt32LE(buffer.length - 8, 4); + buffer.write("WAVE", 8); + buffer.write("fmt ", 12); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(1, 22); + buffer.writeUInt32LE(sampleRate, 24); + buffer.writeUInt32LE(sampleRate * bytesPerSample, 28); + buffer.writeUInt16LE(bytesPerSample, 32); + buffer.writeUInt16LE(16, 34); + buffer.write("data", 36); + buffer.writeUInt32LE(samples.length * bytesPerSample, 40); + + for (let index = 0; index < samples.length; index += 1) { + const value = Math.max(-1, Math.min(1, samples[index])); + buffer.writeInt16LE(Math.round(value * 32_767), 44 + index * bytesPerSample); + } + return buffer; +} + +await mkdir(outputDirectory, { recursive: true }); +await Promise.all( + Object.entries(sounds).map(([name, segments]) => + writeFile(path.join(outputDirectory, `${name}.wav`), encodeWav(renderPcm(segments))) + ) +); diff --git a/scripts/test-pwa.mjs b/scripts/test-pwa.mjs index 583175b..3103453 100644 --- a/scripts/test-pwa.mjs +++ b/scripts/test-pwa.mjs @@ -55,6 +55,8 @@ try { }); assert.ok(cacheState.names.some((name) => name.startsWith("hqbase-pwa-"))); assert.ok(cacheState.urls.includes("/offline.html")); + assert.ok(cacheState.urls.includes("/sounds/incoming-email.wav")); + assert.ok(cacheState.urls.includes("/sounds/unlock.wav")); assert.equal( cacheState.urls.some((url) => url.startsWith("/api/")), false diff --git a/test/unit/app/notification-sounds.test.ts b/test/unit/app/notification-sounds.test.ts index 27c8439..2a88685 100644 --- a/test/unit/app/notification-sounds.test.ts +++ b/test/unit/app/notification-sounds.test.ts @@ -1,16 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -const tiks = vi.hoisted(() => ({ - error: vi.fn(), - init: vi.fn(), - notify: vi.fn(), - pop: vi.fn(), - success: vi.fn(), - swoosh: vi.fn(), - warning: vi.fn() -})); - -vi.mock("@rexa-developer/tiks", () => ({ tiks })); +const audioInstances: MockAudio[] = []; import { initializeNotificationSounds, @@ -20,7 +10,15 @@ import { } from "@/lib/notification-sounds"; describe("notification sounds", () => { - it("maps mail and toast events to quiet, non-blocking synthesized sounds", () => { + it("maps events and plays local audio after an explicit interaction", async () => { + const documentTarget = Object.assign(new EventTarget(), { visibilityState: "visible" }); + const windowTarget = Object.assign(new EventTarget(), { + matchMedia: vi.fn(() => ({ matches: false })) + }); + vi.stubGlobal("document", documentTarget); + vi.stubGlobal("window", windowTarget); + vi.stubGlobal("Audio", MockAudio); + expect(notificationSoundForToastType("success")).toBe("toast-success"); expect(notificationSoundForToastType("warning")).toBe("toast-warning"); expect(notificationSoundForToastType("error")).toBe("toast-error"); @@ -31,29 +29,44 @@ describe("notification sounds", () => { initializeNotificationSounds(); initializeNotificationSounds(); + expect(audioInstances).toHaveLength(0); + + documentTarget.dispatchEvent(new Event("touchend")); + await Promise.resolve(); + expect(audioInstances).toHaveLength(1); + const audio = audioInstances[0]; + if (!audio) throw new Error("Expected the interaction to create an audio player."); + expect(audio.src).toBe("/sounds/unlock.wav"); + expect(audio.play).toHaveBeenCalledOnce(); + playNotificationSound("incoming-email"); + expect(audio.src).toBe("/sounds/incoming-email.wav"); playNotificationSound("outgoing-email"); + expect(audio.src).toBe("/sounds/outgoing-email.wav"); playNotificationSound("toast-success"); + expect(audio.src).toBe("/sounds/toast-success.wav"); playNotificationSound("toast-warning"); + expect(audio.src).toBe("/sounds/toast-warning.wav"); playNotificationSound("toast-error"); + expect(audio.src).toBe("/sounds/toast-error.wav"); playNotificationSound("toast-information"); + expect(audio.src).toBe("/sounds/toast-information.wav"); - expect(tiks.init).toHaveBeenCalledOnce(); - expect(tiks.init).toHaveBeenCalledWith({ - theme: "soft", - volume: 0.18, - respectReducedMotion: true - }); - expect(tiks.notify).toHaveBeenCalledOnce(); - expect(tiks.swoosh).toHaveBeenCalledOnce(); - expect(tiks.success).toHaveBeenCalledOnce(); - expect(tiks.warning).toHaveBeenCalledOnce(); - expect(tiks.error).toHaveBeenCalledOnce(); - expect(tiks.pop).toHaveBeenCalledOnce(); - - tiks.error.mockImplementationOnce(() => { - throw new Error("Audio unavailable"); - }); - expect(() => playNotificationSound("toast-error")).not.toThrow(); + expect(audio.play).toHaveBeenCalledTimes(7); + expect(audio.volume).toBe(0.55); }); }); + +class MockAudio { + currentTime = 0; + pause = vi.fn(); + play = vi.fn(() => Promise.resolve()); + preload = ""; + src: string; + volume = 1; + + constructor(src = "") { + this.src = src; + audioInstances.push(this); + } +} diff --git a/test/unit/scripts/pwa-build.test.mjs b/test/unit/scripts/pwa-build.test.mjs index f869919..6ac0ba0 100644 --- a/test/unit/scripts/pwa-build.test.mjs +++ b/test/unit/scripts/pwa-build.test.mjs @@ -30,6 +30,7 @@ describe("PWA build contract", () => { it("allows only public shell assets into the precache", () => { expect(isAllowedPrecacheUrl("/assets/app-abc.js")).toBe(true); expect(isAllowedPrecacheUrl("/offline.html")).toBe(true); + expect(isAllowedPrecacheUrl("/sounds/incoming-email.wav")).toBe(true); expect(isAllowedPrecacheUrl("/api/me")).toBe(false); expect(isAllowedPrecacheUrl("/api/messages/123/attachment")).toBe(false); expect(isAllowedPrecacheUrl("/setup")).toBe(false);