feat: add voice ducking

This commit is contained in:
saopig1 committed 2026-07-21 15:47:03 +08:00
1 parent b51b5a2317
commit 97e8a87305
17 files changed
+1550 -10

No files matched your search

+65 -2
View File
@@ -13,21 +13,30 @@ import { createAvatarStore } from "../../data/avatars.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createBotRouter } from "./bot.js";
import { getDefaultConfig, type BotConfig, type JellyfinConfig } from "../../data/config.js";
import {
getDefaultConfig,
type BotConfig,
type JellyfinConfig,
type VoiceDuckingConfig,
} from "../../data/config.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
import type { BotManager } from "../../bot/manager.js";
/** Records every updateIdleTimeout / updateAutoPause call so the test can assert propagation. */
/** Records live settings updates so the tests can assert per-bot propagation. */
function makeFakeBot() {
return {
idleTimeoutCalls: [] as number[],
autoPauseCalls: [] as boolean[],
voiceDuckingCalls: [] as VoiceDuckingConfig[],
updateIdleTimeout(minutes: number) {
this.idleTimeoutCalls.push(minutes);
},
updateAutoPause(enabled: boolean) {
this.autoPauseCalls.push(enabled);
},
updateVoiceDucking(settings: VoiceDuckingConfig) {
this.voiceDuckingCalls.push({ ...settings });
},
};
}
@@ -84,6 +93,60 @@ describe("bot router /settings", () => {
expect(res.body.autoPauseOnEmpty).toBe(true);
});
it("GET /settings includes voiceDucking with safe defaults", async () => {
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.voiceDucking).toEqual({ enabled: false, volumePercent: 30 });
});
it("POST /settings safely partial-merges, persists and hot-applies voiceDucking", async () => {
const enable = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: { enabled: true } });
expect(enable.status).toBe(200);
expect(enable.body.voiceDucking).toEqual({ enabled: true, volumePercent: 30 });
const setVolume = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: { volumePercent: 42.5 } });
expect(setVolume.status).toBe(200);
expect(config.voiceDucking).toEqual({ enabled: true, volumePercent: 42.5 });
for (const bot of fakeBots) {
expect(bot.voiceDuckingCalls).toEqual([
{ enabled: true, volumePercent: 30 },
{ enabled: true, volumePercent: 42.5 },
]);
}
const persisted = JSON.parse(readFileSync(configPath, "utf-8"));
expect(persisted.voiceDucking).toEqual({ enabled: true, volumePercent: 42.5 });
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(followUp.body.voiceDucking).toEqual({ enabled: true, volumePercent: 42.5 });
});
it("POST /settings ignores malformed voiceDucking fields and non-object blocks", async () => {
config.voiceDucking = { enabled: true, volumePercent: 25 };
const invalidFields = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: { enabled: "yes", volumePercent: 101 } });
expect(invalidFields.status).toBe(200);
expect(config.voiceDucking).toEqual({ enabled: true, volumePercent: 25 });
const arrayBlock = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: [{ enabled: false, volumePercent: 0 }] });
expect(arrayBlock.status).toBe(200);
expect(config.voiceDucking).toEqual({ enabled: true, volumePercent: 25 });
for (const bot of fakeBots) {
expect(bot.voiceDuckingCalls).toEqual([{ enabled: true, volumePercent: 25 }]);
}
});
it("POST /settings with autoPauseOnEmpty:false persists and propagates to bots", async () => {
const res = await request(app)
.post("/api/bot/settings")
+32 -1
View File
@@ -71,6 +71,7 @@ export function createBotRouter(
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
voiceDucking: config.voiceDucking,
localAudioEnabled: config.localAudioEnabled,
savedQueuesEnabled: config.savedQueuesEnabled,
playKeepsQueue: config.playKeepsQueue,
@@ -86,7 +87,14 @@ export function createBotRouter(
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
// behavior is a bot.manage operation, consistent with PR #80's permission model)
router.post("/settings", requirePermission("bot.manage"), (req, res) => {
const { idleTimeoutMinutes, autoPauseOnEmpty, localAudioEnabled, guestMode, adminGroups } = req.body;
const {
idleTimeoutMinutes,
autoPauseOnEmpty,
localAudioEnabled,
voiceDucking,
guestMode,
adminGroups,
} = req.body;
const hasIdle = idleTimeoutMinutes !== undefined;
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
@@ -101,6 +109,27 @@ export function createBotRouter(
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
if (hasLocalAudioEnabled) config.localAudioEnabled = localAudioEnabled;
// Voice ducking is a partial settings block. Merge only known, strictly
// valid fields so malformed JSON cannot replace the object or inject NaN /
// out-of-range gain values into the live audio path.
const hasVoiceDucking =
voiceDucking !== null &&
typeof voiceDucking === "object" &&
!Array.isArray(voiceDucking);
if (hasVoiceDucking) {
if (typeof voiceDucking.enabled === "boolean") {
config.voiceDucking.enabled = voiceDucking.enabled;
}
if (
typeof voiceDucking.volumePercent === "number" &&
Number.isFinite(voiceDucking.volumePercent) &&
voiceDucking.volumePercent >= 0 &&
voiceDucking.volumePercent <= 100
) {
config.voiceDucking.volumePercent = voiceDucking.volumePercent;
}
}
// Saved-queues + play-keeps-queue toggles (default off). Both read live from
// config by BotInstance / the saved-queues router, so no per-bot push needed;
// only a literal boolean mutates the stored value (junk is ignored).
@@ -244,11 +273,13 @@ export function createBotRouter(
for (const bot of botManager.getAllBots()) {
if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes);
if (hasAutoPause) bot.updateAutoPause(config.autoPauseOnEmpty);
if (hasVoiceDucking) bot.updateVoiceDucking(config.voiceDucking);
}
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
voiceDucking: config.voiceDucking,
localAudioEnabled: config.localAudioEnabled,
savedQueuesEnabled: config.savedQueuesEnabled,
playKeepsQueue: config.playKeepsQueue,