// ── 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; }