feat(web): add onboarding walkthrough + ambient sounds for focus mode
- OnboardingOverlay: 3-step walkthrough for first-time users (localStorage flag) - ambient-sounds.ts: Web Audio API noise generators (rain, white noise, brown noise, coffee shop) - FocusView: ambient sound picker with volume slider, auto-stops on session end
This commit is contained in:
parent
02ac682c52
commit
4e1a22f869
@ -17,6 +17,7 @@ import { BUILT_IN_CATEGORIES, matchesCategory } from '@/lib/categories';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { FeedbackButton } from './FeedbackButton';
|
import { FeedbackButton } from './FeedbackButton';
|
||||||
import { InstallPrompt } from './InstallPrompt';
|
import { InstallPrompt } from './InstallPrompt';
|
||||||
|
import { OnboardingOverlay } from './OnboardingOverlay';
|
||||||
import { useTheme } from '@/lib/use-theme';
|
import { useTheme } from '@/lib/use-theme';
|
||||||
import { getSnoozeSuggestions } from '@/lib/adaptive-snooze';
|
import { getSnoozeSuggestions } from '@/lib/adaptive-snooze';
|
||||||
import type { SnoozeSuggestion } from '@/lib/adaptive-snooze';
|
import type { SnoozeSuggestion } from '@/lib/adaptive-snooze';
|
||||||
@ -127,6 +128,9 @@ export function Dashboard() {
|
|||||||
Skip to content
|
Skip to content
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{/* Onboarding walkthrough for first-time users */}
|
||||||
|
<OnboardingOverlay />
|
||||||
|
|
||||||
{/* Alarm overlay for firing timers */}
|
{/* Alarm overlay for firing timers */}
|
||||||
<AlarmOverlay />
|
<AlarmOverlay />
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,11 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Shield,
|
Shield,
|
||||||
Zap,
|
Zap,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { AMBIENT_SOUNDS, startAmbientSound, stopAmbientSound, setAmbientVolume } from '@/lib/ambient-sounds';
|
||||||
|
import type { AmbientSoundType } from '@/lib/ambient-sounds';
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -54,6 +58,10 @@ export function FocusView({ onExit }: FocusViewProps) {
|
|||||||
const rafRef = useRef<number>(0);
|
const rafRef = useRef<number>(0);
|
||||||
const nextTimer = useTimerStore((s) => s.getNextFiringTimer());
|
const nextTimer = useTimerStore((s) => s.getNextFiringTimer());
|
||||||
|
|
||||||
|
// Ambient sound state
|
||||||
|
const [ambientType, setAmbientType] = useState<AmbientSoundType | null>(null);
|
||||||
|
const [ambientVolume, setAmbientVolumeState] = useState(0.3);
|
||||||
|
|
||||||
// Tick loop for countdown
|
// Tick loop for countdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.isActive) return;
|
if (!session?.isActive) return;
|
||||||
@ -95,6 +103,8 @@ export function FocusView({ onExit }: FocusViewProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const endFocus = useCallback(() => {
|
const endFocus = useCallback(() => {
|
||||||
|
stopAmbientSound();
|
||||||
|
setAmbientType(null);
|
||||||
setSession((prev) => {
|
setSession((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
return { ...prev, isActive: false, isCompleted: true };
|
return { ...prev, isActive: false, isCompleted: true };
|
||||||
@ -367,8 +377,58 @@ export function FocusView({ onExit }: FocusViewProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ambient sound controls */}
|
||||||
|
<div className="mt-6 flex flex-col items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{ambientType ? <Volume2 size={14} style={{ color: 'var(--cm-accent-secondary)' }} /> : <VolumeX size={14} style={{ color: 'var(--cm-text-tertiary)' }} />}
|
||||||
|
<span className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
{ambientType ? AMBIENT_SOUNDS.find(s => s.type === ambientType)?.label : 'No ambient sound'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{AMBIENT_SOUNDS.map((sound) => (
|
||||||
|
<button
|
||||||
|
key={sound.type}
|
||||||
|
onClick={() => {
|
||||||
|
if (ambientType === sound.type) {
|
||||||
|
stopAmbientSound();
|
||||||
|
setAmbientType(null);
|
||||||
|
} else {
|
||||||
|
startAmbientSound(sound.type, ambientVolume);
|
||||||
|
setAmbientType(sound.type);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-all cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: ambientType === sound.type ? 'rgba(46,230,214,0.15)' : 'var(--cm-surface-muted)',
|
||||||
|
color: ambientType === sound.type ? 'var(--cm-accent-secondary)' : 'var(--cm-text-tertiary)',
|
||||||
|
border: ambientType === sound.type ? '1px solid rgba(46,230,214,0.3)' : '1px solid transparent',
|
||||||
|
}}
|
||||||
|
title={sound.description}
|
||||||
|
>
|
||||||
|
{sound.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{ambientType && (
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={Math.round(ambientVolume * 100)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = parseInt(e.target.value) / 100;
|
||||||
|
setAmbientVolumeState(v);
|
||||||
|
setAmbientVolume(v);
|
||||||
|
}}
|
||||||
|
className="w-40 accent-[var(--cm-accent-secondary)]"
|
||||||
|
title={`Volume: ${Math.round(ambientVolume * 100)}%`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Elapsed time */}
|
{/* Elapsed time */}
|
||||||
<div className="mt-6 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
<div className="mt-4 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
Focused for {formatDurationCompact(elapsed)}
|
Focused for {formatDurationCompact(elapsed)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
167
web/src/components/OnboardingOverlay.tsx
Normal file
167
web/src/components/OnboardingOverlay.tsx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Clock, Timer, Bell, ChevronRight, X } from 'lucide-react';
|
||||||
|
|
||||||
|
const ONBOARDING_KEY = 'cm-onboarding-complete';
|
||||||
|
|
||||||
|
interface OnboardingStep {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS: OnboardingStep[] = [
|
||||||
|
{
|
||||||
|
title: 'Create a Timer',
|
||||||
|
description: 'Tap "New Timer" or press N to create your first timer. Try natural language like "meeting in 30 min".',
|
||||||
|
icon: <Timer size={32} />,
|
||||||
|
hint: 'Timers can be alarms, countdowns, Pomodoro sessions, or event countdowns.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Set Pre-Warning Cascades',
|
||||||
|
description: 'Choose how early you want reminders — Aggressive gives you many warnings, Light just a few gentle nudges.',
|
||||||
|
icon: <Bell size={32} />,
|
||||||
|
hint: 'Cascades are what make ChronoMind unique. Never be caught off-guard again.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Watch Your Timeline',
|
||||||
|
description: 'Active timers appear here sorted by fire time. Each card shows countdown, cascade progress, and urgency level.',
|
||||||
|
icon: <Clock size={32} />,
|
||||||
|
hint: 'Try Focus mode (eye icon) for distraction-free sessions, or Routines for multi-step workflows.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function OnboardingOverlay() {
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const done = localStorage.getItem(ONBOARDING_KEY);
|
||||||
|
if (!done) setVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
setVisible(false);
|
||||||
|
localStorage.setItem(ONBOARDING_KEY, 'true');
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
if (step < STEPS.length - 1) {
|
||||||
|
setStep(step + 1);
|
||||||
|
} else {
|
||||||
|
dismiss();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const current = STEPS[step];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-sm mx-4 rounded-2xl border shadow-2xl overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--cm-bg-elevated)',
|
||||||
|
borderColor: 'var(--cm-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Close */}
|
||||||
|
<button
|
||||||
|
onClick={dismiss}
|
||||||
|
className="absolute top-3 right-3 p-1 rounded-lg transition-colors cursor-pointer z-10"
|
||||||
|
style={{ color: 'var(--cm-text-tertiary)' }}
|
||||||
|
aria-label="Skip onboarding"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="flex gap-1.5 px-6 pt-5">
|
||||||
|
{STEPS.map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 h-1 rounded-full transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: i <= step ? 'var(--cm-accent)' : 'var(--cm-surface-muted)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-6 pt-6 pb-4 text-center">
|
||||||
|
<div
|
||||||
|
className="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-4"
|
||||||
|
style={{ backgroundColor: 'rgba(90,140,255,0.12)', color: 'var(--cm-accent)' }}
|
||||||
|
>
|
||||||
|
{current.icon}
|
||||||
|
</div>
|
||||||
|
<h3
|
||||||
|
className="text-lg font-semibold mb-2"
|
||||||
|
style={{ color: 'var(--cm-text-primary)' }}
|
||||||
|
>
|
||||||
|
{current.title}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className="text-sm leading-relaxed mb-3"
|
||||||
|
style={{ color: 'var(--cm-text-secondary)' }}
|
||||||
|
>
|
||||||
|
{current.description}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-xs italic"
|
||||||
|
style={{ color: 'var(--cm-text-tertiary)' }}
|
||||||
|
>
|
||||||
|
{current.hint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="px-6 pb-6 flex gap-3">
|
||||||
|
{step > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(step - 1)}
|
||||||
|
className="flex-1 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--cm-surface-muted)',
|
||||||
|
color: 'var(--cm-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={next}
|
||||||
|
className="flex-1 py-2.5 rounded-xl text-sm font-semibold transition-colors cursor-pointer flex items-center justify-center gap-1"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--cm-accent)',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step === STEPS.length - 1 ? 'Get Started' : 'Next'}
|
||||||
|
{step < STEPS.length - 1 && <ChevronRight size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skip link */}
|
||||||
|
<div className="pb-4 text-center">
|
||||||
|
<button
|
||||||
|
onClick={dismiss}
|
||||||
|
className="text-xs cursor-pointer underline"
|
||||||
|
style={{ color: 'var(--cm-text-tertiary)' }}
|
||||||
|
>
|
||||||
|
Skip walkthrough
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
web/src/lib/ambient-sounds.ts
Normal file
214
web/src/lib/ambient-sounds.ts
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
// ── Ambient Background Sounds ────────────────────────────────
|
||||||
|
// Web Audio API noise generators for focus mode.
|
||||||
|
// No external audio files — all generated programmatically.
|
||||||
|
|
||||||
|
export type AmbientSoundType = 'rain' | 'white_noise' | 'brown_noise' | 'coffee_shop';
|
||||||
|
|
||||||
|
export interface AmbientSoundConfig {
|
||||||
|
type: AmbientSoundType;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AMBIENT_SOUNDS: AmbientSoundConfig[] = [
|
||||||
|
{ type: 'rain', label: 'Rain', description: 'Gentle rainfall' },
|
||||||
|
{ type: 'white_noise', label: 'White Noise', description: 'Even static hiss' },
|
||||||
|
{ type: 'brown_noise', label: 'Brown Noise', description: 'Deep, warm rumble' },
|
||||||
|
{ type: 'coffee_shop', label: 'Coffee Shop', description: 'Low murmur ambience' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let audioCtx: AudioContext | null = null;
|
||||||
|
let activeNodes: AudioNode[] = [];
|
||||||
|
let gainNode: GainNode | null = null;
|
||||||
|
|
||||||
|
function getAudioContext(): AudioContext {
|
||||||
|
if (!audioCtx) {
|
||||||
|
audioCtx = new AudioContext();
|
||||||
|
}
|
||||||
|
if (audioCtx.state === 'suspended') {
|
||||||
|
audioCtx.resume();
|
||||||
|
}
|
||||||
|
return audioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAll(): void {
|
||||||
|
for (const node of activeNodes) {
|
||||||
|
try {
|
||||||
|
node.disconnect();
|
||||||
|
} catch {
|
||||||
|
// already disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeNodes = [];
|
||||||
|
gainNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create white noise: uniform random samples.
|
||||||
|
*/
|
||||||
|
function createWhiteNoise(ctx: AudioContext, gain: GainNode): void {
|
||||||
|
const bufferSize = ctx.sampleRate * 2; // 2 seconds looping
|
||||||
|
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
data[i] = Math.random() * 2 - 1;
|
||||||
|
}
|
||||||
|
const source = ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.loop = true;
|
||||||
|
source.connect(gain);
|
||||||
|
source.start();
|
||||||
|
activeNodes.push(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create brown noise: accumulated random walk with low-pass filtering.
|
||||||
|
*/
|
||||||
|
function createBrownNoise(ctx: AudioContext, gain: GainNode): void {
|
||||||
|
const bufferSize = ctx.sampleRate * 2;
|
||||||
|
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
let last = 0;
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
const white = Math.random() * 2 - 1;
|
||||||
|
last = (last + 0.02 * white) / 1.02;
|
||||||
|
data[i] = last * 3.5; // normalize
|
||||||
|
}
|
||||||
|
const source = ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.loop = true;
|
||||||
|
source.connect(gain);
|
||||||
|
source.start();
|
||||||
|
activeNodes.push(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create rain-like sound: filtered noise with gentle modulation.
|
||||||
|
*/
|
||||||
|
function createRain(ctx: AudioContext, gain: GainNode): void {
|
||||||
|
// Base: brown noise for body
|
||||||
|
const bufferSize = ctx.sampleRate * 4;
|
||||||
|
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
let last = 0;
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
const white = Math.random() * 2 - 1;
|
||||||
|
last = (last + 0.02 * white) / 1.02;
|
||||||
|
// Add occasional louder "drops"
|
||||||
|
const drop = Math.random() < 0.001 ? (Math.random() * 0.3) : 0;
|
||||||
|
data[i] = (last * 2.5) + drop;
|
||||||
|
}
|
||||||
|
const source = ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.loop = true;
|
||||||
|
|
||||||
|
// Band-pass filter to shape like rain
|
||||||
|
const filter = ctx.createBiquadFilter();
|
||||||
|
filter.type = 'bandpass';
|
||||||
|
filter.frequency.value = 2000;
|
||||||
|
filter.Q.value = 0.5;
|
||||||
|
|
||||||
|
source.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
source.start();
|
||||||
|
activeNodes.push(source, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create coffee shop ambience: layered filtered noise.
|
||||||
|
*/
|
||||||
|
function createCoffeeShop(ctx: AudioContext, gain: GainNode): void {
|
||||||
|
// Layer 1: low murmur (brown noise, low-pass)
|
||||||
|
const bufferSize = ctx.sampleRate * 3;
|
||||||
|
const buffer1 = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||||
|
const data1 = buffer1.getChannelData(0);
|
||||||
|
let last1 = 0;
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
const white = Math.random() * 2 - 1;
|
||||||
|
last1 = (last1 + 0.02 * white) / 1.02;
|
||||||
|
data1[i] = last1 * 2;
|
||||||
|
}
|
||||||
|
const source1 = ctx.createBufferSource();
|
||||||
|
source1.buffer = buffer1;
|
||||||
|
source1.loop = true;
|
||||||
|
const lpf = ctx.createBiquadFilter();
|
||||||
|
lpf.type = 'lowpass';
|
||||||
|
lpf.frequency.value = 600;
|
||||||
|
const gain1 = ctx.createGain();
|
||||||
|
gain1.gain.value = 0.7;
|
||||||
|
source1.connect(lpf);
|
||||||
|
lpf.connect(gain1);
|
||||||
|
gain1.connect(gain);
|
||||||
|
source1.start();
|
||||||
|
|
||||||
|
// Layer 2: high chatter (white noise, band-pass)
|
||||||
|
const buffer2 = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||||
|
const data2 = buffer2.getChannelData(0);
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
data2[i] = Math.random() * 2 - 1;
|
||||||
|
}
|
||||||
|
const source2 = ctx.createBufferSource();
|
||||||
|
source2.buffer = buffer2;
|
||||||
|
source2.loop = true;
|
||||||
|
const bpf = ctx.createBiquadFilter();
|
||||||
|
bpf.type = 'bandpass';
|
||||||
|
bpf.frequency.value = 1500;
|
||||||
|
bpf.Q.value = 1.5;
|
||||||
|
const gain2 = ctx.createGain();
|
||||||
|
gain2.gain.value = 0.15;
|
||||||
|
source2.connect(bpf);
|
||||||
|
bpf.connect(gain2);
|
||||||
|
gain2.connect(gain);
|
||||||
|
source2.start();
|
||||||
|
|
||||||
|
activeNodes.push(source1, lpf, gain1, source2, bpf, gain2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start playing an ambient sound at given volume (0-1).
|
||||||
|
*/
|
||||||
|
export function startAmbientSound(type: AmbientSoundType, volume: number = 0.3): void {
|
||||||
|
stopAll();
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
gainNode = ctx.createGain();
|
||||||
|
gainNode.gain.value = Math.max(0, Math.min(1, volume));
|
||||||
|
gainNode.connect(ctx.destination);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'rain':
|
||||||
|
createRain(ctx, gainNode);
|
||||||
|
break;
|
||||||
|
case 'white_noise':
|
||||||
|
createWhiteNoise(ctx, gainNode);
|
||||||
|
break;
|
||||||
|
case 'brown_noise':
|
||||||
|
createBrownNoise(ctx, gainNode);
|
||||||
|
break;
|
||||||
|
case 'coffee_shop':
|
||||||
|
createCoffeeShop(ctx, gainNode);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop all ambient sounds.
|
||||||
|
*/
|
||||||
|
export function stopAmbientSound(): void {
|
||||||
|
stopAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the volume of the currently playing ambient sound.
|
||||||
|
*/
|
||||||
|
export function setAmbientVolume(volume: number): void {
|
||||||
|
if (gainNode) {
|
||||||
|
gainNode.gain.value = Math.max(0, Math.min(1, volume));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if ambient sound is currently playing.
|
||||||
|
*/
|
||||||
|
export function isAmbientPlaying(): boolean {
|
||||||
|
return activeNodes.length > 0;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user