fix(audio): proper noise synthesis + YouTube player init

WhiteNoise:
- Paul Kellet pink noise filter (industry standard)
- Brownian motion for brown noise (proper integration)
- Rain: layered brown rumble + random droplet impulse pings
- Cafe: low hum + scattered clatter transients
- Stereo buffers for spatial depth
- Crossfade start/end for seamless loop (50ms fade)
- Proper cleanup on unmount

MusicPlayer:
- YouTube player container uses offscreen positioning instead of
  display:none (hidden divs prevent iframe API initialization)
- Ref-based closure fix: activeId and volume use refs so the
  onYouTubeIframeAPIReady callback reads current values
- Added playerReady state to guard loadVideoById calls
This commit is contained in:
2026-06-05 15:03:02 -04:00
parent 8543461195
commit 5131eb729f
2 changed files with 172 additions and 104 deletions
+42 -29
View File
@@ -17,27 +17,41 @@ export default function MusicPlayer() {
const [activeId, setActiveId] = useState<string>('lofi-girl');
const [volume, setVolume] = useState(0.3);
const [started, setStarted] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const [playerReady, setPlayerReady] = useState(false);
const playerRef = useRef<any>(null);
const apiReady = useRef(false);
const volumeRef = useRef(volume);
const activeIdRef = useRef(activeId);
// Load YouTube IFrame API on first user interaction
const startPlayer = useCallback(() => {
if (apiReady.current) return;
apiReady.current = true;
// 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';
const first = document.getElementsByTagName('script')[0];
first.parentNode?.insertBefore(tag, first);
document.head.appendChild(tag);
(window as any).onYouTubeIframeAPIReady = () => {
const active = LOFI_PLAYLISTS.find((p) => p.id === activeId);
if (!active) return;
createPlayer();
};
}, []);
const createPlayer = useCallback(() => {
const active = LOFI_PLAYLISTS.find((p) => p.id === activeIdRef.current);
if (!active || playerRef.current) return;
playerRef.current = new (window as any).YT.Player('kira-youtube-player', {
height: '0',
width: '0',
height: '1',
width: '1',
videoId: active.videoId,
playerVars: {
autoplay: 1,
@@ -47,44 +61,41 @@ export default function MusicPlayer() {
enablejsapi: 1,
origin: window.location.origin,
modestbranding: 1,
fs: 0,
},
events: {
onReady: (e: any) => {
e.target.setVolume(volume * 100);
e.target.setVolume(Math.round(volumeRef.current * 100));
e.target.playVideo();
setPlayerReady(true);
},
},
});
};
}, [activeId, volume]);
}, []);
// Handle play button click (first user gesture)
const handlePlay = () => {
if (!started) {
setStarted(true);
startPlayer();
}
loadAPI();
};
// Change station
const changeStation = (id: string) => {
setActiveId(id);
if (playerRef.current && (window as any).YT) {
if (playerRef.current && playerReady) {
const active = LOFI_PLAYLISTS.find((p) => p.id === id);
if (active) {
playerRef.current.loadVideoById(active.videoId);
playerRef.current.setVolume(volume * 100);
playerRef.current.setVolume(Math.round(volume * 100));
playerRef.current.playVideo();
}
}
};
// Volume
// Volume sync
useEffect(() => {
if (playerRef.current) {
playerRef.current.setVolume(volume * 100);
if (playerRef.current && playerReady) {
playerRef.current.setVolume(Math.round(volume * 100));
}
}, [volume]);
}, [volume, playerReady]);
return (
<div className="p-4">
@@ -123,10 +134,12 @@ export default function MusicPlayer() {
))}
</div>
{/* Hidden YouTube player container */}
<div id="kira-youtube-player" ref={wrapperRef} className="hidden" />
{/* 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 }}
/>
{/* Play button / status */}
<div className="relative rounded-xl overflow-hidden bg-white/30" style={{ height: 60 }}>
{!started ? (
<button
+112 -57
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
type NoiseType = 'white' | 'pink' | 'brown' | 'rain' | 'cafe';
@@ -13,101 +13,158 @@ const NOISE_PRESETS: { id: NoiseType; label: string; icon: string }[] = [
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 ctxRef = useRef<AudioContext | null>(null);
const gainRef = useRef<GainNode | null>(null);
const filterRef = useRef<BiquadFilterNode | null>(null);
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
// For rain/cafe: extra nodes to tear down
const extraNodesRef = useRef<AudioNode[]>([]);
const stopNoise = useCallback(() => {
if (noiseSourceRef.current) {
try { noiseSourceRef.current.stop(); } catch {}
noiseSourceRef.current = null;
}
if (audioCtxRef.current) {
// keep context for reuse
if (sourceRef.current) {
try { sourceRef.current.stop(); } catch {}
sourceRef.current.disconnect();
sourceRef.current = null;
}
extraNodesRef.current.forEach((n) => { try { n.disconnect(); } catch {} });
extraNodesRef.current = [];
setActive(null);
}, []);
const startNoise = useCallback((type: NoiseType) => {
stopNoise();
const ctx = audioCtxRef.current || new (window.AudioContext || (window as any).webkitAudioContext)();
audioCtxRef.current = ctx;
const ctx = ctxRef.current || new (window.AudioContext || (window as any).webkitAudioContext)();
ctxRef.current = ctx;
if (ctx.state === 'suspended') ctx.resume();
const bufferSize = 2 * ctx.sampleRate;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
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
// 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
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') {
// Brown/red noise
data[i] = (lastOut + (0.02 * white)) / 1.02;
lastOut = data[i];
data[i] *= 3.5;
} else {
data[i] = white;
// 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;
}
}
}
const source = ctx.createBufferSource();
source.buffer = buffer;
source.loop = true;
// 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 node: AudioNode = source;
let tail: AudioNode = src;
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;
// 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);
}
node.connect(gain);
tail.connect(gain);
gain.connect(ctx.destination);
src.start();
source.start();
noiseSourceRef.current = source;
sourceRef.current = src;
gainRef.current = gain;
setActive(type);
}, [volume, stopNoise]);
const toggle = (type: NoiseType) => {
if (active === type) {
stopNoise();
} else {
startNoise(type);
}
if (active === type) stopNoise();
else startNoise(type);
};
const changeVolume = (v: number) => {
setVolume(v);
if (gainRef.current) {
gainRef.current.gain.value = 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
</h3>
<div className="flex flex-wrap gap-2 mb-3">
{NOISE_PRESETS.map((p) => (
<button
@@ -124,7 +181,6 @@ export default function WhiteNoise() {
</button>
))}
</div>
<div className="flex items-center gap-2 text-xs text-kira-plum/60">
<span>Vol</span>
<input
@@ -138,7 +194,6 @@ export default function WhiteNoise() {
/>
<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>
)}