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:
Generated
+25
-10
@@ -8,6 +8,7 @@
|
|||||||
"name": "kira",
|
"name": "kira",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"howler": "^2.2.4",
|
||||||
"pixi-live2d-display": "^0.4.0",
|
"pixi-live2d-display": "^0.4.0",
|
||||||
"pixi.js": "^7.4.3",
|
"pixi.js": "^7.4.3",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
@@ -15,13 +16,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.6",
|
"@tailwindcss/vite": "^4.1.6",
|
||||||
|
"@types/howler": "^2.2.13",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"tailwindcss": "^4.1.6",
|
"tailwindcss": "^4.1.6",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.12"
|
"vite": "^8.0.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -871,6 +873,13 @@
|
|||||||
"integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==",
|
"integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/offscreencanvas": {
|
||||||
"version": "2019.7.3",
|
"version": "2019.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
|
||||||
@@ -998,9 +1007,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.33",
|
"version": "2.10.34",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
|
||||||
"integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
|
"integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1160,9 +1169,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.367",
|
"version": "1.5.368",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.367.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz",
|
||||||
"integrity": "sha512-4Mk/mrynCNQ+atY40D3UpmhLWB6AHMbYMlIrPhHcMF6x0L7O0b052FCAsxw1LlaR++UFuNg3D/A6XCuGDa0guQ==",
|
"integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -1173,9 +1182,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.22.2",
|
"version": "5.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz",
|
||||||
"integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==",
|
"integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1512,6 +1521,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"howler": "^2.2.4",
|
||||||
"pixi-live2d-display": "^0.4.0",
|
"pixi-live2d-display": "^0.4.0",
|
||||||
"pixi.js": "^7.4.3",
|
"pixi.js": "^7.4.3",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
@@ -16,13 +17,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.6",
|
"@tailwindcss/vite": "^4.1.6",
|
||||||
|
"@types/howler": "^2.2.13",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"tailwindcss": "^4.1.6",
|
"tailwindcss": "^4.1.6",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.12"
|
"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.
@@ -17,85 +17,89 @@ export default function MusicPlayer() {
|
|||||||
const [activeId, setActiveId] = useState<string>('lofi-girl');
|
const [activeId, setActiveId] = useState<string>('lofi-girl');
|
||||||
const [volume, setVolume] = useState(0.3);
|
const [volume, setVolume] = useState(0.3);
|
||||||
const [started, setStarted] = useState(false);
|
const [started, setStarted] = useState(false);
|
||||||
const [playerReady, setPlayerReady] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const playerRef = useRef<any>(null);
|
const playerRef = useRef<any>(null);
|
||||||
const volumeRef = useRef(volume);
|
const volumeRef = useRef(volume);
|
||||||
const activeIdRef = useRef(activeId);
|
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(() => { volumeRef.current = volume; }, [volume]);
|
||||||
useEffect(() => { activeIdRef.current = activeId; }, [activeId]);
|
useEffect(() => { activeIdRef.current = activeId; }, [activeId]);
|
||||||
|
|
||||||
// Load YouTube IFrame API
|
// Load YouTube IFrame API and create player
|
||||||
const loadAPI = useCallback(() => {
|
const initPlayer = 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(() => {
|
|
||||||
const active = LOFI_PLAYLISTS.find((p) => p.id === activeIdRef.current);
|
const active = LOFI_PLAYLISTS.find((p) => p.id === activeIdRef.current);
|
||||||
if (!active || playerRef.current) return;
|
if (!active || playerRef.current) return;
|
||||||
|
|
||||||
playerRef.current = new (window as any).YT.Player('kira-youtube-player', {
|
const createYT = () => {
|
||||||
height: '1',
|
playerRef.current = new (window as any).YT.Player('kira-youtube-player', {
|
||||||
width: '1',
|
height: '1',
|
||||||
videoId: active.videoId,
|
width: '1',
|
||||||
playerVars: {
|
videoId: active.videoId,
|
||||||
autoplay: 1,
|
playerVars: {
|
||||||
controls: 0,
|
autoplay: 1,
|
||||||
loop: 1,
|
controls: 0,
|
||||||
playlist: active.videoId,
|
loop: 1,
|
||||||
enablejsapi: 1,
|
playlist: active.videoId,
|
||||||
origin: window.location.origin,
|
enablejsapi: 1,
|
||||||
modestbranding: 1,
|
modestbranding: 1,
|
||||||
fs: 0,
|
fs: 0,
|
||||||
},
|
rel: 0,
|
||||||
events: {
|
|
||||||
onReady: (e: any) => {
|
|
||||||
e.target.setVolume(Math.round(volumeRef.current * 100));
|
|
||||||
e.target.playVideo();
|
|
||||||
setPlayerReady(true);
|
|
||||||
},
|
},
|
||||||
},
|
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 = () => {
|
const handlePlay = () => {
|
||||||
setStarted(true);
|
setStarted(true);
|
||||||
loadAPI();
|
initPlayer();
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeStation = (id: string) => {
|
const changeStation = (id: string) => {
|
||||||
setActiveId(id);
|
setActiveId(id);
|
||||||
if (playerRef.current && playerReady) {
|
if (playerRef.current && (window as any).YT) {
|
||||||
const active = LOFI_PLAYLISTS.find((p) => p.id === id);
|
const active = LOFI_PLAYLISTS.find((p) => p.id === id);
|
||||||
if (active) {
|
if (active) {
|
||||||
playerRef.current.loadVideoById(active.videoId);
|
try {
|
||||||
playerRef.current.setVolume(Math.round(volume * 100));
|
playerRef.current.loadVideoById(active.videoId);
|
||||||
playerRef.current.playVideo();
|
playerRef.current.setVolume(Math.round(volume * 100));
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Volume sync
|
// Volume sync
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (playerRef.current && playerReady) {
|
try {
|
||||||
playerRef.current.setVolume(Math.round(volume * 100));
|
if (playerRef.current?.setVolume) {
|
||||||
}
|
playerRef.current.setVolume(Math.round(volume * 100));
|
||||||
}, [volume, playerReady]);
|
}
|
||||||
|
} catch {}
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
@@ -134,11 +138,8 @@ export default function MusicPlayer() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* YouTube player: NOT display:none (API won't init). Offscreen instead. */}
|
{/* YouTube player: must have real dimensions for the API to work */}
|
||||||
<div
|
<div id="kira-youtube-player" style={{ width: 1, height: 1, opacity: 0, position: 'absolute', pointerEvents: 'none' }} />
|
||||||
id="kira-youtube-player"
|
|
||||||
style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 1, height: 1 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative rounded-xl overflow-hidden bg-white/30" style={{ height: 60 }}>
|
<div className="relative rounded-xl overflow-hidden bg-white/30" style={{ height: 60 }}>
|
||||||
{!started ? (
|
{!started ? (
|
||||||
@@ -151,7 +152,7 @@ export default function MusicPlayer() {
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center text-kira-plum/40 text-xs gap-2">
|
<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>
|
<span>playing {LOFI_PLAYLISTS.find((p) => p.id === activeId)?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 }[] = [
|
interface Category {
|
||||||
{ id: 'white', label: 'White', icon: '⚪' },
|
id: string;
|
||||||
{ id: 'pink', label: 'Pink', icon: '🌸' },
|
label: string;
|
||||||
{ id: 'brown', label: 'Brown', icon: '🤎' },
|
icon: string;
|
||||||
{ id: 'rain', label: 'Rain', icon: '🌧️' },
|
sounds: AmbientSound[];
|
||||||
{ id: 'cafe', label: 'Cafe', icon: '☕' },
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function WhiteNoise() {
|
||||||
const [active, setActive] = useState<NoiseType | null>(null);
|
const [activeTab, setActiveTab] = useState<string>('noise');
|
||||||
const [volume, setVolume] = useState(0.25);
|
const [playing, setPlaying] = useState<Record<string, number>>({}); // id -> volume 0-1
|
||||||
const ctxRef = useRef<AudioContext | null>(null);
|
const howlsRef = useRef<Map<string, Howl>>(new Map());
|
||||||
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 stopNoise = useCallback(() => {
|
const activeCategory = CATEGORIES.find((c) => c.id === activeTab) ?? CATEGORIES[0];
|
||||||
if (sourceRef.current) {
|
|
||||||
try { sourceRef.current.stop(); } catch {}
|
const toggle = useCallback((sound: AmbientSound) => {
|
||||||
sourceRef.current.disconnect();
|
const existing = howlsRef.current.get(sound.id);
|
||||||
sourceRef.current = null;
|
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) => {
|
const setSoundVolume = useCallback((id: string, vol: number) => {
|
||||||
stopNoise();
|
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)();
|
// Cleanup all on unmount
|
||||||
ctxRef.current = ctx;
|
useEffect(() => {
|
||||||
if (ctx.state === 'suspended') ctx.resume();
|
return () => {
|
||||||
|
howlsRef.current.forEach((h) => { try { h.stop(); h.unload(); } catch {} });
|
||||||
const sr = ctx.sampleRate;
|
howlsRef.current.clear();
|
||||||
// 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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2">
|
<h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2">
|
||||||
<span>🌬️</span> White Noise
|
<span>🌬️</span> Ambient
|
||||||
</h3>
|
</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
|
<button
|
||||||
key={p.id}
|
key={cat.id}
|
||||||
onClick={() => toggle(p.id)}
|
onClick={() => setActiveTab(cat.id)}
|
||||||
className={`text-xs px-3 py-1.5 rounded-full transition-all flex items-center gap-1 ${
|
className={`text-[10px] px-2 py-1 rounded-lg transition-all flex items-center gap-1 ${
|
||||||
active === p.id
|
activeTab === cat.id
|
||||||
? 'bg-kira-pink text-white shadow'
|
? 'bg-kira-pink/20 text-kira-pink font-bold'
|
||||||
: 'bg-white/50 text-kira-plum/70 hover:bg-kira-glow'
|
: 'text-kira-plum/40 hover:text-kira-plum/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{p.icon}</span>
|
<span>{cat.icon}</span>
|
||||||
<span>{p.label}</span>
|
<span>{cat.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-kira-plum/60">
|
|
||||||
<span>Vol</span>
|
{/* Sound grid */}
|
||||||
<input
|
<div className="flex flex-col gap-2">
|
||||||
type="range"
|
{activeCategory.sounds.map((sound) => {
|
||||||
min={0}
|
const vol = playing[sound.id];
|
||||||
max={0.6}
|
const isActive = vol !== undefined;
|
||||||
step={0.02}
|
return (
|
||||||
value={volume}
|
<div key={sound.id} className="flex items-center gap-2">
|
||||||
onChange={(e) => changeVolume(parseFloat(e.target.value))}
|
<button
|
||||||
className="flex-1 accent-kira-pink"
|
onClick={() => toggle(sound)}
|
||||||
/>
|
className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-sm transition-all ${
|
||||||
<span className="tabular-nums w-8 text-right">{Math.round(volume * 100)}%</span>
|
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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user