Initial commit -- Core Cooldown v0.1.0

This commit is contained in:
2026-02-07 01:12:32 +02:00
commit 590b8e1a74
47 changed files with 15106 additions and 0 deletions

115
src/lib/utils/activities.ts Normal file
View File

@@ -0,0 +1,115 @@
export interface BreakActivity {
category: "eyes" | "stretch" | "breathing" | "movement";
text: string;
}
export const breakActivities: BreakActivity[] = [
// Eyes
{ category: "eyes", text: "Look at something 20 feet away for 20 seconds" },
{ category: "eyes", text: "Close your eyes and gently press your palms over them" },
{ category: "eyes", text: "Slowly roll your eyes in circles, 5 times each direction" },
{ category: "eyes", text: "Focus on a distant object, then a near one — repeat 5 times" },
{ category: "eyes", text: "Blink rapidly 20 times to refresh your eyes" },
{ category: "eyes", text: "Look up, down, left, right — hold each for 2 seconds" },
{ category: "eyes", text: "Cup your hands over closed eyes and breathe deeply" },
{ category: "eyes", text: "Trace a figure-8 with your eyes, slowly" },
{ category: "eyes", text: "Look out the nearest window and find the farthest point" },
{ category: "eyes", text: "Gently massage your temples in small circles" },
{ category: "eyes", text: "Close your eyes and visualize a calm, dark space for 20 seconds" },
{ category: "eyes", text: "Warm your palms by rubbing them together, then rest them over closed eyes" },
{ category: "eyes", text: "Slowly shift your gaze along the horizon line outside" },
{ category: "eyes", text: "Focus on the tip of your nose for 5 seconds, then a far wall" },
{ category: "eyes", text: "Look at something green — plants reduce eye strain naturally" },
{ category: "eyes", text: "Close your eyes and gently press your eyebrows outward from center" },
{ category: "eyes", text: "Squeeze your eyes shut for 3 seconds, then open wide — repeat 5 times" },
{ category: "eyes", text: "Trace the outline of a window frame with your eyes, slowly" },
// Stretches
{ category: "stretch", text: "Roll your shoulders backward slowly, 5 times" },
{ category: "stretch", text: "Interlace fingers behind your back and open your chest" },
{ category: "stretch", text: "Tilt your head to each side, holding for 10 seconds" },
{ category: "stretch", text: "Stretch your arms overhead and reach for the ceiling" },
{ category: "stretch", text: "Rotate your wrists in circles, 10 times each direction" },
{ category: "stretch", text: "Clasp hands together and push palms away from you" },
{ category: "stretch", text: "Gently twist your torso left and right from your chair" },
{ category: "stretch", text: "Drop your chin to your chest and slowly roll your neck" },
{ category: "stretch", text: "Extend each arm across your chest, hold for 15 seconds" },
{ category: "stretch", text: "Open and close your hands, spreading fingers wide" },
{ category: "stretch", text: "Place your right hand on your left knee and twist gently — switch sides" },
{ category: "stretch", text: "Reach one arm overhead and lean to the opposite side, hold 10 seconds each" },
{ category: "stretch", text: "Interlace your fingers and flip your palms to the ceiling, push up" },
{ category: "stretch", text: "Pull each finger gently backward for 3 seconds to stretch your forearms" },
{ category: "stretch", text: "Press your palms together at chest height and push for 10 seconds" },
{ category: "stretch", text: "Sit tall, reach behind to grab the back of your chair, and open your chest" },
{ category: "stretch", text: "Cross one ankle over the opposite knee and lean forward gently" },
{ category: "stretch", text: "Shrug your shoulders up to your ears, hold 5 seconds, release slowly" },
// Breathing
{ category: "breathing", text: "Inhale for 4 seconds, hold for 4, exhale for 6" },
{ category: "breathing", text: "Take 5 deep belly breaths — feel your diaphragm expand" },
{ category: "breathing", text: "Box breathing: inhale 4s, hold 4s, exhale 4s, hold 4s" },
{ category: "breathing", text: "Breathe in through your nose, out through your mouth — 5 times" },
{ category: "breathing", text: "Sigh deeply 3 times to release tension" },
{ category: "breathing", text: "Place a hand on your chest and breathe so only your belly moves" },
{ category: "breathing", text: "Alternate nostril breathing: 3 cycles each side" },
{ category: "breathing", text: "Exhale completely, then take the deepest breath you can" },
{ category: "breathing", text: "Count your breaths backward from 10 to 1" },
{ category: "breathing", text: "Breathe in calm, breathe out tension — 5 rounds" },
{ category: "breathing", text: "4-7-8 breathing: inhale 4s, hold 7s, exhale slowly for 8s" },
{ category: "breathing", text: "Take 3 breaths making your exhale twice as long as your inhale" },
{ category: "breathing", text: "Breathe in for 2 counts, out for 2 — gradually increase to 6 each" },
{ category: "breathing", text: "Hum gently on each exhale for 5 breaths — feel the vibration in your chest" },
{ category: "breathing", text: "Imagine breathing in cool blue air and exhaling warm red air — 5 rounds" },
{ category: "breathing", text: "Place both hands on your ribs and breathe so you feel them expand sideways" },
{ category: "breathing", text: "Breathe in through pursed lips slowly, then exhale in a long, steady stream" },
// Movement
{ category: "movement", text: "Stand up and walk to the nearest window" },
{ category: "movement", text: "Do 10 standing calf raises" },
{ category: "movement", text: "Walk to get a glass of water" },
{ category: "movement", text: "Stand and do 5 gentle squats" },
{ category: "movement", text: "Take a short walk around your room" },
{ category: "movement", text: "Stand on one foot for 15 seconds, then switch" },
{ category: "movement", text: "Do 10 arm circles, forward then backward" },
{ category: "movement", text: "March in place for 30 seconds" },
{ category: "movement", text: "Shake out your hands and arms for 10 seconds" },
{ category: "movement", text: "Stand up, touch your toes, and slowly rise" },
{ category: "movement", text: "Walk to the farthest room in your home and back" },
{ category: "movement", text: "Do 5 wall push-ups — hands on the wall, lean in and push back" },
{ category: "movement", text: "Stand up and slowly shift your weight side to side for 20 seconds" },
{ category: "movement", text: "Walk in a small circle, paying attention to each footstep" },
{ category: "movement", text: "Rise onto your tiptoes, hold for 5 seconds, lower slowly — repeat 5 times" },
{ category: "movement", text: "Do a gentle standing forward fold — let your arms hang loose" },
{ category: "movement", text: "Step away from your desk and do 10 hip circles in each direction" },
{ category: "movement", text: "Stand with your back against a wall and slide down into a gentle wall sit for 15 seconds" },
];
const categoryIcons: Record<BreakActivity["category"], string> = {
eyes: "👁",
stretch: "🤸",
breathing: "🌬",
movement: "🚶",
};
const categoryLabels: Record<BreakActivity["category"], string> = {
eyes: "Eye Exercise",
stretch: "Stretch",
breathing: "Breathing",
movement: "Movement",
};
export function getCategoryIcon(cat: BreakActivity["category"]): string {
return categoryIcons[cat];
}
export function getCategoryLabel(cat: BreakActivity["category"]): string {
return categoryLabels[cat];
}
/** Pick a random activity, optionally excluding a previous one */
export function pickRandomActivity(exclude?: BreakActivity): BreakActivity {
const pool = exclude
? breakActivities.filter((a) => a.text !== exclude.text)
: breakActivities;
return pool[Math.floor(Math.random() * pool.length)];
}

