feat: 新增音效功能 (8D / 3D / 混响 / 超重低音 / 清澈人声)#1041
Conversation
在 AudioEffectManager 中引入 StereoPannerNode 和 LFO (OscillatorNode + GainNode),周期性调制 pan 参数实现 8D 环绕效果。使用纯 Web Audio API,Web 版与 Electron 桌面版均可用,MPV 引擎通过 capabilities 能力位自动禁用。 功能点: - 4 个预设:经典 8D / 慢摇 / 快摇 / 乒乓 + 自定义 - 可调摇摆速率 (0.05 ~ 2 Hz) 与深度 (0 ~ 1) - 三种 LFO 波形:sine / triangle / square - 开关启停使用 linearRamp 渐变,避免爆音 - 持久化至 statusStore,切歌时自动恢复 - UI 入口位于「更多功能」菜单,和均衡器并列
将原先单一的"空间音效 (Auto-Pan)"扩展为完整的音效管理系统,参考 网易云等主流播放器的音效菜单设计。 新增效果: - 3D HRTF 环绕:基于 PannerNode HRTF 模型 + 双 LFO (正余弦相位差) 驱动声源做圆周运动,比 8D 更接近真 3D 环绕 - 空间混响:ConvolverNode + 程序化生成脉冲响应 (白噪声 + 指数衰减) 提供音乐厅/KTV/小房间三种场景预设,dry/wet 可调 - 超重低音:BiquadFilterNode lowshelf @80Hz,独立开关 - 清澈人声:BiquadFilterNode peaking @2.5kHz,独立开关 8D 简化: - 移除预设 (原 4 个预设 + 自定义) 和波形选择 (sine/triangle/square) - 仅保留速率与深度两个核心参数 架构变更: - EngineCapabilities.supportsSpatialAudio → supportsAudioEffects - StatusStore 字段 spatial* → effect8d* / effect3d* / reverb* / bassBoost* / vocalEnhance* - 模态组件 SpatialAudio.vue → SoundEffects.vue (分组 UI) - 菜单项 "空间音效" → "音效" - PlayerController 提供 updateEffect8d/updateEffect3d/updateReverb/ updateBassBoost/updateVocalEnhance 五个独立控制方法 音频链路: Input → HP → LP → EQ[10] → BassShelf → VocalPeak → Analyser → StereoPanner(8D) → Panner(3D) → ReverbMix(dry/wet) → Output 8D 和 3D 在 UI 层做互斥 (开启一个自动关闭另一个),底层节点始终 存在于链路中,关闭时通过深度增益为 0 实现直通。
修复 3D 环绕 bug: - 原 PannerNode 的 positionZ 叠加了双份 -1 偏移 (一份静态 positionZ.value = -1,一份 ConstantSourceNode offset = -1), 导致轨迹中心点在 Z = -2,声源只在听者前方远处晃动,无法形成 真正的环绕 - 现移除 ConstantSourceNode,positionZ 初始为 0,让圆周以听者 为中心:t=0 前 → t=T/4 右 → t=T/2 后 → t=3T/4 左 → t=T 前 - 同时把 ensureLfosStarted / disconnect 里的大 try/catch 拆成 每个 LFO 独立 try,避免一个失败影响其它 优化默认值: - effect3dRate: 0.25 → 0.15 Hz (约 6.7 秒绕一圈,更有沉浸感) - reverbWet: 0.4 → 0.35 (经典音乐厅湿度) - 其余 8D/低音/人声/半径等参数已是最佳范围,保持不变 新增重置按钮: - 模态顶部加「重置」按钮,一键关闭所有开关并恢复默认参数 - 默认值集中存于 DEFAULT_EFFECTS 常量,便于维护 - 重置后弹 toast 提示"已重置所有音效"
There was a problem hiding this comment.
Code Review
This pull request implements a comprehensive audio effects system featuring 8D and 3D spatial audio, reverb, bass boost, and vocal enhancement. The changes include a new SoundEffects UI component, state persistence in the status store, and significant updates to the AudioEffectManager to integrate Web Audio API nodes like StereoPanner, PannerNode, and ConvolverNode. Feedback focuses on the fragile use of try-catch blocks in AudioEffectManager.ts to manage the lifecycle of OscillatorNode instances, suggesting that explicit state checks should be used instead of error suppression to avoid hiding genuine runtime issues.
| private ensureLfosStarted() { | ||
| if (this.lfosStarted) return; | ||
| // 每个 LFO 独立 try,避免一个失败影响其它 | ||
| try { | ||
| this.effect8dLfo?.start(); | ||
| } catch { | ||
| /* 已启动 */ | ||
| } | ||
| try { | ||
| this.effect3dLfoX?.start(); | ||
| } catch { | ||
| /* 已启动 */ | ||
| } | ||
| try { | ||
| this.effect3dLfoZ?.start(); | ||
| } catch { | ||
| /* 已启动 */ | ||
| } | ||
| this.lfosStarted = true; | ||
| } |
There was a problem hiding this comment.
The ensureLfosStarted method uses a try-catch block to suppress errors when starting oscillators. This is a fragile pattern that can hide genuine runtime issues. Instead of suppressing errors, consider checking the OscillatorNode state or managing the lifecycle more explicitly to ensure start() is only called once.
| if (this.lfosStarted) { | ||
| try { | ||
| this.effect8dLfo?.stop(); | ||
| } catch { | ||
| /* 已停止 */ | ||
| } | ||
| try { | ||
| this.effect3dLfoX?.stop(); | ||
| } catch { | ||
| /* 已停止 */ | ||
| } | ||
| try { | ||
| this.effect3dLfoZ?.stop(); | ||
| } catch { | ||
| /* 已停止 */ | ||
| } | ||
| } |
There was a problem hiding this comment.
Similar to ensureLfosStarted, the disconnect method uses try-catch blocks to suppress errors when stopping oscillators. This is an anti-pattern. If the oscillators are properly tracked, these errors should not occur. If they are expected, they should be handled with explicit state checks rather than exception suppression.
There was a problem hiding this comment.
Pull request overview
This PR adds a new “音效” feature (8D / 3D HRTF / 混响 / 超重低音 / 清澈人声) to the player, implemented via a Web Audio processing chain and exposed through a new modal entry in the “更多功能” menu, with per-effect persistence and canplay-time restoration.
Changes:
- Introduces
AudioEffectManagersupport for 8D/3D spatial effects, reverb, bass boost, and vocal enhance in the Web Audio graph. - Adds
supportsAudioEffectscapability gating and forwards effect controls throughAudioManager/PlayerController. - Adds UI + persistence: new
SoundEffectsmodal, status store fields/actions, and automatic restoration oncanplay.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/core/audio-player/AudioEffectManager.ts | Implements new Web Audio effect nodes (8D/3D/reverb/bass/vocal) and connects them into the processing chain. |
| src/core/audio-player/BaseAudioPlayer.ts | Exposes effect setter APIs that delegate to AudioEffectManager. |
| src/core/audio-player/IPlaybackEngine.ts | Adds supportsAudioEffects capability and optional effect-control methods to the engine interface. |
| src/core/audio-player/AudioElementPlayer.ts | Enables supportsAudioEffects for HTMLAudioElement-based engine. |
| src/core/audio-player/ffmpeg-engine/FFmpegAudioPlayer.ts | Enables supportsAudioEffects for FFmpeg engine. |
| src/core/audio-player/MpvPlayer.ts | Disables supportsAudioEffects for MPV engine. |
| src/core/player/AudioManager.ts | Proxies new effect-control methods to the active engine. |
| src/core/player/PlayerController.ts | Restores persisted effect state on canplay; adds public update APIs for each effect. |
| src/stores/status.ts | Adds persisted effect fields + setter actions for UI control and restoration. |
| src/components/Modal/SoundEffects.vue | New modal UI for controlling effects, parameters, and “重置” behavior. |
| src/components/Player/PlayerRightMenu.vue | Adds “音效” entry in “更多功能” dropdown, gated by capabilities. |
| src/utils/modal.ts | Adds openSoundEffects() modal opener with lazy import. |
| /** 设置 8D 速率 (Hz) */ | ||
| setEffect8dRate(hz: number) { | ||
| if (Number.isFinite(hz)) { | ||
| this.effect8dRate = Math.max(0.05, Math.min(4, hz)); |
There was a problem hiding this comment.
setEffect8dRate currently clamps the maximum rate to 4Hz, but the UI slider limits 8D 速率 to 2Hz. This mismatch can lead to persisted/store values that the UI cannot represent cleanly. Align the allowed range across store/UI/engine (either raise the slider max or clamp here to 2Hz).
| this.effect8dRate = Math.max(0.05, Math.min(4, hz)); | |
| this.effect8dRate = Math.max(0.05, Math.min(2, hz)); |
| /** 设置 3D 速率 (Hz) */ | ||
| setEffect3dRate(hz: number) { | ||
| if (Number.isFinite(hz)) { | ||
| this.effect3dRate = Math.max(0.02, Math.min(3, hz)); |
There was a problem hiding this comment.
setEffect3dRate clamps the maximum to 3Hz, but the SoundEffects UI slider caps 3D 旋转速度 at 1.5Hz. To avoid impossible-to-set/restore values, please make the range consistent across store/UI/engine (clamp to 1.5Hz or update the slider/spec).
| /** 设置 3D 速率 (Hz) */ | |
| setEffect3dRate(hz: number) { | |
| if (Number.isFinite(hz)) { | |
| this.effect3dRate = Math.max(0.02, Math.min(3, hz)); | |
| /** 设置 3D 速率 (Hz, 0.02-1.5) */ | |
| setEffect3dRate(hz: number) { | |
| if (Number.isFinite(hz)) { | |
| this.effect3dRate = Math.max(0.02, Math.min(1.5, hz)); |
| * Input | ||
| * → HighPass → LowPass (AutoMIX 专用) | ||
| * → EQ[10 段] | ||
| * → BassShelf (超重低音) |
There was a problem hiding this comment.
The audio chain doc comment says the bass effect is BassShelf, but the implementation uses a peaking filter (this.bassBoost.type = "peaking" at 100Hz). Please update the comment to match the actual node/filter type so the documented pipeline stays accurate.
| * → BassShelf (超重低音) | |
| * → BassPeak (超重低音) |
| public setEffect8dRate(hz: number) { | ||
| if (!this.effect8dLfo) return; | ||
| const safeHz = Math.max(0.05, Math.min(4, hz)); | ||
| const currentTime = this.audioCtx.currentTime; | ||
| this.effect8dLfo.frequency.cancelScheduledValues(currentTime); | ||
| this.effect8dLfo.frequency.setValueAtTime(this.effect8dLfo.frequency.value, currentTime); | ||
| this.effect8dLfo.frequency.linearRampToValueAtTime(safeHz, currentTime + 0.1); |
There was a problem hiding this comment.
setEffect8dRate clamps up to 4Hz, but the SoundEffects UI slider currently caps 8D 速率 at 2Hz. Please align the allowed range between engine + store + UI to prevent restoring values that the UI cannot represent.
| public setEffect3dRate(hz: number) { | ||
| if (!this.effect3dLfoX || !this.effect3dLfoZ) return; | ||
| const safeHz = Math.max(0.02, Math.min(3, hz)); | ||
| const currentTime = this.audioCtx.currentTime; | ||
| const rampEnd = currentTime + 0.1; | ||
| for (const lfo of [this.effect3dLfoX, this.effect3dLfoZ]) { | ||
| lfo.frequency.cancelScheduledValues(currentTime); | ||
| lfo.frequency.setValueAtTime(lfo.frequency.value, currentTime); | ||
| lfo.frequency.linearRampToValueAtTime(safeHz, rampEnd); | ||
| } |
There was a problem hiding this comment.
setEffect3dRate currently clamps up to 3Hz, while the SoundEffects UI slider caps 3D 旋转速度 at 1.5Hz. Please make the range consistent across engine + store + UI (either clamp to 1.5Hz here or increase the UI max/spec).
| <div class="effect-header"> | ||
| <div class="effect-info"> | ||
| <div class="effect-name">超重低音</div> | ||
| <div class="effect-desc">提升 80Hz 以下的低频能量</div> |
There was a problem hiding this comment.
The "超重低音" description says it boosts energy below 80Hz, but the implementation is a peaking filter centered at 100Hz (per AudioEffectManager). Please adjust the UI text to match the actual effect so users aren’t misled about what frequency range is being boosted.
| <div class="effect-desc">提升 80Hz 以下的低频能量</div> | |
| <div class="effect-desc">提升 100Hz 附近的低频能量</div> |
关联讨论: #1039
✨ 新增功能
在「更多功能」下拉菜单新增「音效」入口 (和均衡器并列),点击后弹出模态,分三组五个效果,每个独立开关 +
参数调整,另有一键「重置」按钮恢复默认值。
🎧 空间环绕组 (互斥,开一个自动关另一个)
8D 环绕 - 声音在左右耳之间周期性游走
StereoPannerNode+OscillatorNodeLFO 调制 pan 参数3D 环绕 (HRTF) - 声源绕头做圆周运动
PannerNode(HRTF 模型) + 双 LFO (正/余弦相位差 90°) 驱动 X/Z 坐标,轨迹: 前→右→后→左→前🏛️ 混响组
ConvolverNode+ 程序化生成的脉冲响应 (白噪声 + 指数衰减,无需额外 IR 文件),dry/wet 并行混合结构🔊 音色增强组 (独立,可叠加)
超重低音 - 提升低频能量
BiquadFilterNodepeaking @ 100Hz, Q=0.8 (实测此参数比 lowshelf@80Hz 效果明显得多)清澈人声 - 提升人声存在感
BiquadFilterNodepeaking @ 2.5kHz, Q=0.9🏗️ 技术细节
音频链路
Input
→ HighPass → LowPass (AutoMIX 专用,原有)
→ EQ[10 段] (均衡器,原有)
→ BassPeak (超重低音,新增)
→ VocalPeak (清澈人声,新增)
→ Analyser (频谱分析,原有)
→ StereoPanner (8D 环绕,新增)
→ Panner (3D HRTF 环绕,新增)
→ ReverbMix (dry/wet) (空间混响,新增)
→ Output
每个新增节点都通过 depth/gain 为 0 的方式实现"直通",关闭时完全不影响原有信号,零副作用。
架构变更
EngineCapabilities新增supportsAudioEffects: boolean能力位AudioElementPlayer/FFmpegAudioPlayer启用该能力MpvPlayer禁用 (外部进程无法接入 Web Audio 图),菜单项自动灰掉StatusStore新增 13 个持久化字段,切歌时canplay事件自动恢复PlayerController暴露updateEffect8d/updateEffect3d/updateReverb/updateBassBoost/updateVocalEnhance五个独立控制方法
AudioManager做代理转发到当前激活的引擎平台兼容性
🎯 设计决策
为什么选 Web Audio API 而不是 Rust 原生模块:零跨平台维护成本,且所有节点都是标准浏览器 API,桌面和 Web 版完全对等
为什么 8D 和 3D 互斥:两者都在操控 stereo field,同时启用会产生难以预料的相互干扰。UI 层做互斥,底层节点始终存在(通过
depth/radius 为 0 实现直通)
为什么混响用程序化 IR 而不是真实 IR 文件:避免引入额外资源文件,Docker/Vercel 产物不增大。合成 IR 虽然不如真实 IR
逼真,但足以让用户明确听到"有没有混响"的差别
为什么超重低音用 peaking@100Hz 而不是 lowshelf@80Hz:实测 lowshelf 截止频率太低,100Hz (流行音乐鼓点基频) 只能得到约 +5dB
的提升。改用 peaking@100Hz 后峰值直接落在耳朵敏感区,效果立刻明显
重置按钮:考虑到普通用户不会调参,提供一键恢复默认值 + 关闭所有效果的「重置」按钮,降低误操作成本
🧪 测试方法
已通过 Docker Web 版 (基于项目自带的 Dockerfile 构建) 在 Chrome 下实测,5 个效果均工作正常。建议测试歌曲:
由于渲染进程代码完全共享,Web 版通过即代表 Electron 版通过。仅在切换到 MPV 引擎时音效功能自动禁用。
HRTF 精度:Web Audio 的 HRTF 数据集是简化版,前/后方位感不如专业 VR 设备 (如 AirPods Spatial Audio),左/右方位感强烈
MPV 引擎:外部进程音频不经过 Web Audio 图,对 MPV 引擎模式无效 (已通过 capabilities 自动禁用)
📁 改动范围
共 11 个文件修改 + 1 个新增,约 750 行新增。不涉及原生模块、不涉及主进程、不涉及 IPC,全部在渲染进程内完成。
src/core/audio-player/AudioEffectManager.ts- 核心实现src/core/audio-player/BaseAudioPlayer.ts- 暴露 settersrc/core/audio-player/IPlaybackEngine.ts- 接口定义 + 新能力位src/core/audio-player/AudioElementPlayer.ts- capabilitiessrc/core/audio-player/MpvPlayer.ts- capabilitiessrc/core/audio-player/ffmpeg-engine/FFmpegAudioPlayer.ts- capabilitiessrc/core/player/AudioManager.ts- 代理转发src/core/player/PlayerController.ts- 公共 API + canplay 恢复src/stores/status.ts- 13 个新字段 + setters + persistsrc/components/Modal/SoundEffects.vue- UI 主体 (新增)src/components/Player/PlayerRightMenu.vue- 菜单入口src/utils/modal.ts- openSoundEffects🙏 致谢
感谢作者的不断维护。第一次给 SPlayer 贡献代码,如果命名、UI、位置、参数范围等任何地方不太规范,还请谅解。