From aad01df950dfb986711def1ca542fa1f06b080da Mon Sep 17 00:00:00 2001 From: foragedysetour Date: Sun, 25 Oct 2026 16:38:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BD=8E=E9=9F=B3?= =?UTF-8?q?=E9=87=8F=E6=AE=B5=E6=97=A0=E5=A3=B0=E4=B8=8E=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC=E6=97=B6=E7=9A=84=E9=9F=B3=E9=87=8F=E7=AA=81?= =?UTF-8?q?=E5=A2=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native/audio-engine/src/output/cpal_stream.rs | 11 ++++- native/audio-engine/src/output/wasapi/mod.rs | 10 ++-- native/audio-engine/src/output/wasapi/pcm.rs | 18 ++++++- src/components/player/Toolbar.spec.ts | 28 +++++------ src/utils/volume.spec.ts | 48 +++++++++++++++++++ src/utils/volume.ts | 38 +++++++++++---- 6 files changed, 124 insertions(+), 29 deletions(-) create mode 100644 src/utils/volume.spec.ts diff --git a/native/audio-engine/src/output/cpal_stream.rs b/native/audio-engine/src/output/cpal_stream.rs index 9292ccdf9..dfd9afa70 100644 --- a/native/audio-engine/src/output/cpal_stream.rs +++ b/native/audio-engine/src/output/cpal_stream.rs @@ -44,6 +44,7 @@ where T: SizedSample + Sample + FromSample, { let mut xruns = 0_u64; + let mut current_gain: Option = None; let stream = { #[cfg(target_os = "linux")] let _props_guard = @@ -53,14 +54,22 @@ where config, move |data: &mut [T], _| { source.begin_callback(); - let gain = f32::from_bits(volume.load(Ordering::Relaxed)); if stopped.load(Ordering::Acquire) { data.fill(T::EQUILIBRIUM); + current_gain = None; return; } + // 首个缓冲直接采用目标增益:新流创建时原子量还是占位值,从它渐变会把满音量灌进缓冲; + // 运行中的音量变化才在同一缓冲内线性过渡,避免拖动音量或静音时爆音 + let target = f32::from_bits(volume.load(Ordering::Relaxed)); + let from = current_gain.unwrap_or(target); + let step = (target - from) / data.len().max(1) as f32; + let mut gain = from; for output in data { + gain += step; *output = T::from_sample(source.next().unwrap_or(0.0) * gain); } + current_gain = Some(target); }, move |error| { if !output_error_requires_rebuild(error.kind()) { diff --git a/native/audio-engine/src/output/wasapi/mod.rs b/native/audio-engine/src/output/wasapi/mod.rs index 604133d54..848bb39d7 100644 --- a/native/audio-engine/src/output/wasapi/mod.rs +++ b/native/audio-engine/src/output/wasapi/mod.rs @@ -329,11 +329,12 @@ fn prefill_buffer( ) -> Result<()> { unsafe { let ptr = render.GetBuffer(buffer_frames).context("启动预填充失败")?; - let gain = f32::from_bits(volume.load(Ordering::Relaxed)); + let target = f32::from_bits(volume.load(Ordering::Relaxed)); + let mut gain: Option = None; let silent = stopped.load(Ordering::Acquire) || paused.load(Ordering::Acquire); let block_align = usize::from(format.channels * format.container_bits / 8); let byte_buffer = std::slice::from_raw_parts_mut(ptr, buffer_frames as usize * block_align); - fill_buffer(byte_buffer, source, gain, silent, format); + fill_buffer(byte_buffer, source, &mut gain, target, silent, format); render .ReleaseBuffer(buffer_frames, 0) .context("启动预填充提交失败") @@ -391,6 +392,7 @@ fn render_loop( let mut last_wake = Instant::now(); let mut late_wakes = 0u64; let mut longest_gap = Duration::ZERO; + let mut gain: Option = None; loop { let wait = unsafe { WaitForMultipleObjects(&wait_handles, false, INFINITE) }; @@ -423,12 +425,12 @@ fn render_loop( } }; - let gain = f32::from_bits(volume.load(Ordering::Relaxed)); + let target = f32::from_bits(volume.load(Ordering::Relaxed)); let silent = stopped.load(Ordering::Acquire) || paused.load(Ordering::Acquire); let byte_buffer = unsafe { std::slice::from_raw_parts_mut(buffer_ptr, buffer_frames as usize * block_align) }; - fill_buffer(byte_buffer, &mut source, gain, silent, &format); + fill_buffer(byte_buffer, &mut source, &mut gain, target, silent, &format); if let Err(error) = unsafe { render.ReleaseBuffer(buffer_frames, 0) } { warn!(error = %error, "独占模式提交渲染缓冲失败"); diff --git a/native/audio-engine/src/output/wasapi/pcm.rs b/native/audio-engine/src/output/wasapi/pcm.rs index 242e9afed..208645769 100644 --- a/native/audio-engine/src/output/wasapi/pcm.rs +++ b/native/audio-engine/src/output/wasapi/pcm.rs @@ -16,25 +16,39 @@ pub(super) fn convert_sample(sample: f32, gain: f32, valid_bits: u16) -> i32 { } /// 将渲染缓冲按有效位深填充(交错样本,静音时填零) +/// 增益在本次缓冲内从上一缓冲的目标值线性过渡到当前目标值,避免突变产生爆音 +/// `gain` 为 None 表示没有可延续的增益(新流或刚从静音恢复),本缓冲直接采用目标值 pub(super) fn fill_buffer( buffer: &mut [u8], source: &mut DecoderSource, - gain: f32, + gain: &mut Option, + target: f32, silent: bool, format: &ExclusiveFormat, ) { let bytes_per_sample = usize::from(format.container_bits / 8); source.begin_callback(); + let samples = buffer.len() / bytes_per_sample; + let from = gain.unwrap_or(target); + let step = if samples == 0 { + 0.0 + } else { + (target - from) / samples as f32 + }; + let mut current = from; let mut raw = [0u8; 4]; for chunk in buffer.chunks_exact_mut(bytes_per_sample) { let value = if silent { 0 } else { - convert_sample(source.next().unwrap_or(0.0), gain, format.valid_bits) + current += step; + convert_sample(source.next().unwrap_or(0.0), current, format.valid_bits) }; raw[..bytes_per_sample].copy_from_slice(&value.to_le_bytes()[..bytes_per_sample]); chunk.copy_from_slice(&raw[..bytes_per_sample]); } + // 静音缓冲不推进增益状态,恢复时重新对齐当前音量,避免用旧值渐变造成突跳 + *gain = if silent { None } else { Some(target) }; } #[cfg(test)] diff --git a/src/components/player/Toolbar.spec.ts b/src/components/player/Toolbar.spec.ts index 37d6d3626..8a4fb266d 100644 --- a/src/components/player/Toolbar.spec.ts +++ b/src/components/player/Toolbar.spec.ts @@ -72,26 +72,26 @@ describe("感知音量控制", () => { localStorage.setItem("status", JSON.stringify({ volume: 0.125 })); const { wrapper, status } = mountToolbar(); expect(status.volume).toBe(0.125); - expect(wrapper.getComponent(SSlider).props("modelValue")).toBeCloseTo(0.5); - expect(wrapper.text()).toContain("50%"); + expect(wrapper.getComponent(SSlider).props("modelValue")).toBeCloseTo(0.69897, 5); + expect(wrapper.text()).toContain("70%"); expect(player.setVolume).not.toHaveBeenCalled(); }); - it("拖动到 50% 下发三次方增益,重建组件后恢复相同实际音量", async () => { + it("拖动到 50% 下发感知增益,重建组件后恢复相同实际音量", async () => { const { wrapper, status } = mountToolbar(); const hitbox = wrapper.get(".s-slider-hitbox"); Object.assign(hitbox.element, { setPointerCapture: vi.fn() }); vi.spyOn(hitbox.element, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 15, 100)); await hitbox.trigger("pointerdown", { pointerId: 1, clientY: 70 }); - expect(status.volume).toBeCloseTo(0.027); + expect(status.volume).toBeCloseTo(0.0079, 4); await hitbox.trigger("pointermove", { pointerId: 1, clientY: 50 }); await hitbox.trigger("pointerup", { pointerId: 1, clientY: 50 }); - expect(player.setVolume).toHaveBeenLastCalledWith(0.125); - expect(status.volume).toBe(0.125); - expect(JSON.parse(localStorage.getItem("status")!).volume).toBe(0.125); + expect(player.setVolume).toHaveBeenLastCalledWith(expect.closeTo(0.0316, 4)); + expect(status.volume).toBeCloseTo(0.0316, 4); + expect(JSON.parse(localStorage.getItem("status")!).volume).toBeCloseTo(0.0316, 4); wrapper.unmount(); const restored = mountToolbar(); - expect(restored.status.volume).toBe(0.125); + expect(restored.status.volume).toBeCloseTo(0.0316, 4); expect(restored.wrapper.text()).toContain("50%"); }); @@ -100,14 +100,14 @@ describe("感知音量控制", () => { status.volume = 0.125; await nextTick(); await wrapper.get(target).trigger("wheel", { deltaY: -100 }); - expect(status.volume).toBeCloseTo(0.166375); - expect(wrapper.text()).toContain("55%"); + expect(status.volume).toBeCloseTo(0.17657, 4); + expect(wrapper.text()).toContain("75%"); expect(dispatch("player.volumeDown")).toBe(true); await nextTick(); expect(status.volume).toBeCloseTo(0.125); expect(dispatch("player.volumeUp")).toBe(true); await nextTick(); - expect(status.volume).toBeCloseTo(0.166375); + expect(status.volume).toBeCloseTo(0.17657, 4); await wrapper.get(target).trigger("wheel", { deltaY: 100 }); expect(status.volume).toBeCloseTo(0.125); }); @@ -121,14 +121,14 @@ describe("感知音量控制", () => { expect(wrapper.text()).toContain("0%"); await wrapper.get("button").trigger("click"); expect(status.volume).toBe(0.027); - expect(wrapper.text()).toContain("30%"); + expect(wrapper.text()).toContain("48%"); }); - it("低音量可细调,滚轮和快捷键均限制在静音和原始音量之间", async () => { + it("滚轮和快捷键均限制在静音与满音量之间", async () => { const { wrapper, status } = mountToolbar(); status.volume = 0; expect(dispatch("player.volumeUp")).toBe(true); - expect(status.volume).toBeCloseTo(0.000125); + expect(status.volume).toBeCloseTo(0.00141, 5); await nextTick(); await wrapper.get("button").trigger("wheel", { deltaY: 100 }); expect(status.volume).toBe(0); diff --git a/src/utils/volume.spec.ts b/src/utils/volume.spec.ts new file mode 100644 index 000000000..9c392079a --- /dev/null +++ b/src/utils/volume.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { volumeGainToPosition, volumePositionToGain } from "./volume"; + +/** 把线性增益换算成 dB */ +const toDb = (position: number): number => 20 * Math.log10(volumePositionToGain(position)); + +describe("音量曲线", () => { + it("静音位置对应零增益", () => { + expect(volumePositionToGain(0)).toBe(0); + expect(volumeGainToPosition(0)).toBe(0); + }); + + it("最低档接近静音但未关闭", () => { + // 位置 1% 约 -59.4 dB,与静音档只差 0.6 dB + expect(volumePositionToGain(0.01)).toBeCloseTo(0.0011, 4); + expect(toDb(0.01)).toBeCloseTo(-59.4, 1); + }); + + it("低音量位置仍保持可闻增益", () => { + // 位置 5% 为 -57 dB、10% 为 -54 dB,落在夜间小音量的可用范围 + expect(volumePositionToGain(0.05)).toBeCloseTo(0.0014, 4); + expect(volumePositionToGain(0.1)).toBeCloseTo(0.002, 4); + expect(volumePositionToGain(0.1)).toBeGreaterThan(0.001); + }); + + it("滑块位置与 dB 成线性关系", () => { + expect(toDb(1)).toBeCloseTo(0, 6); + expect(toDb(0.5)).toBeCloseTo(-30, 6); + expect(toDb(0.3) - toDb(0.1)).toBeCloseTo(12, 6); + }); + + it("相邻两格滚轮的 dB 步长一致", () => { + for (const position of [0.05, 0.25, 0.55, 0.85]) { + expect(toDb(position + 0.05) - toDb(position)).toBeCloseTo(3, 6); + } + }); + + it("满音量对应单位增益", () => { + expect(volumePositionToGain(1)).toBe(1); + expect(volumeGainToPosition(1)).toBe(1); + }); + + it("位置与增益互为逆映射", () => { + for (const position of [0.01, 0.2, 0.5, 0.77, 1]) { + expect(volumeGainToPosition(volumePositionToGain(position))).toBeCloseTo(position, 5); + } + }); +}); diff --git a/src/utils/volume.ts b/src/utils/volume.ts index 96d69cad2..d6a8ef5bd 100644 --- a/src/utils/volume.ts +++ b/src/utils/volume.ts @@ -2,17 +2,39 @@ export const VOLUME_STEP = 0.05; /** - * 将感知音量位置转换为线性增益 + * 滑块从满音量到最低位置的衰减跨度(dB) + * 位置均匀摊到 0 ~ -60 dB,相邻两格相差 3 dB,半程约 -30 dB, + * 最低档接近静音,配合位置 0 的静音档即可覆盖夜间小音量场景 + */ +const MIN_VOLUME_DB = -60; + +/** + * 将数值限制到 0.0 ~ 1.0 + * @param value - 待限制的数值 + * @returns 限制后的数值 + */ +const clamp01 = (value: number): number => Math.max(0, Math.min(1, value)); + +/** + * 将滑块位置转换为线性增益,位置 0 为静音档 + * 除静音档外位置与 dB 严格线性,滑块变化量等于响度感知变化量 * @param position - 滑块位置(0.0 ~ 1.0) - * @returns 音频线性增益 + * @returns 音频线性增益(0.0 ~ 1.0) */ -export const volumePositionToGain = (position: number): number => - Math.max(0, Math.min(1, position)) ** 3; +export const volumePositionToGain = (position: number): number => { + const value = clamp01(position); + return value === 0 ? 0 : 10 ** ((MIN_VOLUME_DB * (1 - value)) / 20); +}; /** - * 将线性增益转换为感知音量位置,保留已有存档的实际响度 + * 将线性增益转换为滑块位置,保证存档增益还原后仍是同一响度 * @param gain - 音频线性增益(0.0 ~ 1.0) - * @returns 滑块位置 + * @returns 滑块位置(0.0 ~ 1.0) */ -export const volumeGainToPosition = (gain: number): number => - Math.cbrt(Math.max(0, Math.min(1, gain))); +export const volumeGainToPosition = (gain: number): number => { + const value = clamp01(gain); + if (value === 0) return 0; + const position = clamp01(1 - (20 * Math.log10(value)) / MIN_VOLUME_DB); + // 位置保留 6 位小数,避免浮点尾差让最低一档减一档后落在 0 与最低音量之间 + return Math.round(position * 1e6) / 1e6; +};