feat(white-noise): add Web Audio generated white/pink/brown/rain/cafe noise player
Separate from lofi music per original spec. Toggleable, volume control, always available in focus column. Finishes item 5.
This commit is contained in:
@@ -4,6 +4,7 @@ import BackgroundScene from './components/BackgroundScene';
|
|||||||
import MusicPlayer from './components/MusicPlayer';
|
import MusicPlayer from './components/MusicPlayer';
|
||||||
import Timer from './components/Timer';
|
import Timer from './components/Timer';
|
||||||
import Notes from './components/Notes';
|
import Notes from './components/Notes';
|
||||||
|
import WhiteNoise from './components/WhiteNoise';
|
||||||
import KiraAvatar from './components/KiraAvatar';
|
import KiraAvatar from './components/KiraAvatar';
|
||||||
import ChatBubble from './components/ChatBubble';
|
import ChatBubble from './components/ChatBubble';
|
||||||
import PetZone from './components/PetZone';
|
import PetZone from './components/PetZone';
|
||||||
@@ -136,11 +137,12 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column 2: Timer + Music */}
|
{/* Column 2: Timer + Music + Notes + WhiteNoise */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Timer />
|
<Timer />
|
||||||
<MusicPlayer />
|
<MusicPlayer />
|
||||||
<Notes />
|
<Notes />
|
||||||
|
<WhiteNoise />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column 3: Chat + Text Input */}
|
{/* Column 3: Chat + Text Input */}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
type NoiseType = 'white' | 'pink' | 'brown' | 'rain' | 'cafe';
|
||||||
|
|
||||||
|
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: '☕' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function WhiteNoise() {
|
||||||
|
const [active, setActive] = useState<NoiseType | null>(null);
|
||||||
|
const [volume, setVolume] = useState(0.25);
|
||||||
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||||
|
const noiseSourceRef = useRef<AudioBufferSourceNode | null>(null);
|
||||||
|
const gainRef = useRef<GainNode | null>(null);
|
||||||
|
const filterRef = useRef<BiquadFilterNode | null>(null);
|
||||||
|
|
||||||
|
const stopNoise = useCallback(() => {
|
||||||
|
if (noiseSourceRef.current) {
|
||||||
|
try { noiseSourceRef.current.stop(); } catch {}
|
||||||
|
noiseSourceRef.current = null;
|
||||||
|
}
|
||||||
|
if (audioCtxRef.current) {
|
||||||
|
// keep context for reuse
|
||||||
|
}
|
||||||
|
setActive(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startNoise = useCallback((type: NoiseType) => {
|
||||||
|
stopNoise();
|
||||||
|
|
||||||
|
const ctx = audioCtxRef.current || new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
audioCtxRef.current = ctx;
|
||||||
|
|
||||||
|
const bufferSize = 2 * ctx.sampleRate;
|
||||||
|
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
|
||||||
|
// Generate noise
|
||||||
|
let lastOut = 0;
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
let white = Math.random() * 2 - 1;
|
||||||
|
if (type === 'pink') {
|
||||||
|
// Pink noise approx
|
||||||
|
data[i] = (lastOut + (0.02 * white)) / 1.02;
|
||||||
|
lastOut = data[i];
|
||||||
|
data[i] *= 3.5; // gain
|
||||||
|
} else if (type === 'brown') {
|
||||||
|
// Brown/red noise
|
||||||
|
data[i] = (lastOut + (0.02 * white)) / 1.02;
|
||||||
|
lastOut = data[i];
|
||||||
|
data[i] *= 3.5;
|
||||||
|
} else {
|
||||||
|
data[i] = white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.loop = true;
|
||||||
|
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
gain.gain.value = volume;
|
||||||
|
|
||||||
|
let node: AudioNode = source;
|
||||||
|
|
||||||
|
if (type === 'rain' || type === 'cafe') {
|
||||||
|
const filter = ctx.createBiquadFilter();
|
||||||
|
filter.type = type === 'rain' ? 'lowpass' : 'bandpass';
|
||||||
|
filter.frequency.value = type === 'rain' ? 800 : 1200;
|
||||||
|
filter.Q.value = type === 'rain' ? 0.7 : 1.5;
|
||||||
|
node.connect(filter);
|
||||||
|
node = filter;
|
||||||
|
filterRef.current = filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
source.start();
|
||||||
|
noiseSourceRef.current = source;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-4">
|
||||||
|
<h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2">
|
||||||
|
<span>🌬️</span> White Noise
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{NOISE_PRESETS.map((p) => (
|
||||||
|
<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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{p.icon}</span>
|
||||||
|
<span>{p.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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active && (
|
||||||
|
<div className="mt-2 text-[10px] text-kira-mint/70">Playing {active} noise — body double approved ✨</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AnimatedAvatar.tsx","./src/components/BackgroundScene.tsx","./src/components/ChatBubble.tsx","./src/components/Clock.tsx","./src/components/KiraAvatar.tsx","./src/components/MusicPlayer.tsx","./src/components/Notes.tsx","./src/components/Particles.tsx","./src/components/PetZone.tsx","./src/components/Timer.tsx","./src/components/Toolbar.tsx","./src/components/Wardrobe.tsx","./src/components/WelcomeScreen.tsx","./src/components/scenes.ts","./src/hooks/useConversation.ts","./src/types/index.ts"],"version":"6.0.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AnimatedAvatar.tsx","./src/components/BackgroundScene.tsx","./src/components/ChatBubble.tsx","./src/components/Clock.tsx","./src/components/KiraAvatar.tsx","./src/components/MusicPlayer.tsx","./src/components/Notes.tsx","./src/components/Particles.tsx","./src/components/PetZone.tsx","./src/components/Timer.tsx","./src/components/Toolbar.tsx","./src/components/Wardrobe.tsx","./src/components/WelcomeScreen.tsx","./src/components/WhiteNoise.tsx","./src/components/scenes.ts","./src/hooks/useConversation.ts","./src/types/index.ts"],"version":"6.0.3"}
|
||||||
Reference in New Issue
Block a user