Skip to content

Commit 544428f

Browse files
committed
feat: selectable sound themes with a preview picker
## Summary Selectable sound themes. Each theme is a complete set covering every moment, so switching never leaves a gap by accident. | Moment | Classic (default) | Glass | Wood | Singing bowl | Soft | Silent | | --- | --- | --- | --- | --- | --- | --- | | Start focus | E5→A5 chime | E6 ping | C–E–G marimba | C4 bowl | A5 blip | — | | Finish focus | G5–E5–C5 bell | A6–E6–A5 pings | G–E–C–G…C phrase | two G3 strikes | 3 blips | — | | Start break | chime | falling pings | falling E→C | low F3 strike | falling blips | — | | Back to work | bell | rising pings | rising C–E–G–C | rising A3→D4 | rising blips | — | | Pause / resume | — / chime | quiet low / high ping | soft G4 / C5 | — | tiny blips | — | | Button tap | — | 3.2 kHz tick | woodblock | — | — | — | Design rules applied: - **Start and finish are easy to tell apart without looking**; finish is the most noticeable sound in every theme, since it pulls you back after 25 minutes. - **Breaks get their own direction**: falling pitch to relax, rising pitch for back to work. - **Soft attacks and a gain ceiling of 0.15**, so nothing startles mid-focus; "Soft" stays at about a third of that for shared spaces. - **Taps are near-subliminal**, play on any button or link, and are skipped on timer buttons that already make their own sound (`data-own-sound`). - **Classic reproduces today's sounds exactly**, including silent pause and taps, so nothing changes for existing users. - All sounds are synthesized with Web Audio: no audio files, nothing to license. **Menu → Settings → Sounds** opens a picker styled like the shortcuts panel: a radio card per theme with a one-line character description and a preview chip for every moment (moments a theme keeps silent are struck through). Choosing a theme plays its start sound; the choice is stored in `localStorage`. ## Testing - Lint, typecheck, unit tests and build pass. - Instrumented `OscillatorNode.start` in the browser with Wood selected: a nav button plays the woodblock tap; Start Focus plays only the C–E–G phrase (no extra tap); Pause plays G4; Resume plays C5; the session end plays the finish phrase. - Picker renders all six themes, previews play, and the selection persists.
1 parent 7849386 commit 544428f

6 files changed

Lines changed: 364 additions & 42 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Runs as a web app or as a browser extension, and keeps every bit of data in your
2020

2121
- **Focus timer** with pause, resume, breaks, extend-on-complete and goal history (press <kbd>↑</kbd>/<kbd>↓</kbd> in the goal field).
2222
- **Notifications** when a focus session or break runs out. The app asks for permission with Chrome's own prompt the first time you open it; **Menu → Settings → Notifications** shows the current state and asks again if you dismissed the prompt. If nothing appears, allow notifications for your browser in your system settings and turn off Do Not Disturb / Focus.
23+
- **Sound themes**: Classic (default), Glass, Wood, Singing bowl, Soft or Silent, each covering start, finish, break, back to work, pause, resume and button taps. Preview and pick them in **Menu → Settings → Sounds**. All sounds are synthesized in the browser.
2324
- **Pixel-art progress animations** — a coffee cup that fills, a battery that charges, a rocket that lifts off — plus an auto-shuffle option.
2425
- **To-dos** with time estimates, notes, drag-and-drop ordering, a backlog and archive; start a focus session straight from a to-do.
2526
- **Habits** with 7- and 30-day grids and notes per day.

