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 Timer from './components/Timer';
|
||||
import Notes from './components/Notes';
|
||||
import WhiteNoise from './components/WhiteNoise';
|
||||
import KiraAvatar from './components/KiraAvatar';
|
||||
import ChatBubble from './components/ChatBubble';
|
||||
import PetZone from './components/PetZone';
|
||||
@@ -136,11 +137,12 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Column 2: Timer + Music */}
|
||||
{/* Column 2: Timer + Music + Notes + WhiteNoise */}
|
||||
<div className="space-y-4">
|
||||
<Timer />
|
||||
<MusicPlayer />
|
||||
<Notes />
|
||||
<WhiteNoise />
|
||||
</div>
|
||||
|
||||
{/* 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