Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion native/audio-engine/src/output/cpal_stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ where
T: SizedSample + Sample + FromSample<f32>,
{
let mut xruns = 0_u64;
let mut current_gain: Option<f32> = None;
let stream = {
#[cfg(target_os = "linux")]
let _props_guard =
Expand All @@ -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()) {
Expand Down
10 changes: 6 additions & 4 deletions native/audio-engine/src/output/wasapi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f32> = 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("启动预填充提交失败")
Expand Down Expand Up @@ -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<f32> = None;

loop {
let wait = unsafe { WaitForMultipleObjects(&wait_handles, false, INFINITE) };
Expand Down Expand Up @@ -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, "独占模式提交渲染缓冲失败");
Expand Down
18 changes: 16 additions & 2 deletions native/audio-engine/src/output/wasapi/pcm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f32>,
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)]
Expand Down
28 changes: 14 additions & 14 deletions src/components/player/Toolbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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%");
});

Expand All @@ -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);
});
Expand All @@ -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);
Expand Down
48 changes: 48 additions & 0 deletions src/utils/volume.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
38 changes: 30 additions & 8 deletions src/utils/volume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};