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:
2026-06-04 15:49:18 -04:00
parent 3f1497174d
commit 59b72aa184
3 changed files with 151 additions and 2 deletions
+3 -1
View File
@@ -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 */}
+147
View File
@@ -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
View File
@@ -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"}