Merge PR #93: fix(audio) smooth, monotonic volume curve (#84)

# Conflicts:
#	src/audio/player.test.ts
#	src/audio/player.ts
This commit is contained in:
saopig1 committed 2026-06-16 16:29:27 +08:00
commit 3422d45eeb
2 files changed
+50 -3

No files matched your search

+32 -1
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import { mkdtempSync, writeFileSync, existsSync } from "node:fs"; import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall } from "./player.js"; import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor } from "./player.js";
function getHeadersArg(args: string[]): string { function getHeadersArg(args: string[]): string {
const idx = args.indexOf("-headers"); const idx = args.indexOf("-headers");
@@ -89,6 +89,37 @@ describe("buildFfmpegArgs", () => {
}); });
}); });
describe("volumeToFactor (#84 smooth volume curve)", () => {
it("is 0 at vol 0 and exactly 1.0 at vol 100 (full loudness still reserved at 100)", () => {
expect(volumeToFactor(0)).toBe(0);
expect(volumeToFactor(100)).toBe(1);
});
it("clamps out-of-range input", () => {
expect(volumeToFactor(-20)).toBe(0);
expect(volumeToFactor(150)).toBe(1);
});
it("is strictly monotonic across the whole range (no dead zone)", () => {
for (let v = 0; v < 100; v++) {
expect(volumeToFactor(v + 1)).toBeGreaterThan(volumeToFactor(v));
}
});
it("removes the old flat 80-99 dead zone", () => {
// Old mapping moved only 0.16 -> 0.198 across 80..99; new curve climbs clearly.
expect(volumeToFactor(99) - volumeToFactor(80)).toBeGreaterThan(0.3);
});
it("removes the discontinuity at 100 (old jump was ~0.8)", () => {
expect(volumeToFactor(100) - volumeToFactor(99)).toBeLessThan(0.1);
});
it("keeps the low range gentle", () => {
expect(volumeToFactor(50)).toBeLessThan(0.12);
});
});
describe("shouldUsePowerShellDownload", () => { describe("shouldUsePowerShellDownload", () => {
const jdymusicUrl = const jdymusicUrl =
"http://m801.music.126.net/20260507/abc/jdymusic/obj/xyz/song.mp3?vuutv=tok"; "http://m801.music.126.net/20260507/abc/jdymusic/obj/xyz/song.mp3?vuutv=tok";
+18 -2
View File
@@ -130,6 +130,21 @@ export function shouldEndOnStall(
return false; return false;
} }
/**
* Maps a 0-100 volume value to a linear PCM gain factor (#84).
*
* Continuous and strictly monotonic over [0,100]: 0 at vol 0 and exactly 1.0 at
* vol 100. The previous mapping was a two-piece step — gain = (vol/100)*0.2 for
* vol<100 (so the whole 0-99 range only spanned 0..0.198, making 80->99 feel
* flat) then a raw passthrough at vol===100 (a ~5x jump). This single curve keeps
* the low end gentle but ramps smoothly toward full loudness near the top, so the
* slider feels proportional with no dead zone and no discontinuity at 100.
*/
export function volumeToFactor(volume: number): number {
const x = Math.max(0, Math.min(100, volume)) / 100;
return 0.2 * x + 0.8 * Math.pow(x, 8);
}
export interface PlayerEvents { export interface PlayerEvents {
frame: (opusFrame: Buffer) => void; frame: (opusFrame: Buffer) => void;
trackEnd: () => void; trackEnd: () => void;
@@ -552,8 +567,9 @@ export class AudioPlayer extends EventEmitter {
} }
private applyVolume(pcm: Buffer): Buffer { private applyVolume(pcm: Buffer): Buffer {
if (this.volume === 100) return Buffer.from(pcm); const factor = volumeToFactor(this.volume);
const factor = (this.volume / 100) * 0.2; // factor === 1 only at volume 100; skip the per-sample loop at full loudness.
if (factor >= 1) return Buffer.from(pcm);
const out = Buffer.alloc(pcm.length); const out = Buffer.alloc(pcm.length);
for (let i = 0; i < pcm.length; i += 2) { for (let i = 0; i < pcm.length; i += 2) {
let sample = Math.round(pcm.readInt16LE(i) * factor); let sample = Math.round(pcm.readInt16LE(i) * factor);