feat(audio): Haus ambient sounds + YouTube player fix

WhiteNoise replaced with full ambient mixer:
- 6 categories: Noise, Rain, Nature, Places, Things, Animals
- 29 real ambient sounds from Haus (MIT licensed)
- Howler.js for playback with looping and per-sound volume
- Mix multiple sounds simultaneously
- Category tab navigation
- 300ms fade in/out for smooth toggling

MusicPlayer fixes:
- Removed origin param (causes issues behind reverse proxy)
- Added onError handler for YouTube errors
- Added onStateChange to track playing state
- Player container 1x1 opacity:0 instead of offscreen positioning
This commit is contained in:
2026-06-05 15:12:51 -04:00
parent 5131eb729f
commit 5dbe30b43c
95 changed files with 256 additions and 245 deletions
+25 -10
View File
@@ -8,6 +8,7 @@
"name": "kira",
"version": "0.1.0",
"dependencies": {
"howler": "^2.2.4",
"pixi-live2d-display": "^0.4.0",
"pixi.js": "^7.4.3",
"react": "^19.2.6",
@@ -15,13 +16,14 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.6",
"@types/howler": "^2.2.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.6",
"typescript": "~6.0.2",
"typescript": "^6.0.3",
"vite": "^8.0.12"
}
},
@@ -871,6 +873,13 @@
"integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==",
"license": "MIT"
},
"node_modules/@types/howler": {
"version": "2.2.13",
"resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.13.tgz",
"integrity": "sha512-40+EBjqIHHrC4VShlz/7i0lBUsE3QkgzZinQQji74Hd8sBkJZUBaT7LWFLK6rcabsDOOQpoMbEJvtaFQwxOu/g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
@@ -998,9 +1007,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.33",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
"integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
"version": "2.10.34",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
"integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -1160,9 +1169,9 @@
"license": "ISC"
},
"node_modules/electron-to-chromium": {
"version": "1.5.367",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.367.tgz",
"integrity": "sha512-4Mk/mrynCNQ+atY40D3UpmhLWB6AHMbYMlIrPhHcMF6x0L7O0b052FCAsxw1LlaR++UFuNg3D/A6XCuGDa0guQ==",
"version": "1.5.368",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz",
"integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==",
"dev": true,
"license": "ISC"
},
@@ -1173,9 +1182,9 @@
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.22.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz",
"integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==",
"version": "5.23.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz",
"integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1512,6 +1521,12 @@
"node": ">= 0.4"
}
},
"node_modules/howler": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz",
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==",
"license": "MIT"
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+3 -1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"howler": "^2.2.4",
"pixi-live2d-display": "^0.4.0",
"pixi.js": "^7.4.3",
"react": "^19.2.6",
@@ -16,13 +17,14 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.6",
"@types/howler": "^2.2.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.6",
"typescript": "~6.0.2",
"typescript": "^6.0.3",
"vite": "^8.0.12"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+59 -58
View File
@@ -17,85 +17,89 @@ export default function MusicPlayer() {
const [activeId, setActiveId] = useState<string>('lofi-girl');
const [volume, setVolume] = useState(0.3);
const [started, setStarted] = useState(false);
const [playerReady, setPlayerReady] = useState(false);
const [playing, setPlaying] = useState(false);
const playerRef = useRef<any>(null);
const volumeRef = useRef(volume);
const activeIdRef = useRef(activeId);
const readyResolveRef = useRef<(() => void) | null>(null);
// Keep refs in sync so the API callback sees current values
useEffect(() => { volumeRef.current = volume; }, [volume]);
useEffect(() => { activeIdRef.current = activeId; }, [activeId]);
// Load YouTube IFrame API
const loadAPI = useCallback(() => {
if (document.querySelector('script[src="https://www.youtube.com/iframe_api"]')) {
// Already loaded — just wait for YT to be ready
if ((window as any).YT?.Player) {
createPlayer();
}
return;
}
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
(window as any).onYouTubeIframeAPIReady = () => {
createPlayer();
};
}, []);
const createPlayer = useCallback(() => {
// Load YouTube IFrame API and create player
const initPlayer = useCallback(() => {
const active = LOFI_PLAYLISTS.find((p) => p.id === activeIdRef.current);
if (!active || playerRef.current) return;
playerRef.current = new (window as any).YT.Player('kira-youtube-player', {
height: '1',
width: '1',
videoId: active.videoId,
playerVars: {
autoplay: 1,
controls: 0,
loop: 1,
playlist: active.videoId,
enablejsapi: 1,
origin: window.location.origin,
modestbranding: 1,
fs: 0,
},
events: {
onReady: (e: any) => {
e.target.setVolume(Math.round(volumeRef.current * 100));
e.target.playVideo();
setPlayerReady(true);
const createYT = () => {
playerRef.current = new (window as any).YT.Player('kira-youtube-player', {
height: '1',
width: '1',
videoId: active.videoId,
playerVars: {
autoplay: 1,
controls: 0,
loop: 1,
playlist: active.videoId,
enablejsapi: 1,
modestbranding: 1,
fs: 0,
rel: 0,
},
},
});
events: {
onReady: (e: any) => {
e.target.setVolume(Math.round(volumeRef.current * 100));
e.target.playVideo();
setPlaying(true);
},
onStateChange: (e: any) => {
// YT.PlayerState.PLAYING = 1, PAUSED = 2, ENDED = 0
setPlaying(e.data === 1);
},
onError: (e: any) => {
console.warn('[MusicPlayer] YouTube error:', e.data);
},
},
});
};
if ((window as any).YT?.Player) {
createYT();
} else {
// Load the script
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
(window as any).onYouTubeIframeAPIReady = createYT;
}
}, []);
const handlePlay = () => {
setStarted(true);
loadAPI();
initPlayer();
};
const changeStation = (id: string) => {
setActiveId(id);
if (playerRef.current && playerReady) {
if (playerRef.current && (window as any).YT) {
const active = LOFI_PLAYLISTS.find((p) => p.id === id);
if (active) {
playerRef.current.loadVideoById(active.videoId);
playerRef.current.setVolume(Math.round(volume * 100));
playerRef.current.playVideo();
try {
playerRef.current.loadVideoById(active.videoId);
playerRef.current.setVolume(Math.round(volume * 100));
} catch {}
}
}
};
// Volume sync
useEffect(() => {
if (playerRef.current && playerReady) {
playerRef.current.setVolume(Math.round(volume * 100));
}
}, [volume, playerReady]);
try {
if (playerRef.current?.setVolume) {
playerRef.current.setVolume(Math.round(volume * 100));
}
} catch {}
}, [volume]);
return (
<div className="p-4">
@@ -134,11 +138,8 @@ export default function MusicPlayer() {
))}
</div>
{/* YouTube player: NOT display:none (API won't init). Offscreen instead. */}
<div
id="kira-youtube-player"
style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 1, height: 1 }}
/>
{/* YouTube player: must have real dimensions for the API to work */}
<div id="kira-youtube-player" style={{ width: 1, height: 1, opacity: 0, position: 'absolute', pointerEvents: 'none' }} />
<div className="relative rounded-xl overflow-hidden bg-white/30" style={{ height: 60 }}>
{!started ? (
@@ -151,7 +152,7 @@ export default function MusicPlayer() {
</button>
) : (
<div className="w-full h-full flex items-center justify-center text-kira-plum/40 text-xs gap-2">
<span className="w-2 h-2 rounded-full bg-kira-mint animate-pulse inline-block" />
<span className={`w-2 h-2 rounded-full animate-pulse inline-block ${playing ? 'bg-kira-mint' : 'bg-kira-plum/30'}`} />
<span>playing {LOFI_PLAYLISTS.find((p) => p.id === activeId)?.name}</span>
</div>
)}
+169 -176
View File
@@ -1,201 +1,194 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { Howl } from 'howler';
type NoiseType = 'white' | 'pink' | 'brown' | 'rain' | 'cafe';
interface AmbientSound {
id: string;
label: string;
icon: string;
src: string;
}
const NOISE_PRESETS: { id: NoiseType; label: string; icon: string }[] = [
{ id: 'white', label: 'White', icon: '⚪' },
{ id: 'pink', label: 'Pink', icon: '🌸' },
{ id: 'brown', label: 'Brown', icon: '🤎' },
{ id: 'rain', label: 'Rain', icon: '🌧️' },
{ id: 'cafe', label: 'Cafe', icon: '☕' },
interface Category {
id: string;
label: string;
icon: string;
sounds: AmbientSound[];
}
const CATEGORIES: Category[] = [
{
id: 'noise', label: 'Noise', icon: '🔉',
sounds: [
{ id: 'white-noise', label: 'White', icon: '⚪', src: '/sounds/noise/white-noise.wav' },
{ id: 'pink-noise', label: 'Pink', icon: '🌸', src: '/sounds/noise/pink-noise.wav' },
{ id: 'brown-noise', label: 'Brown', icon: '🤎', src: '/sounds/noise/brown-noise.wav' },
],
},
{
id: 'rain', label: 'Rain', icon: '🌧️',
sounds: [
{ id: 'light-rain', label: 'Light', icon: '🌦️', src: '/sounds/rain/light-rain.mp3' },
{ id: 'heavy-rain', label: 'Heavy', icon: '⛈️', src: '/sounds/rain/heavy-rain.mp3' },
{ id: 'rain-on-window', label: 'Window', icon: '🪟', src: '/sounds/rain/rain-on-window.mp3' },
{ id: 'thunder', label: 'Thunder', icon: '⚡', src: '/sounds/rain/thunder.mp3' },
{ id: 'rain-on-tent', label: 'Tent', icon: '⛺', src: '/sounds/rain/rain-on-tent.mp3' },
],
},
{
id: 'nature', label: 'Nature', icon: '🌿',
sounds: [
{ id: 'waves', label: 'Waves', icon: '🌊', src: '/sounds/nature/waves.mp3' },
{ id: 'campfire', label: 'Campfire', icon: '🔥', src: '/sounds/nature/campfire.mp3' },
{ id: 'river', label: 'River', icon: '🏞️', src: '/sounds/nature/river.mp3' },
{ id: 'wind', label: 'Wind', icon: '💨', src: '/sounds/nature/wind.mp3' },
{ id: 'jungle', label: 'Jungle', icon: '🌴', src: '/sounds/nature/jungle.mp3' },
],
},
{
id: 'places', label: 'Places', icon: '☕',
sounds: [
{ id: 'cafe', label: 'Cafe', icon: '☕', src: '/sounds/places/cafe.mp3' },
{ id: 'library', label: 'Library', icon: '📚', src: '/sounds/places/library.mp3' },
{ id: 'office', label: 'Office', icon: '🏢', src: '/sounds/places/office.mp3' },
{ id: 'restaurant', label: 'Restaurant', icon: '🍽️', src: '/sounds/places/restaurant.mp3' },
],
},
{
id: 'things', label: 'Things', icon: '🎵',
sounds: [
{ id: 'keyboard', label: 'Keyboard', icon: '⌨️', src: '/sounds/things/keyboard.mp3' },
{ id: 'clock', label: 'Clock', icon: '🕐', src: '/sounds/things/clock.mp3' },
{ id: 'ceiling-fan', label: 'Fan', icon: '🌀', src: '/sounds/things/ceiling-fan.mp3' },
{ id: 'vinyl-effect', label: 'Vinyl', icon: '💿', src: '/sounds/things/vinyl-effect.mp3' },
{ id: 'wind-chimes', label: 'Chimes', icon: '🎐', src: '/sounds/things/wind-chimes.mp3' },
],
},
{
id: 'animals', label: 'Animals', icon: '🐾',
sounds: [
{ id: 'birds', label: 'Birds', icon: '🐦', src: '/sounds/animals/birds.mp3' },
{ id: 'cat-purring', label: 'Cat', icon: '🐱', src: '/sounds/animals/cat-purring.mp3' },
{ id: 'crickets', label: 'Crickets', icon: '🦗', src: '/sounds/animals/crickets.mp3' },
{ id: 'owl', label: 'Owl', icon: '🦉', src: '/sounds/animals/owl.mp3' },
{ id: 'whale', label: 'Whale', icon: '🐋', src: '/sounds/animals/whale.mp3' },
],
},
];
export default function WhiteNoise() {
const [active, setActive] = useState<NoiseType | null>(null);
const [volume, setVolume] = useState(0.25);
const ctxRef = useRef<AudioContext | null>(null);
const gainRef = useRef<GainNode | null>(null);
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
// For rain/cafe: extra nodes to tear down
const extraNodesRef = useRef<AudioNode[]>([]);
const [activeTab, setActiveTab] = useState<string>('noise');
const [playing, setPlaying] = useState<Record<string, number>>({}); // id -> volume 0-1
const howlsRef = useRef<Map<string, Howl>>(new Map());
const stopNoise = useCallback(() => {
if (sourceRef.current) {
try { sourceRef.current.stop(); } catch {}
sourceRef.current.disconnect();
sourceRef.current = null;
const activeCategory = CATEGORIES.find((c) => c.id === activeTab) ?? CATEGORIES[0];
const toggle = useCallback((sound: AmbientSound) => {
const existing = howlsRef.current.get(sound.id);
if (existing && existing.playing()) {
existing.fade(existing.volume(), 0, 300);
setTimeout(() => {
existing.stop();
existing.unload();
howlsRef.current.delete(sound.id);
}, 300);
setPlaying((prev) => {
const next = { ...prev };
delete next[sound.id];
return next;
});
} else {
const howl = new Howl({
src: [sound.src],
loop: true,
volume: 0.5,
html5: true,
});
howl.fade(0, 0.5, 300);
howl.play();
howlsRef.current.set(sound.id, howl);
setPlaying((prev) => ({ ...prev, [sound.id]: 0.5 }));
}
extraNodesRef.current.forEach((n) => { try { n.disconnect(); } catch {} });
extraNodesRef.current = [];
setActive(null);
}, []);
const startNoise = useCallback((type: NoiseType) => {
stopNoise();
const setSoundVolume = useCallback((id: string, vol: number) => {
const howl = howlsRef.current.get(id);
if (howl) howl.volume(vol);
setPlaying((prev) => ({ ...prev, [id]: vol }));
}, []);
const ctx = ctxRef.current || new (window.AudioContext || (window as any).webkitAudioContext)();
ctxRef.current = ctx;
if (ctx.state === 'suspended') ctx.resume();
const sr = ctx.sampleRate;
// 10-second buffer — long enough to avoid obvious loops, short enough to generate fast
const len = 10 * sr;
const buf = ctx.createBuffer(2, len, sr); // stereo for spatial depth
for (let ch = 0; ch < 2; ch++) {
const d = buf.getChannelData(ch);
if (type === 'white') {
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
} else if (type === 'pink') {
// Paul Kellet's pink noise filter (industry standard)
let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
for (let i = 0; i < len; i++) {
const w = Math.random() * 2 - 1;
b0 = 0.99886 * b0 + w * 0.0555179;
b1 = 0.99332 * b1 + w * 0.0750759;
b2 = 0.96900 * b2 + w * 0.1538520;
b3 = 0.86650 * b3 + w * 0.3104856;
b4 = 0.55000 * b4 + w * 0.5329522;
b5 = -0.7616 * b5 - w * 0.0168980;
d[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362) * 0.11;
b6 = w * 0.115926;
}
} else if (type === 'brown') {
// Brownian motion (integrated white noise)
let last = 0;
for (let i = 0; i < len; i++) {
last += (Math.random() * 2 - 1) * 0.02;
last = Math.max(-1, Math.min(1, last)); // clamp
d[i] = last;
}
} else if (type === 'rain') {
// Layered: low rumble (brown) + random droplet clicks
let last = 0;
for (let i = 0; i < len; i++) {
const w = Math.random() * 2 - 1;
// Brown base for rumble
last += w * 0.005;
last *= 0.998;
// Random droplet pings (sparse impulses filtered)
const drop = Math.random() < 0.0008 ? (Math.random() * 0.6 + 0.2) : 0;
d[i] = last * 0.6 + drop * Math.sin(i * 0.13 + ch) * 0.4;
}
} else if (type === 'cafe') {
// Low brown hum + scattered high transients (clatter)
let last = 0;
for (let i = 0; i < len; i++) {
const w = Math.random() * 2 - 1;
last += w * 0.003;
last *= 0.999;
// Occasional clatter
const clatter = Math.random() < 0.0005 ? (Math.random() * 0.4 + 0.1) * Math.sin(i * 0.47) : 0;
// Distant murmur (band-limited noise burst)
const murmur = Math.random() * 0.02;
d[i] = last * 0.5 + clatter + murmur;
}
}
}
// Crossfade start/end for seamless loop
const fade = Math.min(sr * 0.05, len / 4); // 50ms fade
for (let i = 0; i < fade; i++) {
const t = i / fade;
for (let ch = 0; ch < 2; ch++) {
const d = buf.getChannelData(ch);
d[i] *= t; // fade in
d[len - 1 - i] *= t; // fade out
}
}
const src = ctx.createBufferSource();
src.buffer = buf;
src.loop = true;
const gain = ctx.createGain();
gain.gain.value = volume;
let tail: AudioNode = src;
// For rain/cafe: add filtering for warmth
if (type === 'rain') {
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 6000;
lp.Q.value = 0.5;
tail.connect(lp);
tail = lp;
extraNodesRef.current.push(lp);
} else if (type === 'cafe') {
const bp = ctx.createBiquadFilter();
bp.type = 'lowpass';
bp.frequency.value = 3000;
bp.Q.value = 0.8;
tail.connect(bp);
tail = bp;
extraNodesRef.current.push(bp);
}
tail.connect(gain);
gain.connect(ctx.destination);
src.start();
sourceRef.current = src;
gainRef.current = gain;
setActive(type);
}, [volume, stopNoise]);
const toggle = (type: NoiseType) => {
if (active === type) stopNoise();
else startNoise(type);
};
const changeVolume = (v: number) => {
setVolume(v);
if (gainRef.current) gainRef.current.gain.value = v;
};
// Cleanup on unmount
useEffect(() => () => {
stopNoise();
ctxRef.current?.close().catch(() => {});
}, [stopNoise]);
// Cleanup all on unmount
useEffect(() => {
return () => {
howlsRef.current.forEach((h) => { try { h.stop(); h.unload(); } catch {} });
howlsRef.current.clear();
};
}, []);
return (
<div className="p-4">
<h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2">
<span>🌬</span> White Noise
<span>🌬</span> Ambient
</h3>
<div className="flex flex-wrap gap-2 mb-3">
{NOISE_PRESETS.map((p) => (
{/* Category tabs */}
<div className="flex gap-1 mb-3 flex-wrap">
{CATEGORIES.map((cat) => (
<button
key={p.id}
onClick={() => toggle(p.id)}
className={`text-xs px-3 py-1.5 rounded-full transition-all flex items-center gap-1 ${
active === p.id
? 'bg-kira-pink text-white shadow'
: 'bg-white/50 text-kira-plum/70 hover:bg-kira-glow'
key={cat.id}
onClick={() => setActiveTab(cat.id)}
className={`text-[10px] px-2 py-1 rounded-lg transition-all flex items-center gap-1 ${
activeTab === cat.id
? 'bg-kira-pink/20 text-kira-pink font-bold'
: 'text-kira-plum/40 hover:text-kira-plum/60'
}`}
>
<span>{p.icon}</span>
<span>{p.label}</span>
<span>{cat.icon}</span>
<span>{cat.label}</span>
</button>
))}
</div>
<div className="flex items-center gap-2 text-xs text-kira-plum/60">
<span>Vol</span>
<input
type="range"
min={0}
max={0.6}
step={0.02}
value={volume}
onChange={(e) => changeVolume(parseFloat(e.target.value))}
className="flex-1 accent-kira-pink"
/>
<span className="tabular-nums w-8 text-right">{Math.round(volume * 100)}%</span>
{/* Sound grid */}
<div className="flex flex-col gap-2">
{activeCategory.sounds.map((sound) => {
const vol = playing[sound.id];
const isActive = vol !== undefined;
return (
<div key={sound.id} className="flex items-center gap-2">
<button
onClick={() => toggle(sound)}
className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-sm transition-all ${
isActive
? 'bg-kira-pink text-white shadow'
: 'bg-white/40 text-kira-plum/50 hover:bg-white/60'
}`}
>
{sound.icon}
</button>
<span className={`text-[11px] w-14 shrink-0 ${isActive ? 'text-kira-plum font-medium' : 'text-kira-plum/40'}`}>
{sound.label}
</span>
{isActive && (
<input
type="range"
min={0}
max={1}
step={0.02}
value={vol}
onChange={(e) => setSoundVolume(sound.id, parseFloat(e.target.value))}
className="flex-1 accent-kira-pink"
/>
)}
</div>
);
})}
</div>
{active && (
<div className="mt-2 text-[10px] text-kira-mint/70">Playing {active} noise body double approved </div>
{/* Active sounds count */}
{Object.keys(playing).length > 0 && (
<div className="mt-2 text-[10px] text-kira-mint/70">
{Object.keys(playing).length} sound{Object.keys(playing).length > 1 ? 's' : ''} mixing
</div>
)}
</div>
);