366
src/lib/utils/animate.ts Normal file
View File

@@ -0,0 +1,366 @@
import { animate } from "motion";
/**
* Svelte action: fade in + slide up on mount
*/
export function fadeIn(
node: HTMLElement,
options?: { duration?: number; delay?: number; y?: number },
) {
const { duration = 0.5, delay = 0, y = 15 } = options ?? {};
node.style.opacity = "0";
const controls = animate(
node,
{ opacity: [0, 1], transform: [`translateY(${y}px)`, "translateY(0px)"] },
{ duration, delay, easing: [0.22, 0.03, 0.26, 1] },
);
return {
destroy() {
controls.cancel();
},
};
}
/**
* Svelte action: scale in + fade on mount
*/
export function scaleIn(
node: HTMLElement,
options?: { duration?: number; delay?: number },
) {
const { duration = 0.6, delay = 0 } = options ?? {};
node.style.opacity = "0";
const controls = animate(
node,
{ opacity: [0, 1], transform: ["scale(0.92)", "scale(1)"] },
{ duration, delay, easing: [0.22, 0.03, 0.26, 1] },
);
return {
destroy() {
controls.cancel();
},
};
}
/**
* Svelte action: animate when scrolled into view (IntersectionObserver)
*/
export function inView(
node: HTMLElement,
options?: { delay?: number; y?: number; threshold?: number },
) {
const { delay = 0, y = 20, threshold = 0.05 } = options ?? {};
node.style.opacity = "0";
node.style.transform = `translateY(${y}px)`;
let controls: ReturnType<typeof animate> | null = null;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
controls = animate(
node,
{
opacity: [0, 1],
transform: [`translateY(${y}px)`, "translateY(0px)"],
},
{ duration: 0.45, delay, easing: [0.22, 0.03, 0.26, 1] },
);
observer.disconnect();
}
},
{ threshold },
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
controls?.cancel();
},
};
}
/**
* Svelte action: spring-scale press feedback on buttons
*/
export function pressable(node: HTMLElement) {
let active: ReturnType<typeof animate> | null = null;
function onDown() {
active?.cancel();
// Explicit [from, to] avoids transform conflicts with fadeIn's translateY
active = animate(
node,
{ transform: ["scale(1)", "scale(0.95)"] },
{ duration: 0.1, easing: [0.22, 0.03, 0.26, 1] },
);
}
function onUp() {
active?.cancel();
active = animate(
node,
{ transform: ["scale(0.95)", "scale(1)"] },
{ duration: 0.3, easing: [0.22, 1.2, 0.36, 1] },
);
}
node.addEventListener("mousedown", onDown);
node.addEventListener("mouseup", onUp);
node.addEventListener("mouseleave", onUp);
return {
destroy() {
node.removeEventListener("mousedown", onDown);
node.removeEventListener("mouseup", onUp);
node.removeEventListener("mouseleave", onUp);
active?.cancel();
},
};
}
/**
* Svelte action: animated glow band on hover.
* Creates a crisp ring "band" plus a soft atmospheric glow.
* Pass a hex color (e.g. "#ff4d00").
*
* Uses same-hue zero-alpha as the "off" state so the Web Animations API
* interpolates through the correct color channel instead of through black.
*/
export function glowHover(
node: HTMLElement,
options?: { color?: string },
) {
let color = options?.color ?? "#ff4d00";
let enterAnim: ReturnType<typeof animate> | null = null;
let leaveAnim: ReturnType<typeof animate> | null = null;
// "off" state: same hue at zero alpha (NOT transparent, which is rgba(0,0,0,0))
function off() { return `0 0 0 0px ${color}00, 0 0 0px 0px ${color}00`; }
function on() { return `0 0 0 1.5px ${color}90, 0 0 22px 6px ${color}40`; }
node.style.boxShadow = off();
function onEnter() {
leaveAnim?.cancel();
enterAnim = animate(
node,
{ boxShadow: [off(), on()] },
{ duration: 0.4, easing: [0.22, 0.03, 0.26, 1] },
);
}
function onLeave() {
enterAnim?.cancel();
leaveAnim = animate(
node,
{ boxShadow: [on(), off()] },
{ duration: 0.5, easing: [0.22, 0.03, 0.26, 1] },
);
}
node.addEventListener("mouseenter", onEnter);
node.addEventListener("mouseleave", onLeave);
return {
update(newOptions?: { color?: string }) {
color = newOptions?.color ?? "#ff4d00";
node.style.boxShadow = off();
},
destroy() {
node.removeEventListener("mouseenter", onEnter);
node.removeEventListener("mouseleave", onLeave);
enterAnim?.cancel();
leaveAnim?.cancel();
},
};
}
/**
* Svelte action: momentum-based grab-and-drag scrolling
* with elastic overscroll and spring-back at boundaries.
*
* IMPORTANT: The node must have exactly one child element (a wrapper div).
* Overscroll transforms are applied to that child, NOT to the scroll
* container itself (which would break overflow clipping).
*/
export function dragScroll(node: HTMLElement) {
const content = node.children[0] as HTMLElement | null;
let isDown = false;
let startY = 0;
let scrollStart = 0;
let lastY = 0;
let lastTime = 0;
let animFrame = 0;
let velocitySamples: number[] = [];
let overscrollAmount = 0;
let springAnim: ReturnType<typeof animate> | null = null;
function getMaxScroll() {
return node.scrollHeight - node.clientHeight;
}
function setOverscroll(amount: number) {
if (!content) return;
overscrollAmount = amount;
if (Math.abs(amount) < 0.5) {
content.style.transform = "";
overscrollAmount = 0;
} else {
content.style.transform = `translateY(${-amount}px)`;
}
}
function springBack() {
if (!content) return;
const from = overscrollAmount;
if (Math.abs(from) < 0.5) {
setOverscroll(0);
return;
}
springAnim = animate(
content,
{ transform: [`translateY(${-from}px)`, "translateY(0px)"] },
{ duration: 0.6, easing: [0.16, 1, 0.3, 1] },
);
overscrollAmount = 0;
// Ensure DOM is clean after animation completes
springAnim.finished.then(() => {
if (content) content.style.transform = "";
}).catch(() => { /* cancelled — onDown handles cleanup */ });
}
function forceReset() {
springAnim?.cancel();
cancelAnimationFrame(animFrame);
overscrollAmount = 0;
if (content) content.style.transform = "";
}
function onDown(e: MouseEvent) {
if (e.button !== 0) return;
const tag = (e.target as HTMLElement).tagName;
if (["BUTTON", "INPUT", "LABEL", "SELECT", "TEXTAREA"].includes(tag)) return;
// Force-reset any lingering animation/transform state
forceReset();
isDown = true;
startY = e.pageY;
scrollStart = node.scrollTop;
lastY = e.pageY;
lastTime = Date.now();
velocitySamples = [];
node.style.cursor = "grabbing";
}
function onMove(e: MouseEvent) {
if (!isDown) return;
e.preventDefault();
const y = e.pageY;
const now = Date.now();
const dt = now - lastTime;
if (dt > 0) {
velocitySamples.push((lastY - y) / dt);
if (velocitySamples.length > 5) velocitySamples.shift();
}
lastY = y;
lastTime = now;
const desiredScroll = scrollStart - (y - startY);
const maxScroll = getMaxScroll();
if (desiredScroll < 0) {
node.scrollTop = 0;
setOverscroll(desiredScroll * 0.3);
} else if (desiredScroll > maxScroll) {
node.scrollTop = maxScroll;
setOverscroll((desiredScroll - maxScroll) * 0.3);
} else {
node.scrollTop = desiredScroll;
if (overscrollAmount !== 0) setOverscroll(0);
}
}
function onUp() {
if (!isDown) return;
isDown = false;
node.style.cursor = "";
if (overscrollAmount !== 0) {
springBack();
return;
}
const avgVelocity = velocitySamples.length > 0
? velocitySamples.reduce((a, b) => a + b, 0) / velocitySamples.length
: 0;
// Clamp velocity (px/ms) and bail if negligible
const maxV = 4;
const v0 = Math.max(-maxV, Math.min(maxV, avgVelocity));
if (Math.abs(v0) < 0.005) return;
// Time-based exponential decay (iOS-style scroll physics).
// position(t) = start + v0 * tau * (1 - e^(-t/tau))
// velocity(t) = v0 * e^(-t/tau) → naturally asymptotes to zero
const tau = 325; // time constant in ms — iOS UIScrollView feel
const coastStart = performance.now();
const scrollStart2 = node.scrollTop;
const totalDist = v0 * tau;
function coast() {
const t = performance.now() - coastStart;
const decay = Math.exp(-t / tau);
const offset = totalDist * (1 - decay);
const targetScroll = scrollStart2 + offset;
const maxScroll = getMaxScroll();
if (targetScroll < 0) {
node.scrollTop = 0;
const currentV = Math.abs(v0 * decay);
const bounce = Math.min(40, currentV * 50);
setOverscroll(-bounce);
springBack();
return;
}
if (targetScroll > maxScroll) {
node.scrollTop = maxScroll;
const currentV = Math.abs(v0 * decay);
const bounce = Math.min(40, currentV * 50);
setOverscroll(bounce);
springBack();
return;
}
node.scrollTop = targetScroll;
// Stop when velocity < 0.5 px/sec (completely imperceptible)
if (Math.abs(v0 * decay) > 0.0005) {
animFrame = requestAnimationFrame(coast);
}
}
coast();
}
node.addEventListener("mousedown", onDown);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return {
destroy() {
node.removeEventListener("mousedown", onDown);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
forceReset();
},
};
}

