Repository navigation
feat: 均衡器和变速 - #12
feat: 均衡器和变速#12
Conversation
There was a problem hiding this comment.
Pull request overview
该 PR 为播放器新增「均衡器(10 频段 + 前级)」与「变速/变调(含音调同步)」能力,并在 UI 中提供对应弹窗入口与交互控件,同时完善歌词加载在“解析为空”场景下的处理。
Changes:
- 新增均衡器系统设置(默认值、类型、启动时下发、运行时配置联动)与对应 UI 弹窗
- 新增变速/变调能力:渲染端状态与时间插值同步、主进程 IPC、原生音频引擎 StretchProcessor 接入
- 改造通用滑块组件 SSlider:支持垂直模式与中心填充,用于均衡器/音调调整
Reviewed changes
Copilot reviewed 33 out of 36 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/color.ts | 调整 surfaceBright 调色板与注释 |
| src/stores/status.ts | 增加 speed/pitch/pitchSync 运行时状态(不持久化) |
| src/stores/media.ts | setLyric:解析为空时将其视为无歌词并清空内容/来源 |
| src/services/playback.ts | 插值时间源引入 speed,并新增 setSpeed/reset 行为 |
| src/services/lyricLoader.ts | 在线歌词提交与本地回退/TTML 升级流程重构 |
| src/i18n/locales/zh-CN.json | 新增均衡器/变速相关文案与 common.close |
| src/i18n/locales/en-US.json | 新增均衡器/变速相关文案与 common.close |
| src/core/player.ts | 新增 setSpeed/setPitch/setPitchSync;initPlayer 启动时下发均衡器配置 |
| src/components/ui/STooltip.vue | Tooltip 样式调整(去边框) |
| src/components/ui/SSlider.vue | Slider 支持 vertical/centerFill,调整默认 thumb 展示等 |
| src/components/ui/SSelect.vue | Select 样式调整(边框/阴影) |
| src/components/ui/SPopselect.vue | Popselect 样式调整(去边框) |
| src/components/ui/SPopover.vue | Popover 样式调整(去边框) |
| src/components/ui/SDropdownMenu.vue | DropdownMenu 内容样式调整(去边框) |
| src/components/ui/SDialog.vue | Dialog 非 cover 背景改为 surface-alt |
| src/components/ui/SContextMenu.vue | ContextMenu 内容样式调整(去边框) |
| src/components/player/FullPlayer/index.vue | Seek Slider 显式关闭 alwaysShowThumb |
| src/components/player/FullPlayer/PlayerBar.vue | 播放条新增“更多”菜单入口,打开均衡器/变速弹窗 |
| src/components/modals/SpeedDialog.vue | 新增播放速度/音调同步/音调调整弹窗 |
| src/components/modals/EqualizerDialog.vue | 新增均衡器弹窗(预设、前级、10 频段) |
| shared/types/settings.ts | 增加 EqualizerPreset/EqualizerSettings,并挂到 PlayerSettings |
| shared/types/player.ts | PlayerApi 增加均衡器/变速/变调相关方法声明 |
| shared/defaults/settings.ts | system.player 默认值新增 equalizer 配置 |
| native/audio-engine/src/tempo.rs | 新增 StretchProcessor(变速/变调核心处理) |
| native/audio-engine/src/source.rs | DecoderSource 接入 EQ + tempo 处理链 |
| native/audio-engine/src/player.rs | InnerPlayer 增加跨曲目共享的 equalizer/tempo,并在 load/seek reset 状态 |
| native/audio-engine/src/lib.rs | NAPI 暴露均衡器/变速/变调相关方法 |
| native/audio-engine/src/equalizer.rs | 新增 10 频段参数均衡器实现 |
| native/audio-engine/index.d.ts | 补充原生模块 TS 类型声明(均衡器/变速/变调) |
| native/audio-engine/Cargo.toml | 引入 signalsmith-stretch 依赖 |
| electron/preload/index.ts | preload 暴露 player.setEqualizer*/setSpeed/setPitch/setPitchSync |
| electron/main/services/engine.ts | 增加均衡器相关同步方法(供 config 副作用调用) |
| electron/main/ipc/player.ts | 新增均衡器/变速/变调 IPC handler |
| electron/main/ipc/config.ts | 系统配置变更副作用:把均衡器配置同步到引擎 |
| components.d.ts | 注册新增组件类型(EqualizerDialog/SpeedDialog) |
| Cargo.lock | 锁文件更新以包含新增 Rust 依赖 |
| /** 重置所有状态 */ | ||
| export const reset = (): void => { | ||
| currentTimeMs = 0; | ||
| totalDurationMs = 0; | ||
| lastSyncAt = 0; | ||
| playing = false; | ||
| seeking = false; | ||
| speed = 1.0; |
There was a problem hiding this comment.
playback.reset() 里把 speed 重置为 1.0,但 status.speed 并未在 stop/onQueueEnded 时同步重置;同时引擎侧 tempo 参数也不会在 stop_internal 里清空。结果是:用户曾设置变速后执行 stop/队列结束,再次播放时引擎仍按旧 speed 播放,但前端插值时间源变回 1.0,歌词高亮/进度显示会漂。建议:reset 不要重置 speed(让其随会话延续),或在 stop/onQueueEnded 时同时把引擎、status、playback 三方都重置到 1.0。
| const applyOnline = async ( | ||
| token: number, | ||
| track: Track, | ||
| online: OnlineResult, | ||
| fallbackLocal: LocalLyric | null, | ||
| ): Promise<void> => { | ||
| commit(token, online.source, online.input); | ||
| if (token !== currentToken) return; | ||
| if (useMediaStore().parsedLyric.length === 0) { | ||
| if (fallbackLocal) commitLocal(token, fallbackLocal); | ||
| return; | ||
| } | ||
| if (shouldTryTTML(online.source.platform, online.source.format)) { | ||
| await tryTTMLOverlay(token, track, online.source.platform); | ||
| } |
There was a problem hiding this comment.
applyOnline 的控制流会导致“在线歌词解析为空”时永远不会触发 TTML overlay:parsedLyric.length===0 分支里无论是否存在 fallbackLocal 都直接 return;且 loadForTrack/refreshPreference 还会把 local(即便解析为空)作为 fallbackLocal 传入,进一步提前 return。建议:仅在“本地解析后确实有可用行”时才回退本地;若在线解析为空且无可用本地,则仍应按 shouldTryTTML 尝试 TTML overlay(与函数注释一致)。
| /** | ||
| * 设置播放速度 | ||
| * @param v - 速度(0.5 ~ 2.0),引擎侧自动 clamp | ||
| */ | ||
| export const setSpeed = async (v: number): Promise<void> => { | ||
| const result = await window.api.player.setSpeed(v); | ||
| if (result.success) { | ||
| useStatusStore().speed = v; | ||
| // 同步给 playback 时间源,让墙钟插值正确换算到源时间(影响歌词高亮) | ||
| playback.setSpeed(v); | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * 设置音调偏移(半音 -12 ~ 12) | ||
| */ | ||
| export const setPitch = async (n: number): Promise<void> => { | ||
| const result = await window.api.player.setPitch(n); | ||
| if (result.success) useStatusStore().pitch = n; | ||
| }; |
There was a problem hiding this comment.
setSpeed/setPitch 注释写“引擎侧自动 clamp”,但这里在 IPC 成功后直接把传入值写入 status store,并同步给 playback 时间源;如果调用方传入越界或 NaN(哪怕引擎内部 clamp/处理),前端状态与实际播放会不一致,歌词插值也会错。建议在调用 window.api.player 前先在渲染端 clamp/过滤到合法范围(speed: [0.5,2] 且 finite;pitch: [-12,12] 且整数),并用 clamp 后的值更新 store/playback。
| const BAND_MIN = -12; | ||
| const BAND_MAX = 12; | ||
| const PREAMP_MIN = -12; | ||
| const PREAMP_MAX = 12; |
There was a problem hiding this comment.
这里 BAND_MIN/BAND_MAX 设为 ±12,但共享类型注释(shared/types/settings.ts:25)以及引擎侧 clamp(native/audio-engine/src/equalizer.rs)都是按 ±15 设计;会造成“规格/持久化/引擎能力”和 UI 可调范围不一致。建议统一:要么把 UI 调整到 ±15,要么同步修改类型注释与引擎限制(并说明为何限制为 ±12)。
| const BAND_MIN = -12; | |
| const BAND_MAX = 12; | |
| const PREAMP_MIN = -12; | |
| const PREAMP_MAX = 12; | |
| const BAND_MIN = -15; | |
| const BAND_MAX = 15; | |
| const PREAMP_MIN = -15; | |
| const PREAMP_MAX = 15; |
| const bands = [...EQ_PRESETS[key]]; | ||
| localBands.value = bands; | ||
| window.api.player.setEqualizerBands(bands).catch(() => {}); | ||
| settings.setSystem("player.equalizer.bands", bands); | ||
| settings.setSystem("player.equalizer.preset", key); | ||
| }; | ||
|
|
||
| const onBandChange = (index: number, value: number): void => { | ||
| localBands.value[index] = value; | ||
| }; | ||
|
|
||
| const onBandCommit = (): void => { | ||
| const bands = [...localBands.value]; | ||
| const matched = matchPreset(bands); | ||
| window.api.player.setEqualizerBands(bands).catch(() => {}); | ||
| settings.setSystem("player.equalizer.bands", bands); | ||
| settings.setSystem("player.equalizer.preset", matched); | ||
| }; | ||
|
|
||
| const onPreampChange = (value: number): void => { | ||
| localPreamp.value = value; | ||
| }; | ||
|
|
||
| const onPreampCommit = (): void => { | ||
| window.api.player.setPreampGain(localPreamp.value).catch(() => {}); | ||
| settings.setSystem("player.equalizer.preamp", localPreamp.value); | ||
| }; |
There was a problem hiding this comment.
EqualizerDialog 里对 bands/preamp 的修改同时调用了 window.api.player.*(直接走 player IPC)和 settings.setSystem(走 config IPC → main/applyConfigChange → engine 服务)。这会导致同一操作产生两次 IPC/两次下发,且有潜在竞态(后到的 config 同步可能覆盖先到的直接调用)。建议统一副作用入口:优先只用 settings.setSystem 触发主进程 applyConfigChange;如果确实需要“立即生效”,考虑把后处理放到 settings.setSystem(类似 fadeDuration 的处理),避免在 UI 层同时走两条链路。
| /// 设置播放速度,自动 clamp 到 [0.5, 2.0] | ||
| pub(crate) fn set_speed(&mut self, speed: f32) { | ||
| self.speed = speed.clamp(0.5, 2.0); | ||
| // sync=OFF 时 transpose 跟着 speed 变 | ||
| if !self.pitch_sync { | ||
| self.sync_transpose_to_stretch(); | ||
| } | ||
| } |
There was a problem hiding this comment.
StretchProcessor::set_speed 直接对 speed 做 clamp,但 f32::clamp 遇到 NaN 会返回 NaN,随后 effective_transpose/log2 和 process 里的 output_frames 计算都会产生 NaN,可能导致整段音频不产出(甚至出现异常行为)。建议在这里(或更上游 NAPI/IPC 边界)先对 speed 做 is_finite 检查;非有限值可回退到 1.0,再 clamp 到 [0.5, 2.0]。
| /// 更新所有频段增益(dB),长度不等于 EQ_BAND_COUNT 时按短端截取 | ||
| pub fn set_band_gains(&mut self, gains_db: &[f32]) { | ||
| for (i, gain) in gains_db.iter().take(EQ_BAND_COUNT).enumerate() { | ||
| self.band_gains_db[i] = gain.clamp(-BAND_GAIN_LIMIT_DB, BAND_GAIN_LIMIT_DB); | ||
| } | ||
| self.recompute_coefficients(); | ||
| } | ||
|
|
||
| pub fn band_gains_db(&self) -> [f32; EQ_BAND_COUNT] { | ||
| self.band_gains_db | ||
| } | ||
|
|
||
| pub fn set_preamp_db(&mut self, db: f32) { | ||
| let clamped = db.clamp(-PREAMP_LIMIT_DB, PREAMP_LIMIT_DB); | ||
| self.preamp_linear = db_to_linear(clamped); | ||
| } |
There was a problem hiding this comment.
Equalizer::set_band_gains / set_preamp_db 里对输入直接 clamp,但 clamp 遇到 NaN/Inf 会保留 NaN,随后系数计算/对数会传播 NaN,最终把输出样本变成 NaN,可能引发播放异常。建议在写入 band_gains_db / preamp_linear 前对输入做 is_finite 过滤(非有限值当作 0dB),再进行范围 clamp;同时 recompute_coefficients 也应避免用 NaN gain 生成系数。
- 变速 reset 不再清 speed,避免 stop 后会话内速度漂移 - 在线歌词解析为空且无本地时,仍按需触发 TTML overlay - EQ band 范围 ±12 → ±15,与引擎和类型注释对齐 - EQ 弹窗只走 settings.setSystem,去掉直连 player IPC 的双链路 - core/player.ts setSpeed/setPitch 入口加 finite + clamp - tempo.rs / equalizer.rs 对 NaN/Inf 守卫,避免污染 DSP 状态 Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
No description provided.