Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Runs as a web app or as a browser extension, and keeps every bit of data in your

- **Focus timer** with pause, resume, breaks, extend-on-complete and goal history (press <kbd>↑</kbd>/<kbd>↓</kbd> in the goal field).
- **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.
- **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.
- **Pixel-art progress animations** — a coffee cup that fills, a battery that charges, a rocket that lifts off — plus an auto-shuffle option.
- **To-dos** with time estimates, notes, drag-and-drop ordering, a backlog and archive; start a focus session straight from a to-do.
- **Habits** with 7- and 30-day grids and notes per day.
Expand Down
22 changes: 12 additions & 10 deletions src/components/focus/FocusTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ANIMATIONS, DEFAULT_ANIMATION, currentFrame, isAnimationType, metaOf, t
import { GoalForm, hotkeyHint } from "./GoalForm";
import { PixelArt } from "./PixelArt";
import { notify } from "./notifications";
import { playSound } from "./sounds";
import { OWN_SOUND_ATTRIBUTE, playSound } from "./sounds";
import { useDialog } from "../dialogs/DialogProvider";

const DEFAULT_TITLE = "Boost Focus Timer";
Expand Down Expand Up @@ -121,7 +121,7 @@ function TimerButton({
children: ReactNode;
}) {
return (
<button onClick={onClick} className={className} title={title}>
<button onClick={onClick} className={className} title={title} {...{ [OWN_SOUND_ATTRIBUTE]: "" }}>
{children}
</button>
);
Expand Down Expand Up @@ -293,7 +293,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
titleSuffix: goal || "Focus",
onTick: (minutes, seconds) => setFocus((current) => ({ ...current, minutes, seconds })),
onComplete: () => {
playSound("end");
playSound("focusEnd");
notify("Focus session complete", `${goal.trim() || "Nice work"} · time for a break.`);
setFocusStart(null);
setFocus({ ...IDLE, isCompleted: true });
Expand Down Expand Up @@ -323,7 +323,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
titleSuffix: "Break",
onTick: (minutes, seconds) => setBreakTimer((current) => ({ ...current, minutes, seconds })),
onComplete: () => {
playSound("end");
playSound("breakEnd");
notify("Break is over", "Ready for the next focus session?");
void completeBreakSession();
finishBreak();
Expand All @@ -338,7 +338,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
try {
const created = await databaseService.createFocusSession({ goal, planned_duration_minutes: minutes, session_type: "focus" });
setSession(created);
playSound("start");
playSound("focusStart");
if (goal.trim()) setGoalHistory((history) => [goal, ...history]);
setFocusStart(Date.now());
setFocusTotalMs(minutes * MINUTE_MS);
Expand All @@ -355,6 +355,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
};

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

const resumeFocus = async () => {
playSound("start");
playSound("resume");
await endPause(pauseId, setPauseId);
sendHeartbeat(session);
setFocusStart(Date.now() - (focusTotalMs - remainingMsOf(focus)));
setFocus((current) => ({ ...current, isRunning: true }));
};

const completeEarly = async () => {
playSound("end");
playSound("focusEnd");
document.title = DEFAULT_TITLE;
setFocusStart(null);
await endPause(pauseId, setPauseId);
Expand Down Expand Up @@ -406,7 +407,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
.createFocusSession({ goal: "Break time", planned_duration_minutes: minutes, session_type: "break" })
.catch(logError("Failed to start break session:"));
if (created) setBreakSession(created);
playSound("start");
playSound("breakStart");
setBreakStart(Date.now());
setBreakTotalMs(minutes * MINUTE_MS);
setBreakTimer({ minutes, seconds: 0, isRunning: true, isCompleted: false });
Expand All @@ -415,6 +416,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
};

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

const resumeBreak = async () => {
playSound("start");
playSound("resume");
await endPause(breakPauseId, setBreakPauseId);
setBreakStart(Date.now() - (breakTotalMs - remainingMsOf(breakTimer)));
setBreakTimer((current) => ({ ...current, isRunning: true }));
};

const endBreak = async () => {
playSound("end");
playSound("breakEnd");
document.title = DEFAULT_TITLE;
await completeBreakSession();
finishBreak();
Expand Down
233 changes: 202 additions & 31 deletions src/components/focus/sounds.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,169 @@
export type SoundName = "start" | "end";
/*
* Sound themes, all synthesized with Web Audio (no audio files).
* Every theme covers the same moments so switching themes never leaves a moment unexpectedly silent,
* except where a theme deliberately stays quiet (null).
*/

export type SoundEvent = "tap" | "focusStart" | "focusEnd" | "breakStart" | "breakEnd" | "pause" | "resume";

interface Overtone {
/** Frequency multiple of the note. */
readonly ratio: number;
/** Volume relative to the note. */
readonly volume: number;
/** Duration relative to the note. */
readonly length: number;
}

interface Note {
readonly frequency: number;
/** Seconds after the sound starts. */
readonly delay: number;
/** Seconds until the note has faded out. */
readonly duration: number;
/** Peak gain; kept at or below 0.15 so no theme startles. */
readonly volume: number;
readonly wave?: OscillatorType;
readonly attack?: number;
readonly overtones?: readonly Overtone[];
}

const START_CHIME: readonly Note[] = [
{ frequency: 659.25, delay: 0, duration: 0.35, volume: 0.12 },
{ frequency: 880, delay: 0.12, duration: 0.5, volume: 0.12 },
export type SoundThemeId = "classic" | "glass" | "wood" | "bowl" | "soft" | "silent";

export interface SoundTheme {
readonly id: SoundThemeId;
readonly name: string;
readonly description: string;
readonly sounds: Readonly<Record<SoundEvent, readonly Note[] | null>>;
}

// A quiet inharmonic overtone is what makes a plain sine read as a bell.
const BELL: readonly Overtone[] = [{ ratio: 2.76, volume: 0.25, length: 0.5 }];
const GLASS: readonly Overtone[] = [
{ ratio: 2, volume: 0.35, length: 0.6 },
{ ratio: 3.01, volume: 0.12, length: 0.35 },
];
// Marimba bars ring at roughly 1 : 3.9, which gives the woody "tock".
const MALLET: readonly Overtone[] = [{ ratio: 3.93, volume: 0.2, length: 0.3 }];
const BOWL: readonly Overtone[] = [
{ ratio: 2.76, volume: 0.35, length: 0.6 },
{ ratio: 5.4, volume: 0.12, length: 0.3 },
];

const note = (frequency: number, delay: number, duration: number, volume: number, extra: Partial<Note> = {}): Note => ({
frequency,
delay,
duration,
volume,
...extra,
});
const ping = (frequency: number, delay: number, duration = 1, volume = 0.09) => note(frequency, delay, duration, volume, { overtones: GLASS });
const mallet = (frequency: number, delay: number, duration = 0.45, volume = 0.13) =>
note(frequency, delay, duration, volume, { wave: "triangle", overtones: MALLET });
const strike = (frequency: number, delay: number, duration = 3.5, volume = 0.12) =>
note(frequency, delay, duration, volume, { attack: 0.02, overtones: BOWL });
const blip = (frequency: number, delay: number, duration = 0.2, volume = 0.05) => note(frequency, delay, duration, volume);

const CLASSIC_CHIME = [note(659.25, 0, 0.35, 0.12), note(880, 0.12, 0.5, 0.12)];
const CLASSIC_BELL = [
note(783.99, 0, 1.2, 0.14, { overtones: BELL }),
note(659.25, 0.28, 1.2, 0.14, { overtones: BELL }),
note(523.25, 0.56, 1.6, 0.14, { overtones: BELL }),
];

const END_BELL: readonly Note[] = [
{ frequency: 783.99, delay: 0, duration: 1.2, volume: 0.14 },
{ frequency: 659.25, delay: 0.28, duration: 1.2, volume: 0.14 },
{ frequency: 523.25, delay: 0.56, duration: 1.6, volume: 0.14 },
export const SOUND_THEMES: readonly SoundTheme[] = [
{
id: "classic",
name: "Classic",
description: "Bright two-note chime to start, warm bell to finish. The original sounds.",
sounds: {
tap: null,
focusStart: CLASSIC_CHIME,
focusEnd: CLASSIC_BELL,
breakStart: CLASSIC_CHIME,
breakEnd: CLASSIC_BELL,
pause: null,
resume: CLASSIC_CHIME,
},
},
{
id: "glass",
name: "Glass",
description: "Crystal pings with a soft tick on every button. Light and precise.",
sounds: {
tap: [note(3200, 0, 0.03, 0.025)],
focusStart: [ping(1318.51, 0, 0.9)],
focusEnd: [ping(1760, 0, 1.4), ping(1318.51, 0.22, 1.4), ping(880, 0.44, 2)],
breakStart: [ping(1318.51, 0, 0.9), ping(987.77, 0.16, 1.2)],
breakEnd: [ping(987.77, 0, 0.9), ping(1318.51, 0.16, 1), ping(1760, 0.32, 1.4)],
pause: [ping(987.77, 0, 0.35, 0.05)],
resume: [ping(1318.51, 0, 0.35, 0.05)],
},
},
{
id: "wood",
name: "Wood",
description: "Warm marimba phrases and a woodblock tap. Friendly and musical.",
sounds: {
tap: [note(1100, 0, 0.05, 0.05, { wave: "triangle" })],
focusStart: [mallet(523.25, 0), mallet(659.25, 0.09), mallet(783.99, 0.18)],
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)],
breakStart: [mallet(659.25, 0), mallet(523.25, 0.12, 0.6)],
breakEnd: [mallet(523.25, 0), mallet(659.25, 0.08), mallet(783.99, 0.16), mallet(1046.5, 0.24, 0.7)],
pause: [mallet(392, 0, 0.3, 0.07)],
resume: [mallet(523.25, 0, 0.3, 0.07)],
},
},
{
id: "bowl",
name: "Singing bowl",
description: "Deep, slowly fading bowl strikes. Calm; quiet for pause and taps.",
sounds: {
tap: null,
focusStart: [strike(261.63, 0, 3)],
focusEnd: [strike(196, 0, 4), strike(196, 1.4, 4.5)],
breakStart: [strike(174.61, 0)],
breakEnd: [strike(220, 0, 2.5), strike(293.66, 0.9)],
pause: null,
resume: null,
},
},
{
id: "soft",
name: "Soft",
description: "Short, low-volume blips for libraries and shared offices.",
sounds: {
tap: null,
focusStart: [blip(880, 0)],
focusEnd: [blip(880, 0, 0.25), blip(659.25, 0.22, 0.25), blip(880, 0.44, 0.3)],
breakStart: [blip(659.25, 0), blip(523.25, 0.18)],
breakEnd: [blip(523.25, 0), blip(659.25, 0.18), blip(880, 0.36)],
pause: [blip(523.25, 0, 0.1, 0.03)],
resume: [blip(659.25, 0, 0.1, 0.03)],
},
},
{
id: "silent",
name: "Silent",
description: "No sounds at all. Notifications still appear when they're on.",
sounds: { tap: null, focusStart: null, focusEnd: null, breakStart: null, breakEnd: null, pause: null, resume: null },
},
];

// A quiet inharmonic partial is what makes a plain sine read as a bell.
const BELL_PARTIAL = 2.76;
const BELL_PARTIAL_VOLUME = 0.25;
export const DEFAULT_SOUND_THEME: SoundThemeId = "classic";
const THEME_KEY = "bft:soundTheme";

const themeById = (id: SoundThemeId): SoundTheme => SOUND_THEMES.find((theme) => theme.id === id) ?? SOUND_THEMES[0];

export function getSoundThemeId(): SoundThemeId {
const stored = typeof window === "undefined" ? null : localStorage.getItem(THEME_KEY);
return SOUND_THEMES.some((theme) => theme.id === stored) ? (stored as SoundThemeId) : DEFAULT_SOUND_THEME;
}

export function setSoundThemeId(id: SoundThemeId): void {
localStorage.setItem(THEME_KEY, id);
}

const ATTACK_SECONDS = 0.01;
const SILENCE = 0.0001;

Expand All @@ -37,38 +180,66 @@ function audioContext(): AudioContext | null {
}
}

// Browsers only let audio start after a user gesture, and the end sound fires from a timer.
if (typeof window !== "undefined") {
window.addEventListener("pointerdown", audioContext, { once: true });
window.addEventListener("keydown", audioContext, { once: true });
}

function playTone(audio: AudioContext, note: Note): void {
const startAt = audio.currentTime + note.delay;
function playTone(audio: AudioContext, tone: Note): void {
const startAt = audio.currentTime + tone.delay;
const oscillator = audio.createOscillator();
const gain = audio.createGain();
oscillator.type = "sine";
oscillator.frequency.value = note.frequency;
oscillator.type = tone.wave ?? "sine";
oscillator.frequency.value = tone.frequency;
gain.gain.setValueAtTime(SILENCE, startAt);
gain.gain.exponentialRampToValueAtTime(note.volume, startAt + ATTACK_SECONDS);
gain.gain.exponentialRampToValueAtTime(SILENCE, startAt + note.duration);
gain.gain.exponentialRampToValueAtTime(tone.volume, startAt + (tone.attack ?? ATTACK_SECONDS));
gain.gain.exponentialRampToValueAtTime(SILENCE, startAt + tone.duration);
oscillator.connect(gain).connect(audio.destination);
oscillator.start(startAt);
oscillator.stop(startAt + note.duration);
oscillator.stop(startAt + tone.duration);
}

const withPartial = (note: Note): Note[] => [
note,
{ ...note, frequency: note.frequency * BELL_PARTIAL, duration: note.duration / 2, volume: note.volume * BELL_PARTIAL_VOLUME },
const withOvertones = (tone: Note): Note[] => [
tone,
...(tone.overtones ?? []).map((overtone) => ({
...tone,
frequency: tone.frequency * overtone.ratio,
volume: tone.volume * overtone.volume,
duration: tone.duration * overtone.length,
overtones: undefined,
})),
];

export function playSound(name: SoundName): void {
function playNotes(notes: readonly Note[] | null, label: string): void {
if (!notes) return;
const audio = audioContext();
if (!audio) return;
const notes = name === "start" ? START_CHIME : END_BELL.flatMap(withPartial);
try {
notes.forEach((note) => playTone(audio, note));
notes.flatMap(withOvertones).forEach((tone) => playTone(audio, tone));
} catch (error) {
console.error(`Failed to play ${name} sound:`, error);
console.error(`Failed to play ${label} sound:`, error);
}
}

/** Plays `event` in the user's chosen theme. */
export function playSound(event: SoundEvent): void {
playNotes(themeById(getSoundThemeId()).sounds[event], event);
}

/** Plays `event` in any theme, for previews in the sound picker. */
export function previewSound(themeId: SoundThemeId, event: SoundEvent): void {
playNotes(themeById(themeId).sounds[event], `${themeId} ${event}`);
}

/** Marks a button that plays its own sound, so it doesn't also tick. */
export const OWN_SOUND_ATTRIBUTE = "data-own-sound";

if (typeof window !== "undefined") {
// Browsers only let audio start after a user gesture, and the end sound fires from a timer.
window.addEventListener("pointerdown", audioContext, { once: true });
window.addEventListener("keydown", audioContext, { once: true });
window.addEventListener(
"click",
(event) => {
const target = event.target instanceof Element ? event.target : null;
const button = target?.closest("button, [role='button'], a[href]");
if (button && !button.closest(`[${OWN_SOUND_ATTRIBUTE}]`)) playSound("tap");
},
true,
);
}
1 change: 1 addition & 0 deletions src/components/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,6 @@ export const Target = createIcon("Target", [["circle",{cx:"12",cy:"12",r:"10"}],
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"}]]);
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"}]]);
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"}]]);
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"}]]);
export const XCircle = createIcon("XCircle", [["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"m15 9-6 6"}],["path",{d:"m9 9 6 6"}]]);
export const X = createIcon("X", [["path",{d:"M18 6 6 18"}],["path",{d:"m6 6 12 12"}]]);
Loading
Loading