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..6bc530cdd 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.548455, 5); + expect(wrapper.text()).toContain("55%"); 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.0398, 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.1, 6)); + expect(status.volume).toBeCloseTo(0.1, 6); + expect(JSON.parse(localStorage.getItem("status")!).volume).toBeCloseTo(0.1, 6); wrapper.unmount(); const restored = mountToolbar(); - expect(restored.status.volume).toBe(0.125); + expect(restored.status.volume).toBeCloseTo(0.1, 6); 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.15737, 4); + expect(wrapper.text()).toContain("60%"); 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.15737, 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("22%"); }); - 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.00792, 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..cfa3cba98 --- /dev/null +++ b/src/utils/volume.spec.ts @@ -0,0 +1,34 @@ +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); + expect(volumePositionToGain(1)).toBe(1); + expect(volumeGainToPosition(1)).toBe(1); + }); + + it("主体区间位置与 dB 线性,每格滚轮 2 dB", () => { + expect(toDb(0.5)).toBeCloseTo(-20, 6); + expect(toDb(0.1)).toBeCloseTo(-36, 6); + for (const position of [0.15, 0.35, 0.6, 0.95]) { + expect(toDb(position) - toDb(position - 0.05)).toBeCloseTo(2, 6); + } + }); + + it("底部线性收敛到静音且与主体连续", () => { + expect(volumePositionToGain(0.05)).toBeCloseTo(volumePositionToGain(0.1) / 2, 9); + expect(volumePositionToGain(0.1 - 1e-9)).toBeCloseTo(volumePositionToGain(0.1), 6); + expect(toDb(0.01)).toBeCloseTo(-56, 0); + }); + + it("位置与增益互为逆映射", () => { + for (const position of [0.01, 0.05, 0.1, 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..04c7f53b6 100644 --- a/src/utils/volume.ts +++ b/src/utils/volume.ts @@ -1,18 +1,44 @@ /** 滚轮和快捷键每次调整的滑块位置 */ export const VOLUME_STEP = 0.05; +/** 满音量到滑块最低位置的衰减跨度(dB),每格滚轮 2 dB,半程 -20 dB 与旧三次方曲线接近 */ +const VOLUME_RANGE_DB = 40; + +/** 低于该位置改为增益线性收敛到 0,避免从静音直接跳到可闻电平 */ +const TAPER_POSITION = 0.1; + +/** 线性收敛段起点处的增益 */ +const TAPER_GAIN = 10 ** ((-VOLUME_RANGE_DB * (1 - TAPER_POSITION)) / 20); + +/** + * 将数值限制到 0.0 ~ 1.0 + * @param value - 待限制的数值 + * @returns 限制后的数值 + */ +const clamp01 = (value: number): number => Math.max(0, Math.min(1, value)); + /** - * 将感知音量位置转换为线性增益 + * 将滑块位置转换为线性增益,主体与 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); + if (value < TAPER_POSITION) return (value / TAPER_POSITION) * TAPER_GAIN; + return 10 ** ((-VOLUME_RANGE_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); + const position = + value < TAPER_GAIN + ? (value / TAPER_GAIN) * TAPER_POSITION + : clamp01(1 + (20 * Math.log10(value)) / VOLUME_RANGE_DB); + // 位置保留 6 位小数,避免浮点尾差让最低一档减一档后落在 0 与最低音量之间 + return Math.round(position * 1e6) / 1e6; +};