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:
@@ -17,74 +17,85 @@ 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;
|
||||
|
||||
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();
|
||||
},
|
||||
},
|
||||
});
|
||||
createPlayer();
|
||||
};
|
||||
}, [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 = () => {
|
||||
if (!started) {
|
||||
setStarted(true);
|
||||
startPlayer();
|
||||
}
|
||||
setStarted(true);
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user