283
src/lib/utils/sounds.ts Normal file
View File

@@ -0,0 +1,283 @@
/**
* Synthesized notification sounds using the Web Audio API.
* No external audio files needed — all sounds are generated programmatically.
*/
let audioCtx: AudioContext | null = null;
function getAudioContext(): AudioContext {
if (!audioCtx) {
audioCtx = new AudioContext();
}
return audioCtx;
}
type SoundPreset = "bell" | "chime" | "soft" | "digital" | "harp" | "bowl" | "rain" | "whistle";
/**
* Play a notification sound with the given preset and volume.
* @param preset - One of: "bell", "chime", "soft", "digital"
* @param volume - 0 to 100
*/
export function playSound(preset: SoundPreset, volume: number): void {
const ctx = getAudioContext();
const gain = ctx.createGain();
gain.connect(ctx.destination);
const vol = (volume / 100) * 0.3; // Scale to reasonable max
switch (preset) {
case "bell":
playBell(ctx, gain, vol);
break;
case "chime":
playChime(ctx, gain, vol);
break;
case "soft":
playSoft(ctx, gain, vol);
break;
case "digital":
playDigital(ctx, gain, vol);
break;
case "harp":
playHarp(ctx, gain, vol);
break;
case "bowl":
playBowl(ctx, gain, vol);
break;
case "rain":
playRain(ctx, gain, vol);
break;
case "whistle":
playWhistle(ctx, gain, vol);
break;
}
}
/** Warm bell — two sine tones with harmonics and slow decay */
function playBell(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
// Fundamental
const osc1 = ctx.createOscillator();
const g1 = ctx.createGain();
osc1.type = "sine";
osc1.frequency.setValueAtTime(830, now);
g1.gain.setValueAtTime(vol, now);
g1.gain.exponentialRampToValueAtTime(0.001, now + 1.5);
osc1.connect(g1);
g1.connect(destination);
osc1.start(now);
osc1.stop(now + 1.5);
// Harmonic
const osc2 = ctx.createOscillator();
const g2 = ctx.createGain();
osc2.type = "sine";
osc2.frequency.setValueAtTime(1245, now);
g2.gain.setValueAtTime(vol * 0.4, now);
g2.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
osc2.connect(g2);
g2.connect(destination);
osc2.start(now);
osc2.stop(now + 1.0);
}
/** Two-note ascending chime */
function playChime(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const notes = [523.25, 659.25]; // C5, E5
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, now);
const start = now + i * 0.15;
g.gain.setValueAtTime(0, start);
g.gain.linearRampToValueAtTime(vol, start + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, start + 0.8);
osc.connect(g);
g.connect(destination);
osc.start(start);
osc.stop(start + 0.8);
});
}
/** Gentle soft ping — filtered triangle wave */
function playSoft(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const g = ctx.createGain();
osc.type = "triangle";
osc.frequency.setValueAtTime(600, now);
osc.frequency.exponentialRampToValueAtTime(400, now + 0.5);
filter.type = "lowpass";
filter.frequency.setValueAtTime(2000, now);
filter.frequency.exponentialRampToValueAtTime(400, now + 0.8);
g.gain.setValueAtTime(vol, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 1.2);
osc.connect(filter);
filter.connect(g);
g.connect(destination);
osc.start(now);
osc.stop(now + 1.2);
}
/** Digital blip — short square wave burst */
function playDigital(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
for (let i = 0; i < 2; i++) {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "square";
osc.frequency.setValueAtTime(880, now);
const start = now + i * 0.12;
g.gain.setValueAtTime(0, start);
g.gain.linearRampToValueAtTime(vol * 0.5, start + 0.01);
g.gain.exponentialRampToValueAtTime(0.001, start + 0.15);
osc.connect(g);
g.connect(destination);
osc.start(start);
osc.stop(start + 0.15);
}
}
/** Harp — cascading arpeggiated sine tones (C5-E5-G5-C6) */
function playHarp(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const notes = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, now);
const start = now + i * 0.09;
g.gain.setValueAtTime(0, start);
g.gain.linearRampToValueAtTime(vol * 0.8, start + 0.01);
g.gain.exponentialRampToValueAtTime(0.001, start + 1.2);
osc.connect(g);
g.connect(destination);
osc.start(start);
osc.stop(start + 1.2);
});
}
/** Singing bowl — low sine with slow beating from detuned pair */
function playBowl(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
// Two slightly detuned sines create a beating/shimmering effect
for (const freq of [293.66, 295.5]) { // ~D4 with 2Hz beat
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, now);
g.gain.setValueAtTime(vol, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 2.5);
osc.connect(g);
g.connect(destination);
osc.start(now);
osc.stop(now + 2.5);
}
// Upper harmonic shimmer
const osc3 = ctx.createOscillator();
const g3 = ctx.createGain();
osc3.type = "sine";
osc3.frequency.setValueAtTime(880, now);
g3.gain.setValueAtTime(vol * 0.15, now);
g3.gain.exponentialRampToValueAtTime(0.001, now + 1.5);
osc3.connect(g3);
g3.connect(destination);
osc3.start(now);
osc3.stop(now + 1.5);
}
/** Rain — filtered noise burst with gentle decay */
function playRain(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const bufferSize = ctx.sampleRate * 1;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
// White noise
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = "bandpass";
filter.frequency.setValueAtTime(3000, now);
filter.frequency.exponentialRampToValueAtTime(800, now + 0.8);
filter.Q.setValueAtTime(0.5, now);
const g = ctx.createGain();
g.gain.setValueAtTime(vol * 0.6, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
noise.connect(filter);
filter.connect(g);
g.connect(destination);
noise.start(now);
noise.stop(now + 1.0);
}
/** Whistle — gentle two-note sine glide */
function playWhistle(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(880, now);
osc.frequency.exponentialRampToValueAtTime(1318.5, now + 0.3); // A5 → E6 glide up
osc.frequency.setValueAtTime(1318.5, now + 0.5);
osc.frequency.exponentialRampToValueAtTime(1046.5, now + 0.8); // E6 → C6 settle
g.gain.setValueAtTime(0, now);
g.gain.linearRampToValueAtTime(vol * 0.5, now + 0.05);
g.gain.setValueAtTime(vol * 0.5, now + 0.6);
g.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
osc.connect(g);
g.connect(destination);
osc.start(now);
osc.stop(now + 1.0);
}
/** Play a completion sound — slightly different from start (descending) */
export function playBreakEndSound(preset: SoundPreset, volume: number): void {
const ctx = getAudioContext();
const gain = ctx.createGain();
gain.connect(ctx.destination);
const vol = (volume / 100) * 0.3;
const now = ctx.currentTime;
// Always a gentle descending two-note resolution
const notes = [659.25, 523.25]; // E5, C5 (descending = "done" feeling)
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = preset === "digital" ? "square" : "sine";
osc.frequency.setValueAtTime(freq, now);
const start = now + i * 0.2;
g.gain.setValueAtTime(0, start);
g.gain.linearRampToValueAtTime(vol, start + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, start + 0.6);
osc.connect(g);
g.connect(gain);
osc.start(start);
osc.stop(start + 0.6);
});
}