Initial commit -- Core Cooldown v0.1.0
This commit is contained in:
115
src/lib/utils/activities.ts
Normal file
115
src/lib/utils/activities.ts
Normal 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
366
src/lib/utils/animate.ts
Normal 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
283
src/lib/utils/sounds.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user