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",
|
||||
"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",
|
||||
|
||||
@@ -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.
@@ -17,38 +17,21 @@ 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;
|
||||
|
||||
const createYT = () => {
|
||||
playerRef.current = new (window as any).YT.Player('kira-youtube-player', {
|
||||
height: '1',
|
||||
width: '1',
|
||||
@@ -59,43 +42,64 @@ export default function MusicPlayer() {
|
||||
loop: 1,
|
||||
playlist: active.videoId,
|
||||
enablejsapi: 1,
|
||||
origin: window.location.origin,
|
||||
modestbranding: 1,
|
||||
fs: 0,
|
||||
rel: 0,
|
||||
},
|
||||
events: {
|
||||
onReady: (e: any) => {
|
||||
e.target.setVolume(Math.round(volumeRef.current * 100));
|
||||
e.target.playVideo();
|
||||
setPlayerReady(true);
|
||||
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) {
|
||||
try {
|
||||
playerRef.current.loadVideoById(active.videoId);
|
||||
playerRef.current.setVolume(Math.round(volume * 100));
|
||||
playerRef.current.playVideo();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Volume sync
|
||||
useEffect(() => {
|
||||
if (playerRef.current && playerReady) {
|
||||
try {
|
||||
if (playerRef.current?.setVolume) {
|
||||
playerRef.current.setVolume(Math.round(volume * 100));
|
||||
}
|
||||
}, [volume, playerReady]);
|
||||
} 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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
// Cleanup all on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
howlsRef.current.forEach((h) => { try { h.stop(); h.unload(); } catch {} });
|
||||
howlsRef.current.clear();
|
||||
};
|
||||
|
||||
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 (
|
||||
<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>
|
||||
|
||||
{/* 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={0.6}
|
||||
max={1}
|
||||
step={0.02}
|
||||
value={volume}
|
||||
onChange={(e) => changeVolume(parseFloat(e.target.value))}
|
||||
value={vol}
|
||||
onChange={(e) => setSoundVolume(sound.id, parseFloat(e.target.value))}
|
||||
className="flex-1 accent-kira-pink"
|
||||
/>
|
||||
<span className="tabular-nums w-8 text-right">{Math.round(volume * 100)}%</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
{active && (
|
||||
<div className="mt-2 text-[10px] text-kira-mint/70">Playing {active} noise — body double approved ✨</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user