Add reminder notification sound setting

This commit is contained in:
callumalpass 2026-05-18 08:43:48 +10:00
parent e079c9dd60
commit bc3e6cc454
8 changed files with 267 additions and 1 deletions

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -17,6 +17,8 @@ export class NotificationService {
private processedReminders: Set<string> = new Set(); // Track processed reminders to avoid duplicates
private taskUpdateListener?: EventRef;
private fileUpdateListener?: EventRef;
private activeAudioContexts: Set<AudioContext> = new Set();
private audioCleanupTimeouts: Set<number> = 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;

View file

@ -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,

View file

@ -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();
},
})
);
}
}
}
);

View file

@ -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;

View file

@ -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<Promise<void>, []>().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<typeof NotificationService>[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);
});
});