音乐增加均衡器配置

This commit is contained in:
mtvpls
2026-08-02 11:47:43 +08:00
parent 62deb0655b
commit a44849379f
15 changed files with 207 additions and 7 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+207 -7
View File
@@ -4,6 +4,7 @@
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { SlidersHorizontal } from 'lucide-react';
import AddToPlaylistModal from '@/components/AddToPlaylistModal';
import Toast, { ToastProps } from '@/components/Toast';
import LyricsPiPWindow from '@/components/LyricsPiPWindow';
@@ -19,6 +20,36 @@ const SPECTRUM_EDGE_TRIM = 8;
const SPECTRUM_REFERENCE_VOLUME = 10;
const SPECTRUM_MIN_VOLUME = 5;
const SPECTRUM_MAX_REFERENCE_VOLUME = 15;
const EQ_BANDS = [
{ label: '31', frequency: 31 }, { label: '62', frequency: 62 },
{ label: '125', frequency: 125 }, { label: '250', frequency: 250 },
{ label: '500', frequency: 500 }, { label: '1K', frequency: 1000 },
{ label: '2K', frequency: 2000 }, { label: '4K', frequency: 4000 },
{ label: '8K', frequency: 8000 }, { label: '16K', frequency: 16000 },
] as const;
const DEFAULT_EQ_GAINS = EQ_BANDS.map(() => 0);
const EQ_PRESETS = {
Flat: DEFAULT_EQ_GAINS,
: [4, 3, -1, -2, 1, 2, 3, 2, 4, 3], : [5, 4, 2, 1, -1, -2, 2, 3, 4, 5],
: [4, 3, 1, 1, 0, 1, -1, -2, -3, -4], : [-3, -2, -1, 1, 3, 4, 3, 2, -1, -2],
: [5, 4, 1, -2, -2, 0, 3, 4, 4, 3], : [7, 6, 4, 3, 1, 0, 0, 0, 0, 0],
} as const;
const REVERB_PRESETS = {
none: { label: '关闭', source: null, main: 1, send: 0 },
telephone: { label: '电话', source: 'filter-telephone.wav', main: 0, send: 3 },
church: { label: '教堂', source: 's2_r4_bd.wav', main: 1.8, send: 0.9 },
hall: { label: '大厅', source: 'bright-hall.wav', main: 0.8, send: 2.4 },
cinema: { label: '电影院', source: 'cinema-diningroom.wav', main: 0.6, send: 2.3 },
dining: { label: '餐厅', source: 'dining-living-true-stereo.wav', main: 0.6, send: 1.8 },
living: { label: '卫生间', source: 'living-bedroom-leveled.wav', main: 1.6, send: 2.1 },
spreader: { label: '室内', source: 'spreader50-65ms.wav', main: 1, send: 2.5 },
stereo: { label: '立体声', source: 'cardiod-35-10-spread.wav', main: 1.8, send: 0.6 },
matrix1: { label: '矩阵混响 (1)', source: 'matrix-reverb1.wav', main: 1.5, send: 0.9 },
matrix2: { label: '矩阵混响 (2)', source: 'matrix-reverb2.wav', main: 1.3, send: 1 },
cardiod: { label: '心形扩散', source: 'cardiod-35-10-spread.wav', main: 1.8, send: 0.6 },
magnetic: { label: '磁性立体声', source: 'tim-omni-35-10-magnetic.wav', main: 1, send: 0.2 },
spring: { label: '反馈弹簧', source: 'feedback-spring.wav', main: 1.8, send: 0.8 },
} as const;
interface PlayRecord {
platform: MusicSource;
@@ -245,6 +276,19 @@ export default function MusicClient({ children: _children }: { children?: React.
const [showPlaylist, setShowPlaylist] = useState(false);
const [playlistIndex, setPlaylistIndex] = useState(-1); // 当前在播放列表中的索引
const [showQualityMenu, setShowQualityMenu] = useState(false); // 音质选择菜单
const [showAudioEffectsMenu, setShowAudioEffectsMenu] = useState(false);
const [equalizerEnabled, setEqualizerEnabled] = useState(false);
const [eqGains, setEqGains] = useState<number[]>(DEFAULT_EQ_GAINS);
const [loudnessEnabled, setLoudnessEnabled] = useState(false);
const [reverbEnabled, setReverbEnabled] = useState(false);
const [reverbMix, setReverbMix] = useState(12);
const [reverbPreset, setReverbPreset] = useState<keyof typeof REVERB_PRESETS>('none');
const [reverbMainGain, setReverbMainGain] = useState(100);
const [eqPreset, setEqPreset] = useState('Flat');
const [pitchRate, setPitchRate] = useState(1);
const [surroundEnabled, setSurroundEnabled] = useState(false);
const [surroundSpeed, setSurroundSpeed] = useState(25);
const [surroundDistance, setSurroundDistance] = useState(5);
const [showSleepTimerMenu, setShowSleepTimerMenu] = useState(false); // 睡眠定时菜单
const [sleepTimerEndAt, setSleepTimerEndAt] = useState<number | null>(null); // 睡眠定时结束时间
const [sleepTimerRemaining, setSleepTimerRemaining] = useState(0); // 睡眠定时剩余秒数
@@ -291,6 +335,41 @@ export default function MusicClient({ children: _children }: { children?: React.
() => Array.from({ length: SPECTRUM_BIN_COUNT }, () => SPECTRUM_IDLE_LEVEL)
);
useEffect(() => {
if (typeof window === 'undefined') return;
try {
const saved = JSON.parse(localStorage.getItem('musicEqGains') || 'null');
if (Array.isArray(saved) && saved.length === EQ_BANDS.length) setEqGains(saved.map(value => Math.max(-12, Math.min(12, Number(value) || 0))));
setEqualizerEnabled(localStorage.getItem('musicEqEnabled') === '1');
setLoudnessEnabled(localStorage.getItem('musicLoudnessEnabled') === '1');
setReverbEnabled(localStorage.getItem('musicReverbEnabled') === '1');
setReverbPreset((localStorage.getItem('musicReverbPreset') as keyof typeof REVERB_PRESETS) || 'none');
setReverbMainGain(Math.max(0, Math.min(150, Number(localStorage.getItem('musicReverbMainGain')) || 100)));
setReverbMix(Math.max(0, Math.min(40, Number(localStorage.getItem('musicReverbMix')) || 12)));
setEqPreset(localStorage.getItem('musicEqPreset') || 'Flat');
setPitchRate(Math.max(0.5, Math.min(1.5, Number(localStorage.getItem('musicPitchRate')) || 1)));
setSurroundEnabled(localStorage.getItem('musicSurroundEnabled') === '1');
setSurroundSpeed(Math.max(5, Math.min(60, Number(localStorage.getItem('musicSurroundSpeed')) || 25)));
setSurroundDistance(Math.max(1, Math.min(10, Number(localStorage.getItem('musicSurroundDistance')) || 5)));
} catch {}
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem('musicEqGains', JSON.stringify(eqGains));
localStorage.setItem('musicEqEnabled', equalizerEnabled ? '1' : '0');
localStorage.setItem('musicLoudnessEnabled', loudnessEnabled ? '1' : '0');
localStorage.setItem('musicReverbEnabled', reverbEnabled ? '1' : '0');
localStorage.setItem('musicReverbMix', String(reverbMix));
localStorage.setItem('musicReverbPreset', reverbPreset);
localStorage.setItem('musicReverbMainGain', String(reverbMainGain));
localStorage.setItem('musicEqPreset', eqPreset);
localStorage.setItem('musicPitchRate', String(pitchRate));
localStorage.setItem('musicSurroundEnabled', surroundEnabled ? '1' : '0');
localStorage.setItem('musicSurroundSpeed', String(surroundSpeed));
localStorage.setItem('musicSurroundDistance', String(surroundDistance));
}, [eqGains, equalizerEnabled, loudnessEnabled, reverbEnabled, reverbMix, reverbPreset, reverbMainGain, eqPreset, pitchRate, surroundEnabled, surroundSpeed, surroundDistance]);
const audioRef = useRef<HTMLAudioElement>(null);
const lyricsContainerRef = useRef<HTMLDivElement>(null);
const sleepHoursPickerRef = useRef<HTMLDivElement>(null);
@@ -302,6 +381,14 @@ export default function MusicClient({ children: _children }: { children?: React.
const analyserRef = useRef<AnalyserNode | null>(null);
const mediaSourceRef = useRef<MediaElementAudioSourceNode | null>(null);
const spectrumDataRef = useRef<Uint8Array | null>(null);
const eqFiltersRef = useRef<BiquadFilterNode[]>([]);
const loudnessGainRef = useRef<GainNode | null>(null);
const compressorRef = useRef<DynamicsCompressorNode | null>(null);
const convolverRef = useRef<ConvolverNode | null>(null);
const dryGainRef = useRef<GainNode | null>(null);
const wetGainRef = useRef<GainNode | null>(null);
const pannerRef = useRef<PannerNode | null>(null);
const pannerTimerRef = useRef<number | null>(null);
const spectrumFrameRef = useRef<number | null>(null);
const currentTimeRef = useRef(0);
const volumeRef = useRef(volume);
@@ -1304,6 +1391,68 @@ export default function MusicClient({ children: _children }: { children?: React.
};
// 音频事件监听
useEffect(() => {
eqFiltersRef.current.forEach((filter, index) => { filter.gain.value = equalizerEnabled ? (eqGains[index] || 0) : 0; });
if (compressorRef.current) {
compressorRef.current.threshold.value = loudnessEnabled ? -24 : 0;
compressorRef.current.knee.value = loudnessEnabled ? 12 : 0;
compressorRef.current.ratio.value = loudnessEnabled ? 2 : 1;
}
if (loudnessGainRef.current) loudnessGainRef.current.gain.value = loudnessEnabled ? 1.04 : 1;
if (dryGainRef.current) dryGainRef.current.gain.value = reverbEnabled ? reverbMainGain / 100 : 1;
if (wetGainRef.current) wetGainRef.current.gain.value = reverbEnabled ? reverbMix / 100 : 0;
const audio = audioRef.current as (HTMLAudioElement & { preservesPitch?: boolean }) | null;
if (audio) {
audio.playbackRate = pitchRate;
audio.preservesPitch = false;
}
if (pannerRef.current && !surroundEnabled) {
pannerRef.current.positionX.value = 0;
pannerRef.current.positionY.value = 0;
pannerRef.current.positionZ.value = 0;
}
}, [eqGains, equalizerEnabled, loudnessEnabled, reverbEnabled, reverbMix, reverbMainGain, pitchRate, surroundEnabled]);
useEffect(() => {
const convolver = convolverRef.current;
const context = audioContextRef.current;
const preset = REVERB_PRESETS[reverbPreset];
if (!convolver || !context || !preset || !preset.source || !reverbEnabled) {
if (convolver) convolver.buffer = null;
return;
}
let cancelled = false;
fetch(`/music/effects/${preset.source}`)
.then(response => response.arrayBuffer())
.then(data => context.decodeAudioData(data))
.then(buffer => {
if (!cancelled && convolver) convolver.buffer = buffer;
})
.catch(error => console.warn('加载环境混响音效失败:', error));
return () => { cancelled = true; };
}, [reverbPreset, reverbEnabled]);
useEffect(() => {
if (pannerTimerRef.current !== null) window.clearInterval(pannerTimerRef.current);
if (!surroundEnabled || !pannerRef.current) return;
let angle = 0;
const update = () => {
const panner = pannerRef.current;
if (!panner) return;
angle = (angle + 360 / (surroundSpeed * 10)) % 360;
const radians = angle * Math.PI / 180;
panner.positionX.value = Math.sin(radians) * surroundDistance;
panner.positionY.value = 0;
panner.positionZ.value = Math.cos(radians) * surroundDistance;
};
update();
pannerTimerRef.current = window.setInterval(update, 100);
return () => {
if (pannerTimerRef.current !== null) window.clearInterval(pannerTimerRef.current);
pannerTimerRef.current = null;
};
}, [surroundEnabled, surroundSpeed, surroundDistance]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
@@ -1608,6 +1757,18 @@ export default function MusicClient({ children: _children }: { children?: React.
return getSourceDisplayLabel(currentSource, false);
};
const resetAudioEffects = () => {
setEqGains([...DEFAULT_EQ_GAINS]); setEqPreset('Flat'); setEqualizerEnabled(false);
setLoudnessEnabled(false); setReverbEnabled(false); setReverbMix(12); setReverbPreset('none'); setReverbMainGain(100);
setPitchRate(1); setSurroundEnabled(false); setSurroundSpeed(25); setSurroundDistance(5);
};
const applyEqPreset = (name: string) => {
const preset = EQ_PRESETS[name as keyof typeof EQ_PRESETS];
if (!preset) return;
setEqPreset(name); setEqGains([...preset]); setEqualizerEnabled(name !== 'Flat');
};
const formatTime = (seconds: number) => {
if (isNaN(seconds) || seconds === 0) return '0:00';
const mins = Math.floor(seconds / 60);
@@ -1752,13 +1913,30 @@ export default function MusicClient({ children: _children }: { children?: React.
}
if (!analyserRef.current) {
const analyser = audioContextRef.current.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = 0.8;
mediaSourceRef.current.connect(analyser);
analyser.connect(audioContextRef.current.destination);
analyserRef.current = analyser;
spectrumDataRef.current = new Uint8Array(analyser.frequencyBinCount);
const context = audioContextRef.current;
const eqFilters = EQ_BANDS.map((band) => {
const filter = context.createBiquadFilter();
filter.type = 'peaking'; filter.frequency.value = band.frequency; filter.Q.value = 1.4; filter.gain.value = 0;
return filter;
});
eqFilters.forEach((filter, index) => index === 0 ? mediaSourceRef.current?.connect(filter) : eqFilters[index - 1].connect(filter));
const lastEq = eqFilters[eqFilters.length - 1];
const dryGain = context.createGain(); const wetGain = context.createGain();
const convolver = context.createConvolver();
const impulse = context.createBuffer(2, Math.floor(context.sampleRate * 1.6), context.sampleRate);
for (let channel = 0; channel < impulse.numberOfChannels; channel += 1) {
const data = impulse.getChannelData(channel);
for (let i = 0; i < data.length; i += 1) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / data.length, 2.2);
}
convolver.buffer = impulse; lastEq.connect(dryGain); lastEq.connect(convolver); convolver.connect(wetGain);
const mixer = context.createGain(); dryGain.connect(mixer); wetGain.connect(mixer);
const compressor = context.createDynamicsCompressor(); compressor.threshold.value = 0; compressor.knee.value = 0; compressor.ratio.value = 1; compressor.attack.value = 0.003; compressor.release.value = 0.25; mixer.connect(compressor);
const loudnessGain = context.createGain(); compressor.connect(loudnessGain);
const panner = context.createPanner();
panner.panningModel = 'HRTF'; panner.distanceModel = 'inverse'; panner.refDistance = 1; panner.maxDistance = 20; panner.rolloffFactor = 0.5;
panner.positionX.value = 0; panner.positionY.value = 0; panner.positionZ.value = 0;
const analyser = context.createAnalyser(); analyser.fftSize = 512; analyser.smoothingTimeConstant = 0.8; loudnessGain.connect(panner); panner.connect(analyser); analyser.connect(context.destination);
eqFiltersRef.current = eqFilters; convolverRef.current = convolver; dryGainRef.current = dryGain; wetGainRef.current = wetGain; compressorRef.current = compressor; loudnessGainRef.current = loudnessGain; pannerRef.current = panner; analyserRef.current = analyser; spectrumDataRef.current = new Uint8Array(analyser.frequencyBinCount);
}
if (audioContextRef.current.state === 'suspended') {
@@ -1842,6 +2020,8 @@ export default function MusicClient({ children: _children }: { children?: React.
window.cancelAnimationFrame(spectrumFrameRef.current);
}
analyserRef.current?.disconnect();
eqFiltersRef.current.forEach(filter => filter.disconnect());
convolverRef.current?.disconnect(); dryGainRef.current?.disconnect(); wetGainRef.current?.disconnect(); compressorRef.current?.disconnect(); loudnessGainRef.current?.disconnect(); pannerRef.current?.disconnect();
mediaSourceRef.current?.disconnect();
audioContextRef.current?.close().catch(() => undefined);
};
@@ -2567,6 +2747,7 @@ export default function MusicClient({ children: _children }: { children?: React.
</div>
</div>
</div>
<button onClick={(e) => { e.stopPropagation(); setShowAudioEffectsMenu(true); }} className={`transition-colors ${equalizerEnabled || loudnessEnabled || reverbEnabled || pitchRate !== 1 || surroundEnabled ? 'text-green-400' : 'text-zinc-500 hover:text-white'}`} title="均衡器和音效" aria-label="均衡器和音效"><SlidersHorizontal className="h-4 w-4 md:h-5 md:w-5" /></button>
{/* PiP 歌词按钮 */}
<button
onClick={toggleSpectrum}
@@ -2939,6 +3120,25 @@ export default function MusicClient({ children: _children }: { children?: React.
</div>
)}
{showAudioEffectsMenu && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4" onClick={() => setShowAudioEffectsMenu(false)}>
<div className="relative w-full max-w-5xl max-h-[90vh] bg-zinc-900 rounded-2xl border border-white/10 shadow-2xl overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="p-5 border-b border-white/10 flex items-center justify-between"><div><h3 className="text-lg font-bold text-white"></h3></div><button onClick={() => setShowAudioEffectsMenu(false)} className="w-8 h-8 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10"></button></div>
<div className="p-5 overflow-y-auto max-h-[calc(90vh-80px)]"><div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-8">
<section><div className="flex items-center justify-between mb-4"><div><h4 className="text-sm font-bold text-white">10 </h4></div><label className="flex items-center gap-2 text-xs text-zinc-300"><input type="checkbox" checked={equalizerEnabled} onChange={(e) => setEqualizerEnabled(e.target.checked)} className="h-4 w-4 accent-green-500" /> EQ</label></div>
<div className="rounded-xl border border-white/5 p-4 overflow-x-auto overscroll-x-contain"><div className="min-w-[760px] grid grid-cols-10 gap-3 items-end h-56 sm:h-64">{EQ_BANDS.map((band, index) => (<div key={band.frequency} className="h-full min-w-0 flex flex-col items-center gap-2"><span className="text-[10px] font-mono text-zinc-500">{(eqGains[index] || 0) > 0 ? '+' : ''}{(eqGains[index] || 0).toFixed(1)}</span><div className="flex-1 w-full min-h-0 flex items-center justify-center"><input type="range" min="-12" max="12" step="0.5" value={eqGains[index] || 0} onChange={(e) => { setEqPreset('自定义'); const value = Number(e.target.value); setEqGains(current => current.map((gain, i) => i === index ? value : gain)); }} disabled={!equalizerEnabled} className="w-40 sm:w-52 h-5 -rotate-90 accent-green-500 [direction:rtl] disabled:opacity-40" /></div><span className="text-[10px] font-mono text-zinc-400">{band.label}</span></div>))}</div></div>
<div className="mt-5"><div className="flex items-center justify-between mb-3"><h4 className="text-sm font-bold text-white"></h4><button onClick={() => applyEqPreset('Flat')} className="text-xs text-green-400 hover:text-green-300">Flat </button></div><div className="flex flex-wrap gap-2">{Object.keys(EQ_PRESETS).map(name => (<button key={name} onClick={() => applyEqPreset(name)} className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${eqPreset === name ? 'bg-green-500/20 text-green-300 border border-green-500/50' : 'bg-white/5 text-zinc-300 border border-white/10 hover:bg-white/10'}`}>{name}</button>))}</div></div>
</section>
<aside className="space-y-5"><section className="rounded-xl border border-white/10 bg-white/[0.03] p-4"><div className="flex items-center justify-between"><div><h4 className="text-sm font-bold text-white"></h4><p className="text-xs text-zinc-500 mt-1"></p></div><input type="checkbox" checked={loudnessEnabled} onChange={(e) => setLoudnessEnabled(e.target.checked)} className="h-4 w-4 accent-green-500" /></div></section>
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4"><div className="flex items-center justify-between mb-3"><div><h4 className="text-sm font-bold text-white"></h4><p className="text-xs text-zinc-500 mt-1"></p></div><input type="checkbox" checked={reverbEnabled} onChange={(e) => setReverbEnabled(e.target.checked)} className="h-4 w-4 accent-green-500" /></div><div className="flex flex-wrap gap-2">{Object.entries(REVERB_PRESETS).map(([id, preset]) => (<button key={id} onClick={() => { const selected = id as keyof typeof REVERB_PRESETS; const selectedPreset = REVERB_PRESETS[selected]; setReverbPreset(selected); setReverbEnabled(selected !== 'none'); setReverbMainGain(Math.round(selectedPreset.main * 100)); setReverbMix(Math.min(100, Math.round(selectedPreset.send * 30))); }} className={`rounded-lg border px-2.5 py-1 text-xs ${reverbPreset === id ? 'border-green-500/50 bg-green-500/20 text-green-300' : 'border-white/10 bg-white/5 text-zinc-300 hover:bg-white/10'}`}>{preset.label}</button>))}</div><label className="mt-3 block text-xs text-zinc-400"> {reverbMainGain}%<input type="range" min="0" max="150" value={reverbMainGain} onChange={(e) => setReverbMainGain(Number(e.target.value))} disabled={!reverbEnabled} className="mt-2 w-full accent-green-500 disabled:opacity-40" /></label><label className="mt-3 block text-xs text-zinc-400"> {reverbMix}%<input type="range" min="0" max="100" value={reverbMix} onChange={(e) => setReverbMix(Number(e.target.value))} disabled={!reverbEnabled} className="mt-2 w-full accent-green-500 disabled:opacity-40" /></label></section>
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4"><div className="flex items-center justify-between mb-3"><div><h4 className="text-sm font-bold text-white"></h4><p className="text-xs text-zinc-500 mt-1"> {pitchRate.toFixed(2)}x</p></div><button onClick={() => setPitchRate(1)} className="text-xs text-green-400"></button></div><input type="range" min="0.5" max="1.5" step="0.01" value={pitchRate} onChange={(e) => setPitchRate(Number(e.target.value))} className="w-full accent-green-500" /><div className="mt-2 flex justify-between text-xs text-zinc-500"><span></span><span></span><span></span></div></section>
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4"><div className="flex items-center justify-between mb-3"><div><h4 className="text-sm font-bold text-white">3D </h4><p className="text-xs text-zinc-500 mt-1">使</p></div><input type="checkbox" checked={surroundEnabled} onChange={(e) => setSurroundEnabled(e.target.checked)} className="h-4 w-4 accent-green-500" /></div><label className="block text-xs text-zinc-400"> {surroundSpeed}<input type="range" min="5" max="60" value={surroundSpeed} onChange={(e) => setSurroundSpeed(Number(e.target.value))} disabled={!surroundEnabled} className="mt-2 w-full accent-green-500 disabled:opacity-40" /></label><label className="block mt-3 text-xs text-zinc-400"> {surroundDistance}<input type="range" min="1" max="10" value={surroundDistance} onChange={(e) => setSurroundDistance(Number(e.target.value))} disabled={!surroundEnabled} className="mt-2 w-full accent-green-500 disabled:opacity-40" /></label></section>
<button onClick={() => setShowAudioEffectsMenu(false)} className="w-full rounded-xl bg-green-500 p-3 text-sm font-medium text-black hover:bg-green-400"></button>
</aside></div></div>
</div>
</div>
)}
{/* Quality Selection Menu */}
{showQualityMenu && (
<div