From 3802c90d2db00c8ad702eac34fe3267b9393b9c3 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Tue, 16 Jun 2026 16:21:09 +0800 Subject: [PATCH] fix(audio): smooth, monotonic volume curve (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyVolume() mapped 0-100 with a two-piece, discontinuous curve: 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 to full loudness). That produced the reported dead zone + sudden ear-blast at 100. Replace it with a single continuous, strictly-monotonic curve volumeToFactor(v) = 0.2*x + 0.8*x^8 (x = v/100): 0 at 0, exactly 1.0 at 100, no flat region and no discontinuity, so the slider feels proportional and full loudness is still reserved at 100. Extracted as an exported pure function and unit-tested (boundaries, strict monotonicity, dead-zone removal, no jump at 100). Note: per the maintainer's note on #84 the >80% suppression was intentional ear-protection; this change makes the upper range (above ~75%) audibly louder than before in exchange for a proportional slider — applied per maintainer decision. Fixes #84 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/audio/player.test.ts | 33 ++++++++++++++++++++++++++++++++- src/audio/player.ts | 20 ++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/audio/player.test.ts b/src/audio/player.test.ts index 21dd5be..a2f3ffa 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 } from "./player.js"; +import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, volumeToFactor } from "./player.js"; function getHeadersArg(args: string[]): string { const idx = args.indexOf("-headers"); @@ -80,6 +80,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 765ba7a..f452e6b 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -103,6 +103,21 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] { return args; } +/** + * 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; @@ -509,8 +524,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);