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,27 +17,41 @@ 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;
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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', {
|
playerRef.current = new (window as any).YT.Player('kira-youtube-player', {
|
||||||
height: '0',
|
height: '1',
|
||||||
width: '0',
|
width: '1',
|
||||||
videoId: active.videoId,
|
videoId: active.videoId,
|
||||||
playerVars: {
|
playerVars: {
|
||||||
autoplay: 1,
|
autoplay: 1,
|
||||||
@@ -47,44 +61,41 @@ export default function MusicPlayer() {
|
|||||||
enablejsapi: 1,
|
enablejsapi: 1,
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
modestbranding: 1,
|
modestbranding: 1,
|
||||||
|
fs: 0,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
onReady: (e: any) => {
|
onReady: (e: any) => {
|
||||||
e.target.setVolume(volume * 100);
|
e.target.setVolume(Math.round(volumeRef.current * 100));
|
||||||
e.target.playVideo();
|
e.target.playVideo();
|
||||||
|
setPlayerReady(true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
}, []);
|
||||||
}, [activeId, volume]);
|
|
||||||
|
|
||||||
// Handle play button click (first user gesture)
|
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
if (!started) {
|
|
||||||
setStarted(true);
|
setStarted(true);
|
||||||
startPlayer();
|
loadAPI();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user