- 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
215 lines
5.9 KiB
TypeScript
215 lines
5.9 KiB
TypeScript
// ── 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;
|
|
}
|