| 1 | // Run: tsx src/__tests__/sound.test.ts |
| 2 | |
| 3 | import { |
| 4 | DEFAULT_NOTIFICATION_VOLUME, |
| 5 | NOTIFICATION_VOLUME_STORAGE_KEY, |
| 6 | attentionChimeEventKey, |
| 7 | clearAttentionChimeKeys, |
| 8 | getNotificationVolume, |
| 9 | notificationWavGain, |
| 10 | notificationVolumeToGain, |
| 11 | normalizeNotificationVolume, |
| 12 | playAttentionChime, |
| 13 | playSuccessChime, |
| 14 | setAttentionPreference, |
| 15 | setNotificationVolume, |
| 16 | setSuccessPreference, |
| 17 | shouldPlayAttentionChimeForEvent, |
| 18 | } from "../lib/sound"; |
| 19 | |
| 20 | let passed = 0; |
| 21 | let failed = 0; |
| 22 | |
| 23 | function eq(a: unknown, b: unknown, label: string) { |
| 24 | if (a === b) { |
| 25 | process.stdout.write(` PASS ${label}\n`); |
| 26 | passed += 1; |
| 27 | } else { |
| 28 | process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); |
| 29 | failed += 1; |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | console.log("\nsound notifications"); |
| 34 | |
| 35 | const storedValues = new Map<string, string>(); |
| 36 | Object.defineProperty(globalThis, "localStorage", { |
| 37 | configurable: true, |
| 38 | value: { |
| 39 | getItem(key: string) { return storedValues.get(key) ?? null; }, |
| 40 | setItem(key: string, value: string) { storedValues.set(key, value); }, |
| 41 | removeItem(key: string) { storedValues.delete(key); }, |
| 42 | clear() { storedValues.clear(); }, |
| 43 | }, |
| 44 | }); |
| 45 | |
| 46 | { |
| 47 | eq(getNotificationVolume(), DEFAULT_NOTIFICATION_VOLUME, "legacy installs without a saved volume upgrade to the 70% default"); |
| 48 | eq(setNotificationVolume(85), 85, "notification volume saves a user-selected percentage"); |
| 49 | eq(storedValues.get(NOTIFICATION_VOLUME_STORAGE_KEY), "85", "notification volume persists in its dedicated storage key"); |
| 50 | eq(getNotificationVolume(), 85, "saved notification volume round-trips"); |
| 51 | eq(setNotificationVolume(140), 100, "notification volume clamps values above 100%"); |
| 52 | eq(setNotificationVolume(-10), 0, "notification volume clamps values below 0%"); |
| 53 | storedValues.set(NOTIFICATION_VOLUME_STORAGE_KEY, "not-a-number"); |
| 54 | eq(getNotificationVolume(), DEFAULT_NOTIFICATION_VOLUME, "invalid persisted volume recovers to the default"); |
| 55 | eq(normalizeNotificationVolume(72.6), 73, "notification volume normalizes fractional values to an integer percentage"); |
| 56 | eq(notificationVolumeToGain(70), 0.7, "notification volume converts percentages to Web Audio gain"); |
| 57 | const normalizedWavGains = [ |
| 58 | notificationWavGain("positive", 1), |
| 59 | notificationWavGain("correct", 1), |
| 60 | notificationWavGain("start", 1), |
| 61 | notificationWavGain("back", 1), |
| 62 | ]; |
| 63 | eq(normalizedWavGains.join(","), "0.62,0.85,1,0.7", "bundled WAVs use their measured loudness-normalization trims"); |
| 64 | eq(Math.max(...normalizedWavGains), 1, "WAV normalization never boosts a source above its recorded peak"); |
| 65 | eq(notificationWavGain("start", 0.7), 0.7, "the master volume scales a normalized WAV after its source trim"); |
| 66 | } |
| 67 | |
| 68 | { |
| 69 | const synthPeaks: number[] = []; |
| 70 | class FakeAudioContext { |
| 71 | destination = {}; |
| 72 | createOscillator() { |
| 73 | return { |
| 74 | type: "sine", |
| 75 | frequency: { setValueAtTime() {} }, |
| 76 | connect() {}, |
| 77 | start() {}, |
| 78 | stop() {}, |
| 79 | }; |
| 80 | } |
| 81 | createGain() { |
| 82 | return { |
| 83 | gain: { |
| 84 | setValueAtTime() {}, |
| 85 | linearRampToValueAtTime(value: number) { synthPeaks.push(value); }, |
| 86 | exponentialRampToValueAtTime() {}, |
| 87 | }, |
| 88 | connect() {}, |
| 89 | }; |
| 90 | } |
| 91 | close() {} |
| 92 | } |
| 93 | Object.defineProperty(globalThis, "AudioContext", { configurable: true, value: FakeAudioContext }); |
| 94 | const maxPeak = () => Math.round(Math.max(...synthPeaks) * 1000) / 1000; |
| 95 | |
| 96 | setSuccessPreference("synth"); |
| 97 | setAttentionPreference("synth"); |
| 98 | setNotificationVolume(DEFAULT_NOTIFICATION_VOLUME); |
| 99 | playSuccessChime(); |
| 100 | eq(maxPeak(), 0.245, "the 70% upgrade default raises the synthesized success peak above the legacy level"); |
| 101 | synthPeaks.length = 0; |
| 102 | playAttentionChime(); |
| 103 | eq(maxPeak(), 0.28, "the 70% upgrade default keeps attention at least as audible as success"); |
| 104 | synthPeaks.length = 0; |
| 105 | setNotificationVolume(0); |
| 106 | playSuccessChime(); |
| 107 | playAttentionChime(); |
| 108 | eq(synthPeaks.length, 0, "0% prevents synthesized notifications from creating audio nodes"); |
| 109 | } |
| 110 | |
| 111 | { |
| 112 | eq(attentionChimeEventKey({ kind: "approval_request", tabId: "tab-a", approval: { id: "approval-1" } }), "approval:tab-a:approval-1", "approval request builds a tab-scoped chime key"); |
| 113 | eq(attentionChimeEventKey({ kind: "ask_request", tabId: "tab-a", ask: { id: "ask-1" } }), "ask:tab-a:ask-1", "ask request builds a tab-scoped chime key"); |
| 114 | eq(attentionChimeEventKey({ kind: "approval_request", approval: { id: "approval-1" } }), "approval::approval-1", "legacy approval events without a tab still build a stable key"); |
| 115 | eq(attentionChimeEventKey({ kind: "turn_done" }), undefined, "non-attention events do not build chime keys"); |
| 116 | } |
| 117 | |
| 118 | { |
| 119 | const seen = new Set<string>(); |
| 120 | eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), true, "first approval event plays"); |
| 121 | eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), false, "replayed approval event for the same tab is deduped"); |
| 122 | eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-b", approval: { id: "1" } }, seen), true, "same approval id from another tab still plays"); |
| 123 | eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-a", ask: { id: "1" } }, seen), true, "ask id sharing an approval id still plays"); |
| 124 | eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request" }, seen), false, "malformed approval event does not play"); |
| 125 | } |
| 126 | |
| 127 | { |
| 128 | // The dedupe set stays bounded: after many unique prompts it self-prunes |
| 129 | // while still deduping recently seen ids. |
| 130 | const seen = new Set<string>(); |
| 131 | for (let i = 0; i < 2000; i++) { |
| 132 | shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "t", approval: { id: String(i) } }, seen); |
| 133 | } |
| 134 | eq(seen.size <= 1024, true, "dedupe set stays bounded after 2000 unique prompts"); |
| 135 | eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "t", approval: { id: "1999" } }, seen), false, "most recent prompt id is still deduped after pruning"); |
| 136 | } |
| 137 | |
| 138 | { |
| 139 | // Runtime rebuild clears dedupe keys so reissued ids chime again. |
| 140 | const seen = new Set<string>(); |
| 141 | shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen); |
| 142 | shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen); |
| 143 | clearAttentionChimeKeys(seen, "tab-a"); |
| 144 | eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), true, "rebuilt tab's reissued approval id chimes again"); |
| 145 | eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen), false, "other tab's keys survive a scoped clear"); |
| 146 | clearAttentionChimeKeys(seen); |
| 147 | eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen), true, "tab-less ready clears every key"); |
| 148 | } |
| 149 | |
| 150 | if (failed) { |
| 151 | console.error(`sound notifications: ${failed} failed, ${passed} passed`); |
| 152 | process.exit(1); |
| 153 | } |
| 154 | |
| 155 | console.log(`sound notifications: ${passed} passed`); |
| 156 |