mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
fix(audio): smooth, monotonic volume curve (#84)
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
bea2f92508
commit
3802c90d2d
2 files changed
+50
-3
No files matched your search
@@ -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 } from "./player.js";
|
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, volumeToFactor } from "./player.js";
|
||||||
|
|
||||||
function getHeadersArg(args: string[]): string {
|
function getHeadersArg(args: string[]): string {
|
||||||
const idx = args.indexOf("-headers");
|
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", () => {
|
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
@@ -103,6 +103,21 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
|||||||
return args;
|
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 {
|
export interface PlayerEvents {
|
||||||
frame: (opusFrame: Buffer) => void;
|
frame: (opusFrame: Buffer) => void;
|
||||||
trackEnd: () => void;
|
trackEnd: () => void;
|
||||||
@@ -509,8 +524,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);
|
||||||
|
|||||||
Reference in new issue
Block a user