diff --git a/src/audio/player.test.ts b/src/audio/player.test.ts index cc75fa3..360e510 100644 --- a/src/audio/player.test.ts +++ b/src/audio/player.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { mkdtempSync, writeFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; 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 { 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", () => { const jdymusicUrl = "http://m801.music.126.net/20260507/abc/jdymusic/obj/xyz/song.mp3?vuutv=tok"; diff --git a/src/audio/player.ts b/src/audio/player.ts index 3a56285..8d492ed 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -130,6 +130,21 @@ export function shouldEndOnStall( 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 { frame: (opusFrame: Buffer) => void; trackEnd: () => void; @@ -552,8 +567,9 @@ export class AudioPlayer extends EventEmitter { } private applyVolume(pcm: Buffer): Buffer { - if (this.volume === 100) return Buffer.from(pcm); - const factor = (this.volume / 100) * 0.2; + const factor = volumeToFactor(this.volume); + // 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); for (let i = 0; i < pcm.length; i += 2) { let sample = Math.round(pcm.readInt16LE(i) * factor);