diff --git a/public/music/effects/bright-hall.wav b/public/music/effects/bright-hall.wav new file mode 100644 index 0000000..cc2f47a Binary files /dev/null and b/public/music/effects/bright-hall.wav differ diff --git a/public/music/effects/cardiod-35-10-spread.wav b/public/music/effects/cardiod-35-10-spread.wav new file mode 100644 index 0000000..151c5f1 Binary files /dev/null and b/public/music/effects/cardiod-35-10-spread.wav differ diff --git a/public/music/effects/cinema-diningroom.wav b/public/music/effects/cinema-diningroom.wav new file mode 100644 index 0000000..2d13980 Binary files /dev/null and b/public/music/effects/cinema-diningroom.wav differ diff --git a/public/music/effects/dining-living-true-stereo.wav b/public/music/effects/dining-living-true-stereo.wav new file mode 100644 index 0000000..64b9735 Binary files /dev/null and b/public/music/effects/dining-living-true-stereo.wav differ diff --git a/public/music/effects/feedback-spring.wav b/public/music/effects/feedback-spring.wav new file mode 100644 index 0000000..fb40d64 Binary files /dev/null and b/public/music/effects/feedback-spring.wav differ diff --git a/public/music/effects/filter-telephone.wav b/public/music/effects/filter-telephone.wav new file mode 100644 index 0000000..47c50e7 Binary files /dev/null and b/public/music/effects/filter-telephone.wav differ diff --git a/public/music/effects/living-bedroom-leveled.wav b/public/music/effects/living-bedroom-leveled.wav new file mode 100644 index 0000000..43c5be0 Binary files /dev/null and b/public/music/effects/living-bedroom-leveled.wav differ diff --git a/public/music/effects/matrix-reverb1.wav b/public/music/effects/matrix-reverb1.wav new file mode 100644 index 0000000..58b92eb Binary files /dev/null and b/public/music/effects/matrix-reverb1.wav differ diff --git a/public/music/effects/matrix-reverb2.wav b/public/music/effects/matrix-reverb2.wav new file mode 100644 index 0000000..15f87b1 Binary files /dev/null and b/public/music/effects/matrix-reverb2.wav differ diff --git a/public/music/effects/medium-room1.wav b/public/music/effects/medium-room1.wav new file mode 100644 index 0000000..5c34914 Binary files /dev/null and b/public/music/effects/medium-room1.wav differ diff --git a/public/music/effects/s2_r4_bd.wav b/public/music/effects/s2_r4_bd.wav new file mode 100644 index 0000000..4bb6388 Binary files /dev/null and b/public/music/effects/s2_r4_bd.wav differ diff --git a/public/music/effects/s3_r1_bd.wav b/public/music/effects/s3_r1_bd.wav new file mode 100644 index 0000000..d6276f0 Binary files /dev/null and b/public/music/effects/s3_r1_bd.wav differ diff --git a/public/music/effects/spreader50-65ms.wav b/public/music/effects/spreader50-65ms.wav new file mode 100644 index 0000000..f5b091a Binary files /dev/null and b/public/music/effects/spreader50-65ms.wav differ diff --git a/public/music/effects/tim-omni-35-10-magnetic.wav b/public/music/effects/tim-omni-35-10-magnetic.wav new file mode 100644 index 0000000..927c703 Binary files /dev/null and b/public/music/effects/tim-omni-35-10-magnetic.wav differ diff --git a/src/app/music/MusicClient.tsx b/src/app/music/MusicClient.tsx index ec6c952..01d25ee 100644 --- a/src/app/music/MusicClient.tsx +++ b/src/app/music/MusicClient.tsx @@ -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(DEFAULT_EQ_GAINS); + const [loudnessEnabled, setLoudnessEnabled] = useState(false); + const [reverbEnabled, setReverbEnabled] = useState(false); + const [reverbMix, setReverbMix] = useState(12); + const [reverbPreset, setReverbPreset] = useState('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(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(null); const lyricsContainerRef = useRef(null); const sleepHoursPickerRef = useRef(null); @@ -302,6 +381,14 @@ export default function MusicClient({ children: _children }: { children?: React. const analyserRef = useRef(null); const mediaSourceRef = useRef(null); const spectrumDataRef = useRef(null); + const eqFiltersRef = useRef([]); + const loudnessGainRef = useRef(null); + const compressorRef = useRef(null); + const convolverRef = useRef(null); + const dryGainRef = useRef(null); + const wetGainRef = useRef(null); + const pannerRef = useRef(null); + const pannerTimerRef = useRef(null); const spectrumFrameRef = useRef(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. + {/* PiP 歌词按钮 */} +
+

10 段均衡器

+
{EQ_BANDS.map((band, index) => (
{(eqGains[index] || 0) > 0 ? '+' : ''}{(eqGains[index] || 0).toFixed(1)}
{ 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" />
{band.label}
))}
+

预设

{Object.keys(EQ_PRESETS).map(name => ())}
+
+
+ + + )} + {/* Quality Selection Menu */} {showQualityMenu && (