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
+60 -47
View File
@@ -17,74 +17,85 @@ export default function MusicPlayer() {
const [activeId, setActiveId] = useState<string>('lofi-girl'); const [activeId, setActiveId] = useState<string>('lofi-girl');
const [volume, setVolume] = useState(0.3); const [volume, setVolume] = useState(0.3);
const [started, setStarted] = useState(false); const [started, setStarted] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null); const [playerReady, setPlayerReady] = useState(false);
const playerRef = useRef<any>(null); 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 // Keep refs in sync so the API callback sees current values
const startPlayer = useCallback(() => { useEffect(() => { volumeRef.current = volume; }, [volume]);
if (apiReady.current) return; useEffect(() => { activeIdRef.current = activeId; }, [activeId]);
apiReady.current = true;
// 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'); const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api'; tag.src = 'https://www.youtube.com/iframe_api';
const first = document.getElementsByTagName('script')[0]; document.head.appendChild(tag);
first.parentNode?.insertBefore(tag, first);
(window as any).onYouTubeIframeAPIReady = () => { (window as any).onYouTubeIframeAPIReady = () => {
const active = LOFI_PLAYLISTS.find((p) => p.id === activeId); createPlayer();
if (!active) return;
playerRef.current = new (window as any).YT.Player('kira-youtube-player', {
height: '0',
width: '0',
videoId: active.videoId,
playerVars: {
autoplay: 1,
controls: 0,
loop: 1,
playlist: active.videoId,
enablejsapi: 1,
origin: window.location.origin,
modestbranding: 1,
},
events: {
onReady: (e: any) => {
e.target.setVolume(volume * 100);
e.target.playVideo();
},
},
});
}; };
}, [activeId, volume]); }, []);
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: '1',
width: '1',
videoId: active.videoId,
playerVars: {
autoplay: 1,
controls: 0,
loop: 1,
playlist: active.videoId,
enablejsapi: 1,
origin: window.location.origin,
modestbranding: 1,
fs: 0,
},
events: {
onReady: (e: any) => {
e.target.setVolume(Math.round(volumeRef.current * 100));
e.target.playVideo();
setPlayerReady(true);
},
},
});
}, []);
// Handle play button click (first user gesture)
const handlePlay = () => { const handlePlay = () => {
if (!started) { setStarted(true);
setStarted(true); loadAPI();
startPlayer();
}
}; };
// Change station
const changeStation = (id: string) => { const changeStation = (id: string) => {
setActiveId(id); setActiveId(id);
if (playerRef.current && (window as any).YT) { if (playerRef.current && playerReady) {
const active = LOFI_PLAYLISTS.find((p) => p.id === id); const active = LOFI_PLAYLISTS.find((p) => p.id === id);
if (active) { if (active) {
playerRef.current.loadVideoById(active.videoId); playerRef.current.loadVideoById(active.videoId);
playerRef.current.setVolume(volume * 100); playerRef.current.setVolume(Math.round(volume * 100));
playerRef.current.playVideo(); playerRef.current.playVideo();
} }
} }
}; };
// Volume // Volume sync
useEffect(() => { useEffect(() => {
if (playerRef.current) { if (playerRef.current && playerReady) {
playerRef.current.setVolume(volume * 100); playerRef.current.setVolume(Math.round(volume * 100));
} }
}, [volume]); }, [volume, playerReady]);
return ( return (
<div className="p-4"> <div className="p-4">
@@ -123,10 +134,12 @@ export default function MusicPlayer() {
))} ))}
</div> </div>
{/* Hidden YouTube player container */} {/* YouTube player: NOT display:none (API won't init). Offscreen instead. */}
<div id="kira-youtube-player" ref={wrapperRef} className="hidden" /> <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 }}> <div className="relative rounded-xl overflow-hidden bg-white/30" style={{ height: 60 }}>
{!started ? ( {!started ? (
<button <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'; type NoiseType = 'white' | 'pink' | 'brown' | 'rain' | 'cafe';
@@ -13,101 +13,158 @@ const NOISE_PRESETS: { id: NoiseType; label: string; icon: string }[] = [
export default function WhiteNoise() { export default function WhiteNoise() {
const [active, setActive] = useState<NoiseType | null>(null); const [active, setActive] = useState<NoiseType | null>(null);
const [volume, setVolume] = useState(0.25); const [volume, setVolume] = useState(0.25);
const audioCtxRef = useRef<AudioContext | null>(null); const ctxRef = useRef<AudioContext | null>(null);
const noiseSourceRef = useRef<AudioBufferSourceNode | null>(null);
const gainRef = useRef<GainNode | 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(() => { const stopNoise = useCallback(() => {
if (noiseSourceRef.current) { if (sourceRef.current) {
try { noiseSourceRef.current.stop(); } catch {} try { sourceRef.current.stop(); } catch {}
noiseSourceRef.current = null; sourceRef.current.disconnect();
} sourceRef.current = null;
if (audioCtxRef.current) {
// keep context for reuse
} }
extraNodesRef.current.forEach((n) => { try { n.disconnect(); } catch {} });
extraNodesRef.current = [];
setActive(null); setActive(null);
}, []); }, []);
const startNoise = useCallback((type: NoiseType) => { const startNoise = useCallback((type: NoiseType) => {
stopNoise(); stopNoise();
const ctx = audioCtxRef.current || new (window.AudioContext || (window as any).webkitAudioContext)(); const ctx = ctxRef.current || new (window.AudioContext || (window as any).webkitAudioContext)();
audioCtxRef.current = ctx; ctxRef.current = ctx;
if (ctx.state === 'suspended') ctx.resume();
const bufferSize = 2 * ctx.sampleRate; const sr = ctx.sampleRate;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); // 10-second buffer — long enough to avoid obvious loops, short enough to generate fast
const data = buffer.getChannelData(0); const len = 10 * sr;
const buf = ctx.createBuffer(2, len, sr); // stereo for spatial depth
// Generate noise for (let ch = 0; ch < 2; ch++) {
let lastOut = 0; const d = buf.getChannelData(ch);
for (let i = 0; i < bufferSize; i++) { if (type === 'white') {
let white = Math.random() * 2 - 1; for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
if (type === 'pink') { } else if (type === 'pink') {
// Pink noise approx // Paul Kellet's pink noise filter (industry standard)
data[i] = (lastOut + (0.02 * white)) / 1.02; let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
lastOut = data[i]; for (let i = 0; i < len; i++) {
data[i] *= 3.5; // gain 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') { } else if (type === 'brown') {
// Brown/red noise // Brownian motion (integrated white noise)
data[i] = (lastOut + (0.02 * white)) / 1.02; let last = 0;
lastOut = data[i]; for (let i = 0; i < len; i++) {
data[i] *= 3.5; last += (Math.random() * 2 - 1) * 0.02;
} else { last = Math.max(-1, Math.min(1, last)); // clamp
data[i] = white; 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(); // Crossfade start/end for seamless loop
source.buffer = buffer; const fade = Math.min(sr * 0.05, len / 4); // 50ms fade
source.loop = true; 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(); const gain = ctx.createGain();
gain.gain.value = volume; gain.gain.value = volume;
let node: AudioNode = source; let tail: AudioNode = src;
if (type === 'rain' || type === 'cafe') { // For rain/cafe: add filtering for warmth
const filter = ctx.createBiquadFilter(); if (type === 'rain') {
filter.type = type === 'rain' ? 'lowpass' : 'bandpass'; const lp = ctx.createBiquadFilter();
filter.frequency.value = type === 'rain' ? 800 : 1200; lp.type = 'lowpass';
filter.Q.value = type === 'rain' ? 0.7 : 1.5; lp.frequency.value = 6000;
node.connect(filter); lp.Q.value = 0.5;
node = filter; tail.connect(lp);
filterRef.current = filter; 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); gain.connect(ctx.destination);
src.start();
source.start(); sourceRef.current = src;
noiseSourceRef.current = source;
gainRef.current = gain; gainRef.current = gain;
setActive(type); setActive(type);
}, [volume, stopNoise]); }, [volume, stopNoise]);
const toggle = (type: NoiseType) => { const toggle = (type: NoiseType) => {
if (active === type) { if (active === type) stopNoise();
stopNoise(); else startNoise(type);
} else {
startNoise(type);
}
}; };
const changeVolume = (v: number) => { const changeVolume = (v: number) => {
setVolume(v); setVolume(v);
if (gainRef.current) { if (gainRef.current) gainRef.current.gain.value = v;
gainRef.current.gain.value = v;
}
}; };
// Cleanup on unmount
useEffect(() => () => {
stopNoise();
ctxRef.current?.close().catch(() => {});
}, [stopNoise]);
return ( return (
<div className="p-4"> <div className="p-4">
<h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2"> <h3 className="text-sm font-bold text-kira-plum mb-3 flex items-center gap-2">
<span>🌬</span> White Noise <span>🌬</span> White Noise
</h3> </h3>
<div className="flex flex-wrap gap-2 mb-3"> <div className="flex flex-wrap gap-2 mb-3">
{NOISE_PRESETS.map((p) => ( {NOISE_PRESETS.map((p) => (
<button <button
@@ -124,7 +181,6 @@ export default function WhiteNoise() {
</button> </button>
))} ))}
</div> </div>
<div className="flex items-center gap-2 text-xs text-kira-plum/60"> <div className="flex items-center gap-2 text-xs text-kira-plum/60">
<span>Vol</span> <span>Vol</span>
<input <input
@@ -138,7 +194,6 @@ export default function WhiteNoise() {
/> />
<span className="tabular-nums w-8 text-right">{Math.round(volume * 100)}%</span> <span className="tabular-nums w-8 text-right">{Math.round(volume * 100)}%</span>
</div> </div>
{active && ( {active && (
<div className="mt-2 text-[10px] text-kira-mint/70">Playing {active} noise body double approved </div> <div className="mt-2 text-[10px] text-kira-mint/70">Playing {active} noise body double approved </div>
)} )}