‎src/components/focus/FocusTimer.tsx‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { ANIMATIONS, DEFAULT_ANIMATION, currentFrame, isAnimationType, metaOf, t
1010
import { GoalForm, hotkeyHint } from "./GoalForm";
1111
import { PixelArt } from "./PixelArt";
1212
import { notify } from "./notifications";
13-
import { playSound } from "./sounds";
13+
import { OWN_SOUND_ATTRIBUTE, playSound } from "./sounds";
1414
import { useDialog } from "../dialogs/DialogProvider";
1515

1616
const DEFAULT_TITLE = "Boost Focus Timer";
@@ -121,7 +121,7 @@ function TimerButton({
121121
children: ReactNode;
122122
}) {
123123
return (
124-
<button onClick={onClick} className={className} title={title}>
124+
<button onClick={onClick} className={className} title={title} {...{ [OWN_SOUND_ATTRIBUTE]: "" }}>
125125
{children}
126126
</button>
127127
);
@@ -293,7 +293,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
293293
titleSuffix: goal || "Focus",
294294
onTick: (minutes, seconds) => setFocus((current) => ({ ...current, minutes, seconds })),
295295
onComplete: () => {
296-
playSound("end");
296+
playSound("focusEnd");
297297
notify("Focus session complete", `${goal.trim() || "Nice work"} · time for a break.`);
298298
setFocusStart(null);
299299
setFocus({ ...IDLE, isCompleted: true });
@@ -323,7 +323,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
323323
titleSuffix: "Break",
324324
onTick: (minutes, seconds) => setBreakTimer((current) => ({ ...current, minutes, seconds })),
325325
onComplete: () => {
326-
playSound("end");
326+
playSound("breakEnd");
327327
notify("Break is over", "Ready for the next focus session?");
328328
void completeBreakSession();
329329
finishBreak();
@@ -338,7 +338,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
338338
try {
339339
const created = await databaseService.createFocusSession({ goal, planned_duration_minutes: minutes, session_type: "focus" });
340340
setSession(created);
341-
playSound("start");
341+
playSound("focusStart");
342342
if (goal.trim()) setGoalHistory((history) => [goal, ...history]);
343343
setFocusStart(Date.now());
344344
setFocusTotalMs(minutes * MINUTE_MS);
@@ -355,6 +355,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
355355
};
356356

357357
const pauseFocus = async () => {
358+
playSound("pause");
358359
if (focusStart === null) return setFocus((current) => ({ ...current, isRunning: false }));
359360
const remaining = Math.max(0, focusTotalMs - (Date.now() - focusStart));
360361
setFocus((current) => ({ ...current, ...splitMs(remaining), isRunning: false }));
@@ -366,15 +367,15 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
366367
};
367368

368369
const resumeFocus = async () => {
369-
playSound("start");
370+
playSound("resume");
370371
await endPause(pauseId, setPauseId);
371372
sendHeartbeat(session);
372373
setFocusStart(Date.now() - (focusTotalMs - remainingMsOf(focus)));
373374
setFocus((current) => ({ ...current, isRunning: true }));
374375
};
375376

376377
const completeEarly = async () => {
377-
playSound("end");
378+
playSound("focusEnd");
378379
document.title = DEFAULT_TITLE;
379380
setFocusStart(null);
380381
await endPause(pauseId, setPauseId);
@@ -406,7 +407,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
406407
.createFocusSession({ goal: "Break time", planned_duration_minutes: minutes, session_type: "break" })
407408
.catch(logError("Failed to start break session:"));
408409
if (created) setBreakSession(created);
409-
playSound("start");
410+
playSound("breakStart");
410411
setBreakStart(Date.now());
411412
setBreakTotalMs(minutes * MINUTE_MS);
412413
setBreakTimer({ minutes, seconds: 0, isRunning: true, isCompleted: false });
@@ -415,6 +416,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
415416
};
416417

417418
const pauseBreak = async () => {
419+
playSound("pause");
418420
if (breakStart === null) return setBreakTimer((current) => ({ ...current, isRunning: false }));
419421
const remaining = Math.max(0, breakTotalMs - (Date.now() - breakStart));
420422
setBreakTimer((current) => ({ ...current, ...splitMs(remaining), isRunning: false }));
@@ -426,14 +428,14 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
426428
};
427429

428430
const resumeBreak = async () => {
429-
playSound("start");
431+
playSound("resume");
430432
await endPause(breakPauseId, setBreakPauseId);
431433
setBreakStart(Date.now() - (breakTotalMs - remainingMsOf(breakTimer)));
432434
setBreakTimer((current) => ({ ...current, isRunning: true }));
433435
};
434436

435437
const endBreak = async () => {
436-
playSound("end");
438+
playSound("breakEnd");
437439
document.title = DEFAULT_TITLE;
438440
await completeBreakSession();
439441
finishBreak();

‎src/components/focus/sounds.ts‎

Lines changed: 202 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,169 @@
1-
export type SoundName = "start" | "end";
1+
/*
2+
* Sound themes, all synthesized with Web Audio (no audio files).
3+
* Every theme covers the same moments so switching themes never leaves a moment unexpectedly silent,
4+
* except where a theme deliberately stays quiet (null).
5+
*/
6+
7+
export type SoundEvent = "tap" | "focusStart" | "focusEnd" | "breakStart" | "breakEnd" | "pause" | "resume";
8+
9+
interface Overtone {
10+
/** Frequency multiple of the note. */
11+
readonly ratio: number;
12+
/** Volume relative to the note. */
13+
readonly volume: number;
14+
/** Duration relative to the note. */
15+
readonly length: number;
16+
}
217

318
interface Note {
419
readonly frequency: number;
20+
/** Seconds after the sound starts. */
521
readonly delay: number;
22+
/** Seconds until the note has faded out. */
623
readonly duration: number;
24+
/** Peak gain; kept at or below 0.15 so no theme startles. */
725
readonly volume: number;
26+
readonly wave?: OscillatorType;
27+
readonly attack?: number;
28+
readonly overtones?: readonly Overtone[];
829
}
930

10-
const START_CHIME: readonly Note[] = [
11-
{ frequency: 659.25, delay: 0, duration: 0.35, volume: 0.12 },
12-
{ frequency: 880, delay: 0.12, duration: 0.5, volume: 0.12 },
31+
export type SoundThemeId = "classic" | "glass" | "wood" | "bowl" | "soft" | "silent";
32+
33+
export interface SoundTheme {
34+
readonly id: SoundThemeId;
35+
readonly name: string;
36+
readonly description: string;
37+
readonly sounds: Readonly<Record<SoundEvent, readonly Note[] | null>>;
38+
}
39+
40+
// A quiet inharmonic overtone is what makes a plain sine read as a bell.
41+
const BELL: readonly Overtone[] = [{ ratio: 2.76, volume: 0.25, length: 0.5 }];
42+
const GLASS: readonly Overtone[] = [
43+
{ ratio: 2, volume: 0.35, length: 0.6 },
44+
{ ratio: 3.01, volume: 0.12, length: 0.35 },
45+
];
46+
// Marimba bars ring at roughly 1 : 3.9, which gives the woody "tock".
47+
const MALLET: readonly Overtone[] = [{ ratio: 3.93, volume: 0.2, length: 0.3 }];
48+
const BOWL: readonly Overtone[] = [
49+
{ ratio: 2.76, volume: 0.35, length: 0.6 },
50+
{ ratio: 5.4, volume: 0.12, length: 0.3 },
51+
];
52+
53+
const note = (frequency: number, delay: number, duration: number, volume: number, extra: Partial<Note> = {}): Note => ({
54+
frequency,
55+
delay,
56+
duration,
57+
volume,
58+
...extra,
59+
});
60+
const ping = (frequency: number, delay: number, duration = 1, volume = 0.09) => note(frequency, delay, duration, volume, { overtones: GLASS });
61+
const mallet = (frequency: number, delay: number, duration = 0.45, volume = 0.13) =>
62+
note(frequency, delay, duration, volume, { wave: "triangle", overtones: MALLET });
63+
const strike = (frequency: number, delay: number, duration = 3.5, volume = 0.12) =>
64+
note(frequency, delay, duration, volume, { attack: 0.02, overtones: BOWL });
65+
const blip = (frequency: number, delay: number, duration = 0.2, volume = 0.05) => note(frequency, delay, duration, volume);
66+
67+
const CLASSIC_CHIME = [note(659.25, 0, 0.35, 0.12), note(880, 0.12, 0.5, 0.12)];
68+
const CLASSIC_BELL = [
69+
note(783.99, 0, 1.2, 0.14, { overtones: BELL }),
70+
note(659.25, 0.28, 1.2, 0.14, { overtones: BELL }),
71+
note(523.25, 0.56, 1.6, 0.14, { overtones: BELL }),
1372
];
1473

15-
const END_BELL: readonly Note[] = [
16-
{ frequency: 783.99, delay: 0, duration: 1.2, volume: 0.14 },
17-
{ frequency: 659.25, delay: 0.28, duration: 1.2, volume: 0.14 },
18-
{ frequency: 523.25, delay: 0.56, duration: 1.6, volume: 0.14 },
74+
export const SOUND_THEMES: readonly SoundTheme[] = [
75+
{
76+
id: "classic",
77+
name: "Classic",
78+
description: "Bright two-note chime to start, warm bell to finish. The original sounds.",
79+
sounds: {
80+
tap: null,
81+
focusStart: CLASSIC_CHIME,
82+
focusEnd: CLASSIC_BELL,
83+
breakStart: CLASSIC_CHIME,
84+
breakEnd: CLASSIC_BELL,
85+
pause: null,
86+
resume: CLASSIC_CHIME,
87+
},
88+
},
89+
{
90+
id: "glass",
91+
name: "Glass",
92+
description: "Crystal pings with a soft tick on every button. Light and precise.",
93+
sounds: {
94+
tap: [note(3200, 0, 0.03, 0.025)],
95+
focusStart: [ping(1318.51, 0, 0.9)],
96+
focusEnd: [ping(1760, 0, 1.4), ping(1318.51, 0.22, 1.4), ping(880, 0.44, 2)],
97+
breakStart: [ping(1318.51, 0, 0.9), ping(987.77, 0.16, 1.2)],
98+
breakEnd: [ping(987.77, 0, 0.9), ping(1318.51, 0.16, 1), ping(1760, 0.32, 1.4)],
99+
pause: [ping(987.77, 0, 0.35, 0.05)],
100+
resume: [ping(1318.51, 0, 0.35, 0.05)],
101+
},
102+
},
103+
{
104+
id: "wood",
105+
name: "Wood",
106+
description: "Warm marimba phrases and a woodblock tap. Friendly and musical.",
107+
sounds: {
108+
tap: [note(1100, 0, 0.05, 0.05, { wave: "triangle" })],
109+
focusStart: [mallet(523.25, 0), mallet(659.25, 0.09), mallet(783.99, 0.18)],
110+
focusEnd: [mallet(783.99, 0), mallet(659.25, 0.12), mallet(523.25, 0.24), mallet(392, 0.4), mallet(523.25, 0.62, 0.9)],
111+
breakStart: [mallet(659.25, 0), mallet(523.25, 0.12, 0.6)],
112+
breakEnd: [mallet(523.25, 0), mallet(659.25, 0.08), mallet(783.99, 0.16), mallet(1046.5, 0.24, 0.7)],
113+
pause: [mallet(392, 0, 0.3, 0.07)],
114+
resume: [mallet(523.25, 0, 0.3, 0.07)],
115+
},
116+
},
117+
{
118+
id: "bowl",
119+
name: "Singing bowl",
120+
description: "Deep, slowly fading bowl strikes. Calm; quiet for pause and taps.",
121+
sounds: {
122+
tap: null,
123+
focusStart: [strike(261.63, 0, 3)],
124+
focusEnd: [strike(196, 0, 4), strike(196, 1.4, 4.5)],
125+
breakStart: [strike(174.61, 0)],
126+
breakEnd: [strike(220, 0, 2.5), strike(293.66, 0.9)],
127+
pause: null,
128+
resume: null,
129+
},
130+
},
131+
{
132+
id: "soft",
133+
name: "Soft",
134+
description: "Short, low-volume blips for libraries and shared offices.",
135+
sounds: {
136+
tap: null,
137+
focusStart: [blip(880, 0)],
138+
focusEnd: [blip(880, 0, 0.25), blip(659.25, 0.22, 0.25), blip(880, 0.44, 0.3)],
139+
breakStart: [blip(659.25, 0), blip(523.25, 0.18)],
140+
breakEnd: [blip(523.25, 0), blip(659.25, 0.18), blip(880, 0.36)],
141+
pause: [blip(523.25, 0, 0.1, 0.03)],
142+
resume: [blip(659.25, 0, 0.1, 0.03)],
143+
},
144+
},
145+
{
146+
id: "silent",
147+
name: "Silent",
148+
description: "No sounds at all. Notifications still appear when they're on.",
149+
sounds: { tap: null, focusStart: null, focusEnd: null, breakStart: null, breakEnd: null, pause: null, resume: null },
150+
},
19151
];
20152

21-
// A quiet inharmonic partial is what makes a plain sine read as a bell.
22-
const BELL_PARTIAL = 2.76;
23-
const BELL_PARTIAL_VOLUME = 0.25;
153+
export const DEFAULT_SOUND_THEME: SoundThemeId = "classic";
154+
const THEME_KEY = "bft:soundTheme";
155+
156+
const themeById = (id: SoundThemeId): SoundTheme => SOUND_THEMES.find((theme) => theme.id === id) ?? SOUND_THEMES[0];
157+
158+
export function getSoundThemeId(): SoundThemeId {
159+
const stored = typeof window === "undefined" ? null : localStorage.getItem(THEME_KEY);
160+
return SOUND_THEMES.some((theme) => theme.id === stored) ? (stored as SoundThemeId) : DEFAULT_SOUND_THEME;
161+
}
162+
163+
export function setSoundThemeId(id: SoundThemeId): void {
164+
localStorage.setItem(THEME_KEY, id);
165+
}
166+
24167
const ATTACK_SECONDS = 0.01;
25168
const SILENCE = 0.0001;
26169

@@ -37,38 +180,66 @@ function audioContext(): AudioContext | null {
37180
}
38181
}
39182

40-
// Browsers only let audio start after a user gesture, and the end sound fires from a timer.
41-
if (typeof window !== "undefined") {
42-
window.addEventListener("pointerdown", audioContext, { once: true });
43-
window.addEventListener("keydown", audioContext, { once: true });
44-
}
45-
46-
function playTone(audio: AudioContext, note: Note): void {
47-
const startAt = audio.currentTime + note.delay;
183+
function playTone(audio: AudioContext, tone: Note): void {
184+
const startAt = audio.currentTime + tone.delay;
48185
const oscillator = audio.createOscillator();
49186
const gain = audio.createGain();
50-
oscillator.type = "sine";
51-
oscillator.frequency.value = note.frequency;
187+
oscillator.type = tone.wave ?? "sine";
188+
oscillator.frequency.value = tone.frequency;
52189
gain.gain.setValueAtTime(SILENCE, startAt);
53-
gain.gain.exponentialRampToValueAtTime(note.volume, startAt + ATTACK_SECONDS);
54-
gain.gain.exponentialRampToValueAtTime(SILENCE, startAt + note.duration);
190+
gain.gain.exponentialRampToValueAtTime(tone.volume, startAt + (tone.attack ?? ATTACK_SECONDS));
191+
gain.gain.exponentialRampToValueAtTime(SILENCE, startAt + tone.duration);
55192
oscillator.connect(gain).connect(audio.destination);
56193
oscillator.start(startAt);
57-
oscillator.stop(startAt + note.duration);
194+
oscillator.stop(startAt + tone.duration);
58195
}
59196

60-
const withPartial = (note: Note): Note[] => [
61-
note,
62-
{ ...note, frequency: note.frequency * BELL_PARTIAL, duration: note.duration / 2, volume: note.volume * BELL_PARTIAL_VOLUME },
197+
const withOvertones = (tone: Note): Note[] => [
198+
tone,
199+
...(tone.overtones ?? []).map((overtone) => ({
200+
...tone,
201+
frequency: tone.frequency * overtone.ratio,
202+
volume: tone.volume * overtone.volume,
203+
duration: tone.duration * overtone.length,
204+
overtones: undefined,
205+
})),
63206
];
64207

65-
export function playSound(name: SoundName): void {
208+
function playNotes(notes: readonly Note[] | null, label: string): void {
209+
if (!notes) return;
66210
const audio = audioContext();
67211
if (!audio) return;
68-
const notes = name === "start" ? START_CHIME : END_BELL.flatMap(withPartial);
69212
try {
70-
notes.forEach((note) => playTone(audio, note));
213+
notes.flatMap(withOvertones).forEach((tone) => playTone(audio, tone));
71214
} catch (error) {
72-
console.error(`Failed to play ${name} sound:`, error);
215+
console.error(`Failed to play ${label} sound:`, error);
73216
}
74217
}
218+
219+
/** Plays `event` in the user's chosen theme. */
220+
export function playSound(event: SoundEvent): void {
221+
playNotes(themeById(getSoundThemeId()).sounds[event], event);
222+
}
223+
224+
/** Plays `event` in any theme, for previews in the sound picker. */
225+
export function previewSound(themeId: SoundThemeId, event: SoundEvent): void {
226+
playNotes(themeById(themeId).sounds[event], `${themeId} ${event}`);
227+
}
228+
229+
/** Marks a button that plays its own sound, so it doesn't also tick. */
230+
export const OWN_SOUND_ATTRIBUTE = "data-own-sound";
231+
232+
if (typeof window !== "undefined") {
233+
// Browsers only let audio start after a user gesture, and the end sound fires from a timer.
234+
window.addEventListener("pointerdown", audioContext, { once: true });
235+
window.addEventListener("keydown", audioContext, { once: true });
236+
window.addEventListener(
237+
"click",
238+
(event) => {
239+
const target = event.target instanceof Element ? event.target : null;
240+
const button = target?.closest("button, [role='button'], a[href]");
241+
if (button && !button.closest(`[${OWN_SOUND_ATTRIBUTE}]`)) playSound("tap");
242+
},
243+
true,
244+
);
245+
}

‎src/components/icons.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,6 @@ export const Target = createIcon("Target", [["circle",{cx:"12",cy:"12",r:"10"}],
5353
export const Trash2 = createIcon("Trash2", [["path",{d:"M3 6h18"}],["path",{d:"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"}],["path",{d:"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"}],["line",{x1:"10",x2:"10",y1:"11",y2:"17"}],["line",{x1:"14",x2:"14",y1:"11",y2:"17"}]]);
5454
export const TrendingUp = createIcon("TrendingUp", [["polyline",{points:"22 7 13.5 15.5 8.5 10.5 2 17"}],["polyline",{points:"16 7 22 7 22 13"}]]);
5555
export const User = createIcon("User", [["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"}],["circle",{cx:"12",cy:"7",r:"4"}]]);
56+
export const Volume2 = createIcon("Volume2", [["polygon",{points:"11 5 6 9 2 9 2 15 6 15 11 19 11 5"}],["path",{d:"M15.54 8.46a5 5 0 0 1 0 7.07"}],["path",{d:"M19.07 4.93a10 10 0 0 1 0 14.14"}]]);
5657
export const XCircle = createIcon("XCircle", [["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"m15 9-6 6"}],["path",{d:"m9 9 6 6"}]]);
5758
export const X = createIcon("X", [["path",{d:"M18 6 6 18"}],["path",{d:"m6 6 12 12"}]]);

0 commit comments

Comments
 (0)