From bc3e6cc454bacdd2a23a293da62d8b32483e47ac Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 18 May 2026 08:43:48 +1000 Subject: [PATCH] Add reminder notification sound setting --- docs/releases/unreleased.md | 1 + docs/settings/features.md | 2 +- src/i18n/resources/en.ts | 7 + src/services/NotificationService.ts | 72 ++++++++++ src/settings/defaults.ts | 2 + src/settings/tabs/featuresTab.ts | 59 +++++++++ src/types/settings.ts | 2 + .../issue-873-notification-sound.test.ts | 123 ++++++++++++++++++ 8 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 tests/unit/issues/issue-873-notification-sound.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 0d7422ea..c12f6ba5 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -75,6 +75,7 @@ Example: - ([#1655](https://github.com/callumalpass/tasknotes/issues/1655)) Added live elapsed time to the optional active time tracking status bar item. Thanks to @connradolisboa for suggesting this. - ([#1622](https://github.com/callumalpass/tasknotes/issues/1622)) Allowed multiple comma-separated default reminder offsets for timed Google Calendar task exports. Thanks to @solidabstract for suggesting this. - ([#706](https://github.com/callumalpass/tasknotes/issues/706), [#820](https://github.com/callumalpass/tasknotes/issues/820), [#1040](https://github.com/callumalpass/tasknotes/issues/1040)) Added direct Pomodoro duration editing, hour-aware timer formatting, clearer one-minute controls, and an optional active Pomodoro status bar countdown. Thanks to @SublunarSage, @lj1446615403-cloud, @0-BSCode, and @thestrike72 for the suggestions and feedback. +- ([#873](https://github.com/callumalpass/tasknotes/issues/873)) Added an optional built-in sound for task reminder notifications, with volume and preview controls in Settings. Thanks to @berzernberg for suggesting this. - Added a generated Pomodoro statistics Base with daily and monthly summary views for Pomodoro history stored in daily notes. ## Changed diff --git a/docs/settings/features.md b/docs/settings/features.md index 32f1f16e..f893838c 100644 --- a/docs/settings/features.md +++ b/docs/settings/features.md @@ -25,7 +25,7 @@ Pomodoro settings control interval lengths, long-break cadence, optional auto-st ## Notifications -Use this section to enable reminders globally and choose whether notices are shown in-app or through system notifications. +Use this section to enable reminders globally, choose whether notices are shown in-app or through system notifications, and optionally play a built-in reminder sound with its own volume control. ## Performance & Behavior diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index ab3c5d70..620fabfe 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -637,6 +637,13 @@ export const en: TranslationTree = { typeDesc: "Type of notifications to show", systemLabel: "System notifications", inAppLabel: "In-app notifications", + soundEnabledName: "Notification sound", + soundEnabledDesc: "Play a sound when task reminders trigger", + soundVolumeName: "Sound volume", + soundVolumeDesc: "Volume for task reminder sounds (0-100)", + soundPreviewName: "Preview notification sound", + soundPreviewDesc: "Play the configured task reminder sound", + soundPreviewButton: "Preview", }, overdue: { hideCompletedName: "Hide completed tasks from overdue", diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index 70700f3b..71950554 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -17,6 +17,8 @@ export class NotificationService { private processedReminders: Set = new Set(); // Track processed reminders to avoid duplicates private taskUpdateListener?: EventRef; private fileUpdateListener?: EventRef; + private activeAudioContexts: Set = new Set(); + private audioCleanupTimeouts: Set = new Set(); private lastBroadScanTime: number = Date.now(); private lastQuickCheckTime: number = Date.now(); @@ -66,6 +68,16 @@ export class NotificationService { if (this.fileUpdateListener) { this.plugin.emitter.offref(this.fileUpdateListener); } + for (const timeout of this.audioCleanupTimeouts) { + window.clearTimeout(timeout); + } + this.audioCleanupTimeouts.clear(); + for (const audioContext of this.activeAudioContexts) { + if (audioContext.state !== "closed") { + audioContext.close().catch(() => {}); + } + } + this.activeAudioContexts.clear(); this.notificationQueue = []; this.processedReminders.clear(); } @@ -259,6 +271,8 @@ export class NotificationService { const message = item.reminder.description || this.generateDefaultMessage(task, item.reminder); + this.playNotificationSound(); + if (this.plugin.settings.notificationType === "system") { // System notification if ("Notification" in window && Notification.permission === "granted") { @@ -293,6 +307,64 @@ export class NotificationService { } } + playNotificationSound(): void { + if (!this.plugin.settings.notificationSoundEnabled) { + return; + } + + const AudioContextConstructor = + window.AudioContext || + (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + + if (!AudioContextConstructor) { + return; + } + + try { + const audioContext = new AudioContextConstructor(); + const gainNode = audioContext.createGain(); + const volume = Math.max( + 0, + Math.min(1, this.plugin.settings.notificationSoundVolume / 100) + ); + + gainNode.gain.value = volume * 0.3; + gainNode.connect(audioContext.destination); + + const playTone = (frequency: number, durationSeconds: number) => { + const oscillator = audioContext.createOscillator(); + oscillator.connect(gainNode); + oscillator.frequency.value = frequency; + oscillator.type = "sine"; + oscillator.start(); + oscillator.stop(audioContext.currentTime + durationSeconds); + }; + + playTone(880, 0.12); + + this.activeAudioContexts.add(audioContext); + + const secondToneTimeout = window.setTimeout(() => { + try { + playTone(1175, 0.12); + } catch (error) { + console.error("Failed to play notification sound tone:", error); + } + }, 140); + this.audioCleanupTimeouts.add(secondToneTimeout); + + const cleanupTimeout = window.setTimeout(() => { + this.activeAudioContexts.delete(audioContext); + this.audioCleanupTimeouts.delete(secondToneTimeout); + this.audioCleanupTimeouts.delete(cleanupTimeout); + audioContext.close().catch(() => {}); + }, 320); + this.audioCleanupTimeouts.add(cleanupTimeout); + } catch (error) { + console.error("Failed to play notification sound:", error); + } + } + private showInAppNotice(message: string, taskPath: string): void { const notice = new Notice(message, 0); // 0 = persistent until clicked const noticeEl = (notice as unknown as { noticeEl: HTMLElement }).noticeEl; diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index ab958f4d..acc92b9e 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -362,6 +362,8 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { // Notification defaults enableNotifications: true, notificationType: "system", + notificationSoundEnabled: false, + notificationSoundVolume: 50, // HTTP API defaults enableAPI: false, apiPort: 8080, diff --git a/src/settings/tabs/featuresTab.ts b/src/settings/tabs/featuresTab.ts index 4873dbed..ec36276d 100644 --- a/src/settings/tabs/featuresTab.ts +++ b/src/settings/tabs/featuresTab.ts @@ -6,6 +6,7 @@ import { configureToggleSetting, configureDropdownSetting, configureNumberSetting, + configureButtonSetting, } from "../components/settingHelpers"; import { showStorageLocationConfirmationModal } from "../../modals/StorageLocationConfirmationModal"; import { getAvailableLanguages } from "../../locales"; @@ -567,6 +568,64 @@ export function renderFeaturesTab( }, }) ); + + group.addSetting( + (setting) => + void configureToggleSetting(setting, { + name: translate( + "settings.features.notifications.soundEnabledName" + ), + desc: translate( + "settings.features.notifications.soundEnabledDesc" + ), + getValue: () => plugin.settings.notificationSoundEnabled, + setValue: async (value: boolean) => { + plugin.settings.notificationSoundEnabled = value; + save(); + renderFeaturesTab(container, plugin, save); + }, + }) + ); + + if (plugin.settings.notificationSoundEnabled) { + group.addSetting( + (setting) => + void configureNumberSetting(setting, { + name: translate( + "settings.features.notifications.soundVolumeName" + ), + desc: translate( + "settings.features.notifications.soundVolumeDesc" + ), + placeholder: "50", + min: 0, + max: 100, + getValue: () => plugin.settings.notificationSoundVolume, + setValue: async (value: number) => { + plugin.settings.notificationSoundVolume = value; + save(); + }, + }) + ); + + group.addSetting( + (setting) => + void configureButtonSetting(setting, { + name: translate( + "settings.features.notifications.soundPreviewName" + ), + desc: translate( + "settings.features.notifications.soundPreviewDesc" + ), + buttonText: translate( + "settings.features.notifications.soundPreviewButton" + ), + onClick: () => { + plugin.notificationService?.playNotificationSound(); + }, + }) + ); + } } } ); diff --git a/src/types/settings.ts b/src/types/settings.ts index b84b7d01..e0720d17 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -194,6 +194,8 @@ export interface TaskNotesSettings { // Notification settings enableNotifications: boolean; notificationType: "in-app" | "system"; + notificationSoundEnabled: boolean; + notificationSoundVolume: number; // 0-100 // HTTP API settings enableAPI: boolean; apiPort: number; diff --git a/tests/unit/issues/issue-873-notification-sound.test.ts b/tests/unit/issues/issue-873-notification-sound.test.ts new file mode 100644 index 00000000..6efbc95d --- /dev/null +++ b/tests/unit/issues/issue-873-notification-sound.test.ts @@ -0,0 +1,123 @@ +import { NotificationService } from "../../../src/services/NotificationService"; + +class MockOscillator { + frequency = { value: 0 }; + type: OscillatorType = "sine"; + connect = jest.fn(); + start = jest.fn(); + stop = jest.fn(); +} + +class MockGain { + gain = { value: 0 }; + connect = jest.fn(); +} + +class MockAudioContext { + currentTime = 10; + destination = {}; + state: AudioContextState = "running"; + oscillators: MockOscillator[] = []; + gains: MockGain[] = []; + close = jest.fn, []>().mockResolvedValue(undefined); + + createOscillator(): OscillatorNode { + const oscillator = new MockOscillator(); + this.oscillators.push(oscillator); + return oscillator as unknown as OscillatorNode; + } + + createGain(): GainNode { + const gain = new MockGain(); + this.gains.push(gain); + return gain as unknown as GainNode; + } +} + +describe("Issue #873: notification reminder sound", () => { + let originalAudioContext: typeof window.AudioContext | undefined; + let contexts: MockAudioContext[]; + + beforeEach(() => { + jest.useFakeTimers(); + originalAudioContext = window.AudioContext; + contexts = []; + + const AudioContextMock = jest.fn(() => { + const context = new MockAudioContext(); + contexts.push(context); + return context; + }); + + Object.defineProperty(window, "AudioContext", { + configurable: true, + value: AudioContextMock as unknown as typeof AudioContext, + }); + }); + + afterEach(() => { + Object.defineProperty(window, "AudioContext", { + configurable: true, + value: originalAudioContext, + }); + jest.useRealTimers(); + }); + + function createService(settings: { + notificationSoundEnabled: boolean; + notificationSoundVolume: number; + }): NotificationService { + return new NotificationService({ + settings, + } as unknown as ConstructorParameters[0]); + } + + it("plays a two-tone reminder sound using the configured volume", () => { + const service = createService({ + notificationSoundEnabled: true, + notificationSoundVolume: 50, + }); + + service.playNotificationSound(); + + expect(contexts).toHaveLength(1); + expect(contexts[0].gains[0].gain.value).toBeCloseTo(0.15); + expect(contexts[0].oscillators[0].frequency.value).toBe(880); + expect(contexts[0].oscillators[0].stop).toHaveBeenCalledWith(10.12); + + jest.advanceTimersByTime(140); + + expect(contexts[0].oscillators).toHaveLength(2); + expect(contexts[0].oscillators[1].frequency.value).toBe(1175); + expect(contexts[0].oscillators[1].stop).toHaveBeenCalledWith(10.12); + + jest.advanceTimersByTime(180); + + expect(contexts[0].close).toHaveBeenCalledTimes(1); + }); + + it("does not create audio when notification sounds are disabled", () => { + const service = createService({ + notificationSoundEnabled: false, + notificationSoundVolume: 50, + }); + + service.playNotificationSound(); + + expect(contexts).toHaveLength(0); + }); + + it("cleans up active notification audio when the service is destroyed", () => { + const service = createService({ + notificationSoundEnabled: true, + notificationSoundVolume: 100, + }); + + service.playNotificationSound(); + service.destroy(); + + expect(contexts[0].close).toHaveBeenCalledTimes(1); + jest.runOnlyPendingTimers(); + expect(contexts[0].close).toHaveBeenCalledTimes(1); + }); +});