mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc711758b7 | ||
|
|
97e8a87305 | ||
|
|
b51b5a2317 | ||
|
|
7805f52151 | ||
|
|
3b2d6a7a59 | ||
|
|
8b5a360d8a | ||
|
|
0ad00cd7f7 |
No files matched your search
@@ -876,7 +876,14 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
||||
|
||||
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
|
||||
|
||||
### 最新版本 — 保存/加载播放清单 + 重启恢复队列
|
||||
### 最新版本 — v1.11.1:修复 `!help` 触发机器人自动点歌
|
||||
|
||||
**丢弃自回显消息([PR #135](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/135),感谢 [@EvolvedGhost](https://github.com/EvolvedGhost))**
|
||||
|
||||
- 修复输入 `!help` 后机器人会自己点一首歌开始播放的问题:帮助文本超过 TeamSpeak 单条消息上限被分段发送,而 TeamSpeak 会把 bot 自己发到频道的消息回推给它自己,第二段恰好以 `!artist ...` 开头,被误当作新命令解析执行。
|
||||
- 现在在协议层丢弃发送者为机器人自身的消息,机器人不再响应任何自己发出的文本,所有超长分段输出均安全。无配置变化,升级无需任何操作。
|
||||
|
||||
### v1.11.0 — 播放清单持久化 / 设置保留 / 自定义默认音源
|
||||
|
||||
**保存/加载播放清单 + 队列持久化([#119](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/119))——三项开关均默认关闭,升级无行为变化**
|
||||
|
||||
@@ -885,7 +892,23 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
||||
- **单曲直接播放不清空队列(`playKeepsQueue`,默认关闭,独立开关)**:开启后,直接播放单曲会插入到当前歌曲之后并立即播放、播完继续原队列,而不是清空整个队列。仅影响单曲的「直接播放」;歌单 / 专辑 / 电台仍会替换队列。
|
||||
- 三项均在 设置 → 行为设置 中开关,保存即时生效,无需重启。
|
||||
|
||||
### Jellyfin 可选音源
|
||||
**重启后保留播放设置([#125](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/125))**
|
||||
|
||||
- **音量与播放模式**现按机器人持久化到数据库(`bot_instances` 表新增 `volume` / `play_mode` 列,自动迁移),**各平台音质**持久化到 `config.json` 的 `audioQuality` 字段;重启后自动恢复,不再需要每次手动重调。
|
||||
- 聊天命令、WebUI、REST 三种入口的改动都会落盘;`!fm` / `!artist` 的临时随机 / 循环切换**不会**覆盖你用 `!mode` 显式保存的偏好。播放队列、当前歌曲与播放进度仍不持久化(队列恢复见上方 `savedQueuesEnabled`)。
|
||||
|
||||
**自定义默认音源([#126](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/126))**
|
||||
|
||||
- `config.json` 新增可选字段 `defaultPlatform`,或在 **设置 → 默认音源** 下拉选择:设定后,不带平台标志的 `!play` / `!search` / `!fm` 走你指定的音源(例如设为 `bilibili` 后点播 B 站视频音乐无需每次加 `-b`)。
|
||||
- 留空 / `null` 恢复原有固定优先级(网易云 → QQ → 酷狗 → Jellyfin → B 站 → YouTube);若指定音源未启用或值非法,自动回退到优先级,保存即时生效、无需重启。
|
||||
|
||||
**修复与加固**
|
||||
|
||||
- **QQ 音乐 API 端口对齐([#122](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/122))**:内嵌 QQ 音乐 API sidecar 现在保证绑定到 `qqMusicApiPort`(与客户端请求端口一致),启动日志改为打印**实际绑定端口**便于排查。若你在旧 `latest` 镜像上遇到「日志里 baseURL 是 3200、服务却在 3300」导致二维码不显示,请 `docker compose pull` 重新拉取镜像。
|
||||
- **WebUI 不再被搜索引擎收录([#128](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/128))**:所有响应加 `X-Robots-Tag: noindex, nofollow`、新增 `/robots.txt`(Disallow 全站)、页面加 `robots` meta 标签。⚠️ 这只是阻止**收录**,不是访问控制——公网部署请务必依赖登录鉴权与反向代理,并且不要把自己的 WebUI 链接发到公开网页。
|
||||
- **`.gitignore` 补充 `.claude/`([#127](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/127),感谢 [@ItsEricRao](https://github.com/ItsEricRao))**:本地 Claude Code 配置不再被误提交(已从版本库取消跟踪,本地文件不受影响)。
|
||||
|
||||
### v1.10.1 — Jellyfin 可选音源
|
||||
|
||||
**Jellyfin 集成([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123),由 [@ItsEricRao](https://github.com/ItsEricRao) 贡献;随后调整为可选音源)**
|
||||
|
||||
|
||||
@@ -216,6 +216,78 @@ const silentLogger = {
|
||||
},
|
||||
} as unknown as Logger;
|
||||
|
||||
function applyPlayerVolume(player: AudioPlayer, pcm: Buffer): Buffer {
|
||||
return (
|
||||
player as unknown as { applyVolume(input: Buffer): Buffer }
|
||||
).applyVolume(pcm);
|
||||
}
|
||||
|
||||
function stereoPcm(sample: number, frames = 2): Buffer {
|
||||
const pcm = Buffer.alloc(frames * 4);
|
||||
for (let offset = 0; offset < pcm.length; offset += 2) {
|
||||
pcm.writeInt16LE(sample, offset);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
describe("AudioPlayer transient ducking gain", () => {
|
||||
it("layers ducking on the PCM path without changing the user's base volume", () => {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
player.setVolume(100);
|
||||
player.setDuckingGain(0.3);
|
||||
|
||||
const adjusted = applyPlayerVolume(player, stereoPcm(10_000));
|
||||
|
||||
expect(adjusted.readInt16LE(0)).toBe(3_000);
|
||||
expect(adjusted.readInt16LE(2)).toBe(3_000);
|
||||
expect(player.getVolume()).toBe(100);
|
||||
expect(player.getDuckingGain()).toBe(0.3);
|
||||
});
|
||||
|
||||
it("multiplies the transient gain by the existing base-volume curve", () => {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
player.setVolume(50);
|
||||
player.setDuckingGain(0.5);
|
||||
|
||||
const adjusted = applyPlayerVolume(player, stereoPcm(10_000));
|
||||
expect(adjusted.readInt16LE(0)).toBe(
|
||||
Math.round(10_000 * volumeToFactor(50) * 0.5),
|
||||
);
|
||||
});
|
||||
|
||||
it("interpolates ramps smoothly across each stereo PCM frame", () => {
|
||||
let now = 100;
|
||||
const nowSpy = vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||
try {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
player.setVolume(100);
|
||||
player.setDuckingGain(0.2, 100);
|
||||
|
||||
now = 150;
|
||||
expect(player.getDuckingGain()).toBeCloseTo(0.6, 8);
|
||||
const adjusted = applyPlayerVolume(player, stereoPcm(10_000));
|
||||
|
||||
// At t=150 the ramp is 0.6; at the end of this 20 ms frame it is 0.44.
|
||||
expect(adjusted.readInt16LE(0)).toBe(6_000);
|
||||
expect(adjusted.readInt16LE(2)).toBe(6_000);
|
||||
expect(adjusted.readInt16LE(4)).toBe(4_400);
|
||||
expect(adjusted.readInt16LE(6)).toBe(4_400);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps transient gain and ignores a non-finite update", () => {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
player.setDuckingGain(-1);
|
||||
expect(player.getDuckingGain()).toBe(0);
|
||||
player.setDuckingGain(2);
|
||||
expect(player.getDuckingGain()).toBe(1);
|
||||
player.setDuckingGain(Number.NaN);
|
||||
expect(player.getDuckingGain()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// A readable we fully control: no underlying source; we push PCM manually and
|
||||
// keep it open (never push(null)) to model the long-lived go-librespot sidecar.
|
||||
function openPcmReadable(): Readable {
|
||||
|
||||
+67
-5
@@ -161,6 +161,15 @@ export class AudioPlayer extends EventEmitter {
|
||||
private encoder: Encoder;
|
||||
private state: PlayerState = "idle";
|
||||
private volume = 75;
|
||||
/**
|
||||
* A transient gain envelope layered on top of the persisted user volume.
|
||||
* Voice ducking drives this value; keeping it separate means a temporary
|
||||
* attenuation can never leak into the saved volume setting.
|
||||
*/
|
||||
private duckingRampStartGain = 1;
|
||||
private duckingTargetGain = 1;
|
||||
private duckingRampStartedAt = 0;
|
||||
private duckingRampDurationMs = 0;
|
||||
private pcmBuffer: Buffer = Buffer.alloc(0);
|
||||
private logger: Logger;
|
||||
private frameLoopRunning = false;
|
||||
@@ -732,17 +741,54 @@ export class AudioPlayer extends EventEmitter {
|
||||
}
|
||||
|
||||
private applyVolume(pcm: Buffer): Buffer {
|
||||
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 baseFactor = volumeToFactor(this.volume);
|
||||
const now = performance.now();
|
||||
const startDuckingGain = this.duckingGainAt(now);
|
||||
const endDuckingGain = this.duckingGainAt(now + FRAME_DURATION_MS);
|
||||
const startFactor = baseFactor * startDuckingGain;
|
||||
const endFactor = baseFactor * endDuckingGain;
|
||||
|
||||
if (startFactor >= 1 && endFactor >= 1) {
|
||||
return Buffer.from(pcm);
|
||||
}
|
||||
|
||||
const out = Buffer.alloc(pcm.length);
|
||||
// Most frames are outside the short attack/release windows. Preserve the
|
||||
// old constant-factor hot path instead of doing interpolation per sample.
|
||||
if (startFactor === endFactor) {
|
||||
for (let i = 0; i < pcm.length; i += 2) {
|
||||
const sample = Math.round(pcm.readInt16LE(i) * startFactor);
|
||||
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// PCM is fixed at stereo s16le. Use one gain for each L/R pair so a ramp
|
||||
// never creates a tiny channel imbalance, and span the whole 20 ms frame.
|
||||
const stereoFrames = Math.max(1, Math.ceil(pcm.length / 4));
|
||||
for (let i = 0; i < pcm.length; i += 2) {
|
||||
let sample = Math.round(pcm.readInt16LE(i) * factor);
|
||||
const frameIndex = Math.floor(i / 4);
|
||||
const progress = stereoFrames === 1 ? 0 : frameIndex / (stereoFrames - 1);
|
||||
const factor = startFactor + (endFactor - startFactor) * progress;
|
||||
const sample = Math.round(pcm.readInt16LE(i) * factor);
|
||||
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private duckingGainAt(at: number): number {
|
||||
if (this.duckingRampDurationMs <= 0) return this.duckingTargetGain;
|
||||
|
||||
const progress = Math.max(
|
||||
0,
|
||||
Math.min(1, (at - this.duckingRampStartedAt) / this.duckingRampDurationMs),
|
||||
);
|
||||
return (
|
||||
this.duckingRampStartGain +
|
||||
(this.duckingTargetGain - this.duckingRampStartGain) * progress
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: in external (Spotify sidecar) mode getElapsed() is frame-count based
|
||||
// (framesPlayed includes silence frames emitted on underrun) and therefore
|
||||
// only APPROXIMATE — the authoritative position is the controller's live
|
||||
@@ -762,9 +808,25 @@ export class AudioPlayer extends EventEmitter {
|
||||
resetFailures(): void { this.consecutiveFailures = 0; }
|
||||
setVolume(vol: number): void { this.volume = Math.max(0, Math.min(100, vol)); }
|
||||
getVolume(): number { return this.volume; }
|
||||
/** Set the temporary voice-ducking gain (0=silent, 1=unchanged). */
|
||||
setDuckingGain(gain: number, rampMs = 0): void {
|
||||
if (!Number.isFinite(gain)) return;
|
||||
|
||||
const now = performance.now();
|
||||
const currentGain = this.duckingGainAt(now);
|
||||
const targetGain = Math.max(0, Math.min(1, gain));
|
||||
const duration = Number.isFinite(rampMs) ? Math.max(0, rampMs) : 0;
|
||||
|
||||
this.duckingRampStartGain = currentGain;
|
||||
this.duckingTargetGain = targetGain;
|
||||
this.duckingRampStartedAt = now;
|
||||
this.duckingRampDurationMs =
|
||||
duration > 0 && currentGain !== targetGain ? duration : 0;
|
||||
}
|
||||
getDuckingGain(): number { return this.duckingGainAt(performance.now()); }
|
||||
getState(): PlayerState { return this.state; }
|
||||
// True only while attached to an external (Spotify sidecar) PCM stream. Used
|
||||
// by the orchestrator to decide whether to re-attach: stop() detaches (sets
|
||||
// externalMode=false) so this is false after any player.stop().
|
||||
isExternalActive(): boolean { return this.externalMode; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js";
|
||||
import type { BotInstanceOptions } from "./instance.js";
|
||||
import { PlayQueue, PlayMode } from "../audio/queue.js";
|
||||
@@ -11,6 +12,7 @@ import type { MusicProvider } from "../music/provider.js";
|
||||
import type { BotDatabase } from "../data/database.js";
|
||||
import type { AvatarStore } from "../data/avatars.js";
|
||||
import type { BotConfig } from "../data/config.js";
|
||||
import { ManagedVoiceClientRegistry } from "./managed-voice-clients.js";
|
||||
|
||||
// Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer,
|
||||
// reads avatars, etc.), and runExclusive only touches a single private field
|
||||
@@ -122,6 +124,155 @@ describe("BotInstance.runExclusive — serialization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("BotInstance voice-ducking lifecycle integration", () => {
|
||||
const connect = BotInstance.prototype.connect as unknown as (
|
||||
this: Record<string, any>,
|
||||
) => Promise<void>;
|
||||
|
||||
function makeConnectContext(connectPromise: Promise<void>) {
|
||||
return {
|
||||
disconnectEmitted: false,
|
||||
connected: false,
|
||||
tsClient: {
|
||||
connect: vi.fn(() => connectPromise),
|
||||
getResolvedVoiceEndpoint: vi.fn(() => ({ host: "203.0.113.20", port: 12000 })),
|
||||
},
|
||||
configuredVoiceServerScope: {
|
||||
host: "voice-alias.example.com",
|
||||
voicePort: 9987,
|
||||
},
|
||||
voiceServerScope: { host: "voice-alias.example.com", voicePort: 9987 },
|
||||
voiceDucking: { reset: vi.fn() },
|
||||
registerManagedVoiceClient: vi.fn(),
|
||||
profileManager: { onConnect: vi.fn() },
|
||||
emit: vi.fn(),
|
||||
restoreQueueFromSnapshot: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
it("registers its managed client only after a successful outer connect", async () => {
|
||||
const ctx = makeConnectContext(Promise.resolve());
|
||||
|
||||
await connect.call(ctx);
|
||||
|
||||
expect(ctx.connected).toBe(true);
|
||||
expect(ctx.voiceServerScope).toEqual({ host: "203.0.113.20", voicePort: 12000 });
|
||||
expect(ctx.voiceDucking.reset).toHaveBeenCalledWith(true);
|
||||
expect(ctx.registerManagedVoiceClient).toHaveBeenCalledOnce();
|
||||
expect(ctx.profileManager.onConnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not register a late handshake after disconnect aborted it", async () => {
|
||||
const handshake = deferred();
|
||||
const ctx = makeConnectContext(handshake.promise);
|
||||
|
||||
const result = connect.call(ctx);
|
||||
ctx.disconnectEmitted = true;
|
||||
handshake.resolve();
|
||||
|
||||
await expect(result).rejects.toThrow("Connect aborted by concurrent disconnect");
|
||||
expect(ctx.connected).toBe(false);
|
||||
expect(ctx.registerManagedVoiceClient).not.toHaveBeenCalled();
|
||||
expect(ctx.voiceDucking.reset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the configured endpoint when identity discovery is unavailable", async () => {
|
||||
const ctx = makeConnectContext(Promise.resolve());
|
||||
ctx.tsClient.getResolvedVoiceEndpoint.mockReturnValue(null as any);
|
||||
|
||||
await connect.call(ctx);
|
||||
|
||||
expect(ctx.voiceServerScope).toEqual({
|
||||
host: "voice-alias.example.com",
|
||||
voicePort: 9987,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes human voice activity but filters another managed bot", () => {
|
||||
const tsClient = new EventEmitter() as EventEmitter & {
|
||||
getClientId(): number;
|
||||
};
|
||||
tsClient.getClientId = () => 10;
|
||||
const managedVoiceClients = new ManagedVoiceClientRegistry();
|
||||
const voiceServerScope = { host: "voice.example.com", voicePort: 9987 };
|
||||
managedVoiceClients.register(
|
||||
{ host: "192.168.1.10", voicePort: 20_000 },
|
||||
20,
|
||||
{},
|
||||
"managed-bot-uid=",
|
||||
);
|
||||
managedVoiceClients.register(voiceServerScope, 22, {}, "fallback-bot-uid=");
|
||||
const handleVoiceActivity = vi.fn();
|
||||
const ctx = {
|
||||
tsClient,
|
||||
connected: true,
|
||||
managedVoiceClients,
|
||||
voiceServerScope,
|
||||
voiceDucking: {
|
||||
handleVoiceActivity,
|
||||
removeSpeaker: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
} as Record<string, any>;
|
||||
|
||||
(BotInstance.prototype as any).setupTsEvents.call(ctx);
|
||||
tsClient.emit("voiceActivity", {
|
||||
clientId: 20,
|
||||
codec: 5,
|
||||
clientUid: "managed-bot-uid=",
|
||||
});
|
||||
// If a UID is momentarily unavailable, the scoped client-id registry is
|
||||
// retained as a fallback for the common same-endpoint case.
|
||||
tsClient.emit("voiceActivity", { clientId: 22, codec: 5 });
|
||||
tsClient.emit("voiceActivity", {
|
||||
clientId: 21,
|
||||
codec: 5,
|
||||
clientUid: "human-uid=",
|
||||
});
|
||||
|
||||
expect(handleVoiceActivity).toHaveBeenCalledOnce();
|
||||
expect(handleVoiceActivity).toHaveBeenCalledWith(21);
|
||||
});
|
||||
|
||||
it("keeps a disconnecting bot registered during the in-flight packet grace", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const managedVoiceClients = new ManagedVoiceClientRegistry();
|
||||
const voiceServerScope = { host: "voice.example.com", voicePort: 9987 };
|
||||
const owner = {};
|
||||
managedVoiceClients.register(
|
||||
voiceServerScope,
|
||||
20,
|
||||
owner,
|
||||
"managed-bot-uid=",
|
||||
);
|
||||
const ctx = {
|
||||
managedVoiceClients,
|
||||
voiceServerScope,
|
||||
registeredVoiceClientId: 20,
|
||||
registeredVoiceClientOwner: owner,
|
||||
registeredVoiceClientScope: voiceServerScope,
|
||||
registeredVoiceClientUid: "managed-bot-uid=",
|
||||
};
|
||||
|
||||
(BotInstance.prototype as any).unregisterManagedVoiceClient.call(ctx, 1_000);
|
||||
// A reconnect may resolve to a new endpoint before the grace expires;
|
||||
// cleanup must still target the scope that owned the old client id.
|
||||
ctx.voiceServerScope = { host: "other.example.com", voicePort: 9987 };
|
||||
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true);
|
||||
expect(managedVoiceClients.hasClientUid("managed-bot-uid=")).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(false);
|
||||
expect(managedVoiceClients.hasClientUid("managed-bot-uid=")).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** Minimal `this` carrying only what handleTextMessage's gate path touches.
|
||||
* The gate methods live on the prototype and are attached here so calls like
|
||||
* `this.isCommandAllowed(...)` resolve against this same object. */
|
||||
|
||||
+124
-2
@@ -3,6 +3,7 @@ import {
|
||||
TS3Client,
|
||||
type TS3ClientOptions,
|
||||
type TS3TextMessage,
|
||||
type TS3VoiceActivity,
|
||||
} from "../ts-protocol/client.js";
|
||||
import { AudioPlayer } from "../audio/player.js";
|
||||
import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js";
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
defaultPlatform,
|
||||
type BotConfig,
|
||||
type SpotifyConfig,
|
||||
type VoiceDuckingConfig,
|
||||
} from "../data/config.js";
|
||||
import type { JellyfinPlaybackReporter } from "../music/jellyfin.js";
|
||||
import { BotProfileManager } from "./profile.js";
|
||||
@@ -35,6 +37,12 @@ import path from "node:path";
|
||||
import { SpotifyController } from "../music/spotify/controller.js";
|
||||
import type { SpotifyTrackEndedEvent } from "../music/spotify/backend.js";
|
||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||
import { VoiceDuckingController } from "./voice-ducking.js";
|
||||
import {
|
||||
ManagedVoiceClientRegistry,
|
||||
type ManagedVoiceClientOwnerToken,
|
||||
type ManagedVoiceClientScope,
|
||||
} from "./managed-voice-clients.js";
|
||||
|
||||
/** Reply sent when a non-admin invokes an admin-only chat command. */
|
||||
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
|
||||
@@ -48,6 +56,10 @@ const PLAY_MODE_BY_VALUE: Record<string, PlayMode> = {
|
||||
rloop: PlayMode.RandomLoop,
|
||||
};
|
||||
|
||||
// Keep a disconnected bot id classified as managed briefly so UDP packets
|
||||
// already in flight cannot make another local bot duck during teardown.
|
||||
const MANAGED_VOICE_CLIENT_RELEASE_GRACE_MS = 1_000;
|
||||
|
||||
/** Fallback message when Spotify audio can't be served (backend unavailable
|
||||
* OR a per-track playTrack failure against a dead/failed sidecar). */
|
||||
const SPOTIFY_UNAVAILABLE_MESSAGE =
|
||||
@@ -100,6 +112,8 @@ export interface BotInstanceOptions {
|
||||
config: BotConfig;
|
||||
logger: Logger;
|
||||
avatarStore: AvatarStore;
|
||||
/** Shared across one manager so its bots do not trigger one another. */
|
||||
managedVoiceClients?: ManagedVoiceClientRegistry;
|
||||
/** Base dir (under DATA_DIR) for per-bot go-librespot work/config trees. */
|
||||
spotifyDataDir?: string;
|
||||
/** Process-wide shared Spotify OAuth (single account); injected into the
|
||||
@@ -139,6 +153,14 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
private tsClient: TS3Client;
|
||||
private player: AudioPlayer;
|
||||
private voiceDucking: VoiceDuckingController;
|
||||
private managedVoiceClients: ManagedVoiceClientRegistry;
|
||||
private readonly configuredVoiceServerScope: ManagedVoiceClientScope;
|
||||
private voiceServerScope: ManagedVoiceClientScope;
|
||||
private registeredVoiceClientId = 0;
|
||||
private registeredVoiceClientOwner: ManagedVoiceClientOwnerToken | null = null;
|
||||
private registeredVoiceClientScope: ManagedVoiceClientScope | null = null;
|
||||
private registeredVoiceClientUid: string | null = null;
|
||||
private spotifyController: SpotifyController;
|
||||
private queue: PlayQueue;
|
||||
private neteaseProvider: MusicProvider;
|
||||
@@ -197,6 +219,17 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
this.tsClient = new TS3Client(options.tsOptions, this.logger);
|
||||
this.player = new AudioPlayer(this.logger);
|
||||
this.voiceDucking = new VoiceDuckingController(
|
||||
this.player,
|
||||
this.config.voiceDucking ?? { enabled: false, volumePercent: 30 },
|
||||
);
|
||||
this.managedVoiceClients =
|
||||
options.managedVoiceClients ?? new ManagedVoiceClientRegistry();
|
||||
this.configuredVoiceServerScope = {
|
||||
host: options.tsOptions.host,
|
||||
voicePort: options.tsOptions.port,
|
||||
};
|
||||
this.voiceServerScope = { ...this.configuredVoiceServerScope };
|
||||
this.queue = new PlayQueue();
|
||||
|
||||
// Restore persisted per-bot player settings (#125): volume + play mode
|
||||
@@ -358,6 +391,8 @@ export class BotInstance extends EventEmitter {
|
||||
// this.connected was never flipped to true. Previously this handler
|
||||
// short-circuited on !this.connected, leaving player stuck as "playing".
|
||||
this.connected = false;
|
||||
this.unregisterManagedVoiceClient(MANAGED_VOICE_CLIENT_RELEASE_GRACE_MS);
|
||||
this.voiceDucking.reset(true);
|
||||
// Cancel any pending live-queue snapshot BEFORE clearing the queue: a
|
||||
// debounced snapshot firing after clear() would persist an empty queue
|
||||
// (clearQueueState), wiping the state we want to restore on reconnect —
|
||||
@@ -389,6 +424,17 @@ export class BotInstance extends EventEmitter {
|
||||
this._startJellyfinReportPoller();
|
||||
});
|
||||
|
||||
this.tsClient.on("voiceActivity", (activity: TS3VoiceActivity) => {
|
||||
if (!this.connected) return;
|
||||
if (
|
||||
this.managedVoiceClients.hasClientUid(activity.clientUid) ||
|
||||
this.managedVoiceClients.has(this.voiceServerScope, activity.clientId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.voiceDucking.handleVoiceActivity(activity.clientId);
|
||||
});
|
||||
|
||||
// React near-instantly to channel membership changes. The 30s idle
|
||||
// poller remains the fallback if any of these events are missed.
|
||||
//
|
||||
@@ -400,8 +446,60 @@ export class BotInstance extends EventEmitter {
|
||||
this._resumeIfReturning();
|
||||
void this.refreshOccupancy();
|
||||
});
|
||||
this.tsClient.on("clientLeave", () => void this.refreshOccupancy());
|
||||
this.tsClient.on("clientMoved", () => void this.refreshOccupancy());
|
||||
this.tsClient.on("clientLeave", (event: { id: number }) => {
|
||||
this.voiceDucking.removeSpeaker(event.id);
|
||||
void this.refreshOccupancy();
|
||||
});
|
||||
this.tsClient.on("clientMoved", (event: { id: number }) => {
|
||||
if (event.id === this.tsClient.getClientId()) {
|
||||
// Moving the bot invalidates every activity deadline from its old
|
||||
// channel even if no individual leave events arrive.
|
||||
this.voiceDucking.reset(false);
|
||||
} else {
|
||||
this.voiceDucking.removeSpeaker(event.id);
|
||||
}
|
||||
void this.refreshOccupancy();
|
||||
});
|
||||
}
|
||||
|
||||
private registerManagedVoiceClient(): void {
|
||||
this.unregisterManagedVoiceClient();
|
||||
const clientId = this.tsClient.getClientId();
|
||||
if (!Number.isSafeInteger(clientId) || clientId <= 0) return;
|
||||
|
||||
const owner = {};
|
||||
const scope = { ...this.voiceServerScope };
|
||||
const clientUid = this.tsClient.getClientUid();
|
||||
if (this.managedVoiceClients.register(scope, clientId, owner, clientUid)) {
|
||||
this.registeredVoiceClientId = clientId;
|
||||
this.registeredVoiceClientOwner = owner;
|
||||
this.registeredVoiceClientScope = scope;
|
||||
this.registeredVoiceClientUid = clientUid;
|
||||
}
|
||||
}
|
||||
|
||||
private unregisterManagedVoiceClient(graceMs = 0): void {
|
||||
const clientId = this.registeredVoiceClientId;
|
||||
const owner = this.registeredVoiceClientOwner;
|
||||
const clientUid = this.registeredVoiceClientUid ?? undefined;
|
||||
const scope = this.registeredVoiceClientScope
|
||||
? { ...this.registeredVoiceClientScope }
|
||||
: { ...this.voiceServerScope };
|
||||
this.registeredVoiceClientId = 0;
|
||||
this.registeredVoiceClientOwner = null;
|
||||
this.registeredVoiceClientScope = null;
|
||||
this.registeredVoiceClientUid = null;
|
||||
if (clientId <= 0 || owner === null) return;
|
||||
|
||||
const unregister = () => {
|
||||
this.managedVoiceClients.unregister(scope, clientId, owner, clientUid);
|
||||
};
|
||||
if (graceMs > 0) {
|
||||
const timer = setTimeout(unregister, graceMs);
|
||||
timer.unref?.();
|
||||
} else {
|
||||
unregister();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,6 +538,13 @@ export class BotInstance extends EventEmitter {
|
||||
async connect(): Promise<void> {
|
||||
this.disconnectEmitted = false;
|
||||
await this.tsClient.connect();
|
||||
const resolvedEndpoint = this.tsClient.getResolvedVoiceEndpoint();
|
||||
this.voiceServerScope = {
|
||||
host:
|
||||
resolvedEndpoint?.host ?? this.configuredVoiceServerScope.host,
|
||||
voicePort:
|
||||
resolvedEndpoint?.port ?? this.configuredVoiceServerScope.voicePort,
|
||||
};
|
||||
// Race guard: if disconnect() was called while the handshake was
|
||||
// awaiting, don't flip connected back to true — that would leave the
|
||||
// bot in an inconsistent state (externally "connected" but the tsClient
|
||||
@@ -448,6 +553,12 @@ export class BotInstance extends EventEmitter {
|
||||
throw new Error("Connect aborted by concurrent disconnect");
|
||||
}
|
||||
this.connected = true;
|
||||
// Register only after the outer lifecycle race guard succeeds. The TS
|
||||
// wrapper emits its own "connected" event before connect() resolves, so
|
||||
// registering in that callback could let a cancelled, late handshake
|
||||
// overwrite a newer instance that reused the same client id.
|
||||
this.voiceDucking.reset(true);
|
||||
this.registerManagedVoiceClient();
|
||||
this.profileManager.onConnect();
|
||||
this.emit("connected");
|
||||
// Feature 2 (#119): restore + resume the live queue persisted before the
|
||||
@@ -458,6 +569,7 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
disconnect(): void {
|
||||
this._cancelIdleTimer();
|
||||
this.voiceDucking.reset(true);
|
||||
// Cancel any pending live-queue snapshot before clearing so it can't fire
|
||||
// afterwards and persist an empty queue over the state we keep for restore
|
||||
// (#119). The disconnected handler cancels too, but do it here as well for
|
||||
@@ -478,6 +590,10 @@ export class BotInstance extends EventEmitter {
|
||||
this.emit("disconnected");
|
||||
}
|
||||
this.tsClient.disconnect();
|
||||
// Stop outbound PCM and initiate the TeamSpeak disconnect before removing
|
||||
// our id from the shared registry, minimizing the window in which another
|
||||
// managed bot could mistake our final packet for a human speaker.
|
||||
this.unregisterManagedVoiceClient(MANAGED_VOICE_CLIENT_RELEASE_GRACE_MS);
|
||||
}
|
||||
|
||||
/** 外部更新 idleTimeoutMinutes(由 API 保存时调用) */
|
||||
@@ -500,6 +616,12 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/** Hot-apply voice ducking without mutating the user's base player volume. */
|
||||
updateVoiceDucking(settings: VoiceDuckingConfig): void {
|
||||
this.config.voiceDucking = { ...settings };
|
||||
this.voiceDucking.updateSettings(settings);
|
||||
}
|
||||
|
||||
private _startIdlePoller(): void {
|
||||
// 每 30 秒检查一次频道人数
|
||||
const poll = async () => {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ManagedVoiceClientRegistry,
|
||||
normalizeManagedVoiceClientScope,
|
||||
normalizeManagedVoiceHost,
|
||||
} from "./managed-voice-clients.js";
|
||||
|
||||
describe("managed voice client scope normalization", () => {
|
||||
it("normalizes DNS host casing, whitespace, and trailing root dots", () => {
|
||||
expect(normalizeManagedVoiceHost(" Voice.Example.COM... ")).toBe(
|
||||
"voice.example.com",
|
||||
);
|
||||
expect(
|
||||
normalizeManagedVoiceClientScope({
|
||||
host: "VOICE.EXAMPLE.COM.",
|
||||
voicePort: 9987,
|
||||
}),
|
||||
).toEqual({ host: "voice.example.com", voicePort: 9987 });
|
||||
});
|
||||
|
||||
it("treats bracketed and equivalent expanded IPv6 literals as one host", () => {
|
||||
expect(normalizeManagedVoiceHost("[2001:0DB8:0:0:0:0:0:1]")).toBe(
|
||||
"2001:db8::1",
|
||||
);
|
||||
expect(normalizeManagedVoiceHost("2001:db8::1")).toBe("2001:db8::1");
|
||||
});
|
||||
|
||||
it("rejects empty hosts and invalid voice ports", () => {
|
||||
expect(
|
||||
normalizeManagedVoiceClientScope({ host: " . ", voicePort: 9987 }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeManagedVoiceClientScope({ host: "example.com", voicePort: 0 }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeManagedVoiceClientScope({
|
||||
host: "example.com",
|
||||
voicePort: 65_536,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("ManagedVoiceClientRegistry", () => {
|
||||
it("finds clients through normalized forms of the same scope", () => {
|
||||
const registry = new ManagedVoiceClientRegistry();
|
||||
const owner = Symbol("connection");
|
||||
|
||||
expect(
|
||||
registry.register(
|
||||
{ host: " Voice.Example.COM. ", voicePort: 9987 },
|
||||
42,
|
||||
owner,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
registry.has({ host: "voice.example.com", voicePort: 9987 }, 42),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps different voice ports and hosts in separate scopes", () => {
|
||||
const registry = new ManagedVoiceClientRegistry();
|
||||
registry.register(
|
||||
{ host: "voice.example.com", voicePort: 9987 },
|
||||
7,
|
||||
Symbol("connection"),
|
||||
);
|
||||
|
||||
expect(
|
||||
registry.has({ host: "voice.example.com", voicePort: 9988 }, 7),
|
||||
).toBe(false);
|
||||
expect(
|
||||
registry.has({ host: "other.example.com", voicePort: 9987 }, 7),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("finds a managed bot by stable client UID across network endpoints", () => {
|
||||
const registry = new ManagedVoiceClientRegistry();
|
||||
const owner = Symbol("connection");
|
||||
|
||||
registry.register(
|
||||
{ host: "127.0.0.1", voicePort: 9987 },
|
||||
17,
|
||||
owner,
|
||||
" managed-client-uid= ",
|
||||
);
|
||||
|
||||
expect(registry.hasClientUid("managed-client-uid=")).toBe(true);
|
||||
expect(
|
||||
registry.has({ host: "192.168.1.10", voicePort: 20_000 }, 17),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a shared managed UID until its last owner unregisters", () => {
|
||||
const registry = new ManagedVoiceClientRegistry();
|
||||
const scope = { host: "203.0.113.4", voicePort: 9987 };
|
||||
const first = Symbol("first connection");
|
||||
const second = Symbol("second connection");
|
||||
registry.register(scope, 18, first, "shared-client-uid=");
|
||||
registry.register(scope, 19, second, "shared-client-uid=");
|
||||
|
||||
expect(registry.unregister(scope, 18, first, "shared-client-uid=")).toBe(true);
|
||||
expect(registry.hasClientUid("shared-client-uid=")).toBe(true);
|
||||
expect(registry.unregister(scope, 19, second, "shared-client-uid=")).toBe(true);
|
||||
expect(registry.hasClientUid("shared-client-uid=")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores missing or empty client UIDs", () => {
|
||||
const registry = new ManagedVoiceClientRegistry();
|
||||
registry.register(
|
||||
{ host: "203.0.113.4", voicePort: 9987 },
|
||||
19,
|
||||
Symbol("connection"),
|
||||
" ",
|
||||
);
|
||||
|
||||
expect(registry.hasClientUid(undefined)).toBe(false);
|
||||
expect(registry.hasClientUid(" ")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses an IPv6-safe scope key", () => {
|
||||
const registry = new ManagedVoiceClientRegistry();
|
||||
registry.register(
|
||||
{ host: "[2001:0db8:0:0:0:0:0:1]", voicePort: 9987 },
|
||||
9,
|
||||
Symbol("connection"),
|
||||
);
|
||||
|
||||
expect(
|
||||
registry.has({ host: "2001:db8::1", voicePort: 9987 }, 9),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let a delayed old disconnect remove a replacement", () => {
|
||||
const registry = new ManagedVoiceClientRegistry();
|
||||
const scope = { host: "voice.example.com", voicePort: 9987 };
|
||||
const oldConnection = Symbol("old connection");
|
||||
const newConnection = Symbol("new connection");
|
||||
|
||||
registry.register(scope, 12, oldConnection, "managed-client-uid=");
|
||||
registry.register(scope, 12, newConnection, "managed-client-uid=");
|
||||
|
||||
// The old UID owner is removed, but the replacement still owns both the
|
||||
// scoped id and the shared stable UID.
|
||||
expect(
|
||||
registry.unregister(scope, 12, oldConnection, "managed-client-uid="),
|
||||
).toBe(true);
|
||||
expect(registry.has(scope, 12)).toBe(true);
|
||||
expect(registry.hasClientUid("managed-client-uid=")).toBe(true);
|
||||
expect(
|
||||
registry.unregister(scope, 12, newConnection, "managed-client-uid="),
|
||||
).toBe(true);
|
||||
expect(registry.has(scope, 12)).toBe(false);
|
||||
expect(registry.hasClientUid("managed-client-uid=")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
|
||||
"ignores invalid client id %s",
|
||||
(clientId) => {
|
||||
const registry = new ManagedVoiceClientRegistry();
|
||||
const scope = { host: "voice.example.com", voicePort: 9987 };
|
||||
const owner = Symbol("connection");
|
||||
|
||||
expect(registry.register(scope, clientId, owner)).toBe(false);
|
||||
expect(registry.has(scope, clientId)).toBe(false);
|
||||
expect(registry.unregister(scope, clientId, owner)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("has no shared module-level state between registry instances", () => {
|
||||
const first = new ManagedVoiceClientRegistry();
|
||||
const second = new ManagedVoiceClientRegistry();
|
||||
const scope = { host: "voice.example.com", voicePort: 9987 };
|
||||
|
||||
first.register(scope, 3, Symbol("connection"));
|
||||
|
||||
expect(first.has(scope, 3)).toBe(true);
|
||||
expect(second.has(scope, 3)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
/** Identifies one TeamSpeak voice server. */
|
||||
export interface ManagedVoiceClientScope {
|
||||
host: string;
|
||||
voicePort: number;
|
||||
}
|
||||
|
||||
export interface NormalizedManagedVoiceClientScope {
|
||||
readonly host: string;
|
||||
readonly voicePort: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An opaque value identifying the connection that owns a client id.
|
||||
*
|
||||
* A fresh object or Symbol per connection is recommended. Value tokens are
|
||||
* also supported for callers that already have a unique connection id.
|
||||
*/
|
||||
export type ManagedVoiceClientOwnerToken = object | string | number | symbol;
|
||||
|
||||
/**
|
||||
* Normalize a TeamSpeak host for comparisons.
|
||||
*
|
||||
* DNS names are case-insensitive and may include a trailing root dot. IPv6
|
||||
* literals may be supplied either bare or in URL-style brackets; valid IPv6
|
||||
* addresses are also put into the canonical form produced by the URL parser.
|
||||
*/
|
||||
export function normalizeManagedVoiceHost(host: string): string {
|
||||
let normalized = host.trim().toLowerCase().replace(/\.+$/, "");
|
||||
|
||||
if (normalized.startsWith("[") && normalized.endsWith("]")) {
|
||||
normalized = normalized.slice(1, -1);
|
||||
}
|
||||
|
||||
if (isIP(normalized) === 6) {
|
||||
// URL's host serializer compresses equivalent IPv6 spellings. `isIP`
|
||||
// ensures interpolation cannot be interpreted as another URL component.
|
||||
const serialized = new URL(`http://[${normalized}]/`).hostname;
|
||||
return serialized.slice(1, -1);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Return a comparable scope, or null when the runtime input is unusable. */
|
||||
export function normalizeManagedVoiceClientScope(
|
||||
scope: ManagedVoiceClientScope,
|
||||
): NormalizedManagedVoiceClientScope | null {
|
||||
if (
|
||||
!scope ||
|
||||
typeof scope.host !== "string" ||
|
||||
typeof scope.voicePort !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = normalizeManagedVoiceHost(scope.host);
|
||||
if (
|
||||
host.length === 0 ||
|
||||
!Number.isInteger(scope.voicePort) ||
|
||||
scope.voicePort < 1 ||
|
||||
scope.voicePort > 65_535
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { host, voicePort: scope.voicePort };
|
||||
}
|
||||
|
||||
function scopeKey(scope: ManagedVoiceClientScope): string | null {
|
||||
const normalized = normalizeManagedVoiceClientScope(scope);
|
||||
if (!normalized) return null;
|
||||
|
||||
// A serialized tuple stays unambiguous when host itself contains colons.
|
||||
return JSON.stringify([normalized.host, normalized.voicePort]);
|
||||
}
|
||||
|
||||
function validClientId(clientId: number): boolean {
|
||||
return Number.isSafeInteger(clientId) && clientId > 0;
|
||||
}
|
||||
|
||||
function normalizeClientUid(clientUid: string | undefined): string | null {
|
||||
if (typeof clientUid !== "string") return null;
|
||||
const normalized = clientUid.trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks voice client ids and stable TeamSpeak identities owned by bot
|
||||
* connections in this process. The UID path survives DNS aliases, NAT,
|
||||
* multiple NICs, and dual-stack endpoints; scoped ids remain a fallback when
|
||||
* a sender has not yet appeared in the receiving client's view cache.
|
||||
*
|
||||
* This class intentionally has no module-level singleton. BotManager owns one
|
||||
* instance and injects it into its BotInstances so separate managers remain
|
||||
* isolated in tests and in the same process.
|
||||
*/
|
||||
export class ManagedVoiceClientRegistry {
|
||||
private readonly clientsByScope = new Map<
|
||||
string,
|
||||
Map<number, ManagedVoiceClientOwnerToken>
|
||||
>();
|
||||
private readonly ownersByClientUid = new Map<
|
||||
string,
|
||||
Set<ManagedVoiceClientOwnerToken>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Register (or replace) the connection that owns a client id and, when
|
||||
* available, add its stable UID to the managed set.
|
||||
* Returns false when the scope or client id is invalid.
|
||||
*/
|
||||
register(
|
||||
scope: ManagedVoiceClientScope,
|
||||
clientId: number,
|
||||
ownerToken: ManagedVoiceClientOwnerToken,
|
||||
clientUid?: string,
|
||||
): boolean {
|
||||
const key = scopeKey(scope);
|
||||
if (!key || !validClientId(clientId)) return false;
|
||||
|
||||
let clients = this.clientsByScope.get(key);
|
||||
if (!clients) {
|
||||
clients = new Map();
|
||||
this.clientsByScope.set(key, clients);
|
||||
}
|
||||
clients.set(clientId, ownerToken);
|
||||
|
||||
const normalizedUid = normalizeClientUid(clientUid);
|
||||
if (normalizedUid) {
|
||||
let owners = this.ownersByClientUid.get(normalizedUid);
|
||||
if (!owners) {
|
||||
owners = new Set();
|
||||
this.ownersByClientUid.set(normalizedUid, owners);
|
||||
}
|
||||
owners.add(ownerToken);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a client only if it is still owned by this connection.
|
||||
*
|
||||
* The ownership check prevents a delayed disconnect from an old connection
|
||||
* deleting a newer connection that reused the same TeamSpeak client id.
|
||||
*/
|
||||
unregister(
|
||||
scope: ManagedVoiceClientScope,
|
||||
clientId: number,
|
||||
ownerToken: ManagedVoiceClientOwnerToken,
|
||||
clientUid?: string,
|
||||
): boolean {
|
||||
const key = scopeKey(scope);
|
||||
if (!key || !validClientId(clientId)) return false;
|
||||
|
||||
let removed = false;
|
||||
const clients = this.clientsByScope.get(key);
|
||||
if (clients?.get(clientId) === ownerToken) {
|
||||
clients.delete(clientId);
|
||||
if (clients.size === 0) this.clientsByScope.delete(key);
|
||||
removed = true;
|
||||
}
|
||||
|
||||
const normalizedUid = normalizeClientUid(clientUid);
|
||||
if (normalizedUid) {
|
||||
const owners = this.ownersByClientUid.get(normalizedUid);
|
||||
if (owners?.delete(ownerToken)) removed = true;
|
||||
if (owners?.size === 0) this.ownersByClientUid.delete(normalizedUid);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
has(scope: ManagedVoiceClientScope, clientId: number): boolean {
|
||||
const key = scopeKey(scope);
|
||||
if (!key || !validClientId(clientId)) return false;
|
||||
return this.clientsByScope.get(key)?.has(clientId) ?? false;
|
||||
}
|
||||
|
||||
/** TeamSpeak client UIDs are stable across endpoint aliases and NAT paths. */
|
||||
hasClientUid(clientUid: string | undefined): boolean {
|
||||
const normalizedUid = normalizeClientUid(clientUid);
|
||||
return normalizedUid
|
||||
? (this.ownersByClientUid.get(normalizedUid)?.size ?? 0) > 0
|
||||
: false;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { ServerProtocol } from "../ts-protocol/client.js";
|
||||
import type { AvatarStore } from "../data/avatars.js";
|
||||
import type { PermissionStore } from "../data/permissions.js";
|
||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||
import { ManagedVoiceClientRegistry } from "./managed-voice-clients.js";
|
||||
|
||||
/**
|
||||
* Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
|
||||
@@ -72,6 +73,7 @@ export interface CreateBotParams {
|
||||
|
||||
export class BotManager extends EventEmitter {
|
||||
private bots = new Map<string, BotInstance>();
|
||||
private readonly managedVoiceClients = new ManagedVoiceClientRegistry();
|
||||
private neteaseProvider: MusicProvider;
|
||||
private qqProvider: MusicProvider;
|
||||
private bilibiliProvider: MusicProvider;
|
||||
@@ -161,6 +163,7 @@ export class BotManager extends EventEmitter {
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
avatarStore: this.avatarStore,
|
||||
managedVoiceClients: this.managedVoiceClients,
|
||||
spotifyDataDir: this.spotifyDataDir,
|
||||
spotifyOAuth: this.spotifyOAuth,
|
||||
});
|
||||
@@ -305,6 +308,7 @@ export class BotManager extends EventEmitter {
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
avatarStore: this.avatarStore,
|
||||
managedVoiceClients: this.managedVoiceClients,
|
||||
spotifyDataDir: this.spotifyDataDir,
|
||||
spotifyOAuth: this.spotifyOAuth,
|
||||
});
|
||||
@@ -363,6 +367,7 @@ export class BotManager extends EventEmitter {
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
avatarStore: this.avatarStore,
|
||||
managedVoiceClients: this.managedVoiceClients,
|
||||
spotifyDataDir: this.spotifyDataDir,
|
||||
spotifyOAuth: this.spotifyOAuth,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { VoiceDuckingController } from "./voice-ducking.js";
|
||||
|
||||
function makeHarness(
|
||||
enabled = true,
|
||||
volumePercent = 30,
|
||||
timing = { attackMs: 50, holdMs: 100, releaseMs: 200 },
|
||||
) {
|
||||
let now = 0;
|
||||
const setDuckingGain = vi.fn<(gain: number, rampMs?: number) => void>();
|
||||
const controller = new VoiceDuckingController(
|
||||
{ setDuckingGain },
|
||||
{ enabled, volumePercent },
|
||||
{ timing, now: () => now },
|
||||
);
|
||||
|
||||
const advance = (milliseconds: number) => {
|
||||
now += milliseconds;
|
||||
vi.advanceTimersByTime(milliseconds);
|
||||
};
|
||||
|
||||
return { controller, setDuckingGain, advance };
|
||||
}
|
||||
|
||||
describe("VoiceDuckingController", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("is inert while disabled", () => {
|
||||
vi.useFakeTimers();
|
||||
const { controller, setDuckingGain, advance } = makeHarness(false);
|
||||
|
||||
controller.handleVoiceActivity(12);
|
||||
advance(1_000);
|
||||
|
||||
expect(setDuckingGain).not.toHaveBeenCalled();
|
||||
expect(controller.isDucking()).toBe(false);
|
||||
expect(controller.activeSpeakerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("attacks once, refreshes the packet deadline, then releases", () => {
|
||||
vi.useFakeTimers();
|
||||
const { controller, setDuckingGain, advance } = makeHarness();
|
||||
|
||||
controller.handleVoiceActivity(12);
|
||||
expect(setDuckingGain).toHaveBeenCalledWith(0.3, 50);
|
||||
|
||||
advance(60);
|
||||
controller.handleVoiceActivity(12);
|
||||
expect(setDuckingGain).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The original t=100 sweep observes the refreshed t=160 deadline.
|
||||
advance(40);
|
||||
expect(controller.isDucking()).toBe(true);
|
||||
expect(setDuckingGain).toHaveBeenCalledTimes(1);
|
||||
|
||||
advance(60);
|
||||
expect(controller.isDucking()).toBe(false);
|
||||
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
|
||||
});
|
||||
|
||||
it("stays ducked until the last overlapping speaker expires", () => {
|
||||
vi.useFakeTimers();
|
||||
const { controller, setDuckingGain, advance } = makeHarness();
|
||||
|
||||
controller.handleVoiceActivity(1);
|
||||
advance(50);
|
||||
controller.handleVoiceActivity(2);
|
||||
advance(50);
|
||||
|
||||
expect(controller.activeSpeakerCount()).toBe(1);
|
||||
expect(controller.isDucking()).toBe(true);
|
||||
expect(setDuckingGain).toHaveBeenCalledTimes(1);
|
||||
|
||||
advance(50);
|
||||
expect(controller.activeSpeakerCount()).toBe(0);
|
||||
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
|
||||
});
|
||||
|
||||
it("removes a client immediately on leave without disturbing other speakers", () => {
|
||||
vi.useFakeTimers();
|
||||
const { controller, setDuckingGain } = makeHarness();
|
||||
|
||||
controller.handleVoiceActivity(1);
|
||||
controller.handleVoiceActivity(2);
|
||||
controller.removeSpeaker(1);
|
||||
expect(controller.isDucking()).toBe(true);
|
||||
expect(controller.activeSpeakerCount()).toBe(1);
|
||||
|
||||
controller.removeSpeaker(2);
|
||||
expect(controller.isDucking()).toBe(false);
|
||||
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
|
||||
});
|
||||
|
||||
it("retargets a live duck and smoothly restores when disabled", () => {
|
||||
vi.useFakeTimers();
|
||||
const { controller, setDuckingGain } = makeHarness();
|
||||
|
||||
controller.handleVoiceActivity(7);
|
||||
controller.updateSettings({ enabled: true, volumePercent: 45 });
|
||||
expect(setDuckingGain).toHaveBeenLastCalledWith(0.45, 50);
|
||||
|
||||
controller.updateSettings({ enabled: false, volumePercent: 45 });
|
||||
expect(controller.isDucking()).toBe(false);
|
||||
expect(controller.activeSpeakerCount()).toBe(0);
|
||||
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
|
||||
});
|
||||
|
||||
it("attacks again when speech resumes during the release window", () => {
|
||||
vi.useFakeTimers();
|
||||
const { controller, setDuckingGain, advance } = makeHarness();
|
||||
|
||||
controller.handleVoiceActivity(7);
|
||||
advance(100);
|
||||
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
|
||||
|
||||
advance(50);
|
||||
controller.handleVoiceActivity(7);
|
||||
expect(controller.isDucking()).toBe(true);
|
||||
expect(setDuckingGain).toHaveBeenLastCalledWith(0.3, 50);
|
||||
});
|
||||
|
||||
it("invalidates an old expiry callback after reset", () => {
|
||||
vi.useFakeTimers();
|
||||
const { controller, setDuckingGain, advance } = makeHarness();
|
||||
|
||||
controller.handleVoiceActivity(8);
|
||||
controller.reset(true);
|
||||
const callsAfterReset = setDuckingGain.mock.calls.length;
|
||||
advance(1_000);
|
||||
|
||||
expect(setDuckingGain).toHaveBeenCalledTimes(callsAfterReset);
|
||||
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 0);
|
||||
});
|
||||
|
||||
it("rejects invalid client ids and supports an immediate lifecycle reset", () => {
|
||||
vi.useFakeTimers();
|
||||
const { controller, setDuckingGain } = makeHarness();
|
||||
|
||||
for (const id of [0, -1, 1.5, Number.NaN]) {
|
||||
controller.handleVoiceActivity(id);
|
||||
}
|
||||
expect(setDuckingGain).not.toHaveBeenCalled();
|
||||
|
||||
controller.handleVoiceActivity(8);
|
||||
controller.reset(true);
|
||||
expect(controller.isDucking()).toBe(false);
|
||||
expect(controller.activeSpeakerCount()).toBe(0);
|
||||
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
export interface VoiceDuckingSettings {
|
||||
enabled: boolean;
|
||||
volumePercent: number;
|
||||
}
|
||||
|
||||
export interface VoiceDuckingGainTarget {
|
||||
setDuckingGain(gain: number, rampMs?: number): void;
|
||||
}
|
||||
|
||||
export interface VoiceDuckingTiming {
|
||||
attackMs: number;
|
||||
holdMs: number;
|
||||
releaseMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_VOICE_DUCKING_TIMING: Readonly<VoiceDuckingTiming> = {
|
||||
attackMs: 50,
|
||||
holdMs: 700,
|
||||
releaseMs: 500,
|
||||
};
|
||||
|
||||
interface VoiceDuckingControllerOptions {
|
||||
timing?: Partial<VoiceDuckingTiming>;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function nonNegativeFinite(value: number | undefined, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? Math.max(0, value)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function normalizeSettings(settings: VoiceDuckingSettings): VoiceDuckingSettings {
|
||||
return {
|
||||
enabled: settings.enabled === true,
|
||||
volumePercent:
|
||||
typeof settings.volumePercent === "number" && Number.isFinite(settings.volumePercent)
|
||||
? Math.max(0, Math.min(100, settings.volumePercent))
|
||||
: 30,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the stream of incoming TeamSpeak voice packets into a stable
|
||||
* ducking envelope. TeamSpeak's full-client protocol exposes voice packets,
|
||||
* but not an explicit "stopped talking" event, so a speaker remains active
|
||||
* for a short hold period after their most recent packet.
|
||||
*
|
||||
* Only one timeout is live at a time. Repeated ~20 ms voice packets update a
|
||||
* deadline in the map instead of constantly destroying/recreating timers.
|
||||
*/
|
||||
export class VoiceDuckingController {
|
||||
private settings: VoiceDuckingSettings;
|
||||
private readonly timing: VoiceDuckingTiming;
|
||||
private readonly now: () => number;
|
||||
private readonly activeUntil = new Map<number, number>();
|
||||
private expiryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private timerDueAt = Number.POSITIVE_INFINITY;
|
||||
private timerGeneration = 0;
|
||||
private ducking = false;
|
||||
|
||||
constructor(
|
||||
private readonly target: VoiceDuckingGainTarget,
|
||||
initialSettings: VoiceDuckingSettings,
|
||||
options: VoiceDuckingControllerOptions = {},
|
||||
) {
|
||||
this.settings = normalizeSettings(initialSettings);
|
||||
this.timing = {
|
||||
attackMs: nonNegativeFinite(options.timing?.attackMs, DEFAULT_VOICE_DUCKING_TIMING.attackMs),
|
||||
holdMs: nonNegativeFinite(options.timing?.holdMs, DEFAULT_VOICE_DUCKING_TIMING.holdMs),
|
||||
releaseMs: nonNegativeFinite(options.timing?.releaseMs, DEFAULT_VOICE_DUCKING_TIMING.releaseMs),
|
||||
};
|
||||
this.now = options.now ?? (() => performance.now());
|
||||
}
|
||||
|
||||
handleVoiceActivity(clientId: number): void {
|
||||
if (!this.settings.enabled || !Number.isInteger(clientId) || clientId <= 0) return;
|
||||
|
||||
const now = this.now();
|
||||
this.activeUntil.set(clientId, now + this.timing.holdMs);
|
||||
|
||||
if (!this.ducking) {
|
||||
this.ducking = true;
|
||||
this.target.setDuckingGain(this.settings.volumePercent / 100, this.timing.attackMs);
|
||||
}
|
||||
|
||||
this.scheduleNextSweep(now);
|
||||
}
|
||||
|
||||
removeSpeaker(clientId: number): void {
|
||||
if (!this.activeUntil.delete(clientId)) return;
|
||||
if (this.activeUntil.size === 0) {
|
||||
this.cancelTimer();
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
|
||||
updateSettings(settings: VoiceDuckingSettings): void {
|
||||
const previous = this.settings;
|
||||
this.settings = normalizeSettings(settings);
|
||||
|
||||
if (!this.settings.enabled) {
|
||||
this.activeUntil.clear();
|
||||
this.cancelTimer();
|
||||
this.release();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
previous.volumePercent !== this.settings.volumePercent &&
|
||||
this.ducking
|
||||
) {
|
||||
this.target.setDuckingGain(this.settings.volumePercent / 100, this.timing.attackMs);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all activity. Disconnects use an immediate reset; disabling the
|
||||
* feature uses updateSettings(), which returns smoothly over releaseMs. */
|
||||
reset(immediate = true): void {
|
||||
this.activeUntil.clear();
|
||||
this.cancelTimer();
|
||||
this.ducking = false;
|
||||
this.target.setDuckingGain(1, immediate ? 0 : this.timing.releaseMs);
|
||||
}
|
||||
|
||||
isDucking(): boolean {
|
||||
return this.ducking;
|
||||
}
|
||||
|
||||
activeSpeakerCount(): number {
|
||||
return this.activeUntil.size;
|
||||
}
|
||||
|
||||
private scheduleNextSweep(now = this.now()): void {
|
||||
if (this.activeUntil.size === 0) return;
|
||||
|
||||
let nextDueAt = Number.POSITIVE_INFINITY;
|
||||
for (const deadline of this.activeUntil.values()) {
|
||||
if (deadline < nextDueAt) nextDueAt = deadline;
|
||||
}
|
||||
|
||||
// Keeping an earlier timer is intentional. When it fires it will observe
|
||||
// the refreshed deadline and schedule the remaining delay, avoiding timer
|
||||
// churn on every incoming packet.
|
||||
if (this.expiryTimer && this.timerDueAt <= nextDueAt) return;
|
||||
|
||||
this.cancelTimer();
|
||||
const generation = ++this.timerGeneration;
|
||||
this.timerDueAt = nextDueAt;
|
||||
this.expiryTimer = setTimeout(() => {
|
||||
if (generation !== this.timerGeneration) return;
|
||||
this.expiryTimer = null;
|
||||
this.timerDueAt = Number.POSITIVE_INFINITY;
|
||||
this.sweepExpiredSpeakers();
|
||||
}, Math.max(0, nextDueAt - now));
|
||||
}
|
||||
|
||||
private sweepExpiredSpeakers(): void {
|
||||
const now = this.now();
|
||||
for (const [clientId, deadline] of this.activeUntil) {
|
||||
if (deadline <= now) this.activeUntil.delete(clientId);
|
||||
}
|
||||
|
||||
if (this.activeUntil.size > 0) {
|
||||
this.scheduleNextSweep(now);
|
||||
} else {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
if (!this.ducking) return;
|
||||
this.ducking = false;
|
||||
this.target.setDuckingGain(1, this.timing.releaseMs);
|
||||
}
|
||||
|
||||
private cancelTimer(): void {
|
||||
this.timerGeneration++;
|
||||
if (this.expiryTimer) clearTimeout(this.expiryTimer);
|
||||
this.expiryTimer = null;
|
||||
this.timerDueAt = Number.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,79 @@ describe("config", () => {
|
||||
expect(config).toEqual(getDefaultConfig());
|
||||
});
|
||||
|
||||
it("defaults voice ducking to disabled at 30 percent", () => {
|
||||
expect(getDefaultConfig().voiceDucking).toEqual({
|
||||
enabled: false,
|
||||
volumePercent: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("fills voiceDucking defaults for legacy and partial configs", () => {
|
||||
const dir = makeTmpDir();
|
||||
const legacyPath = join(dir, "legacy.json");
|
||||
writeFileSync(legacyPath, JSON.stringify({ webPort: 4000 }));
|
||||
expect(loadConfig(legacyPath).voiceDucking).toEqual({
|
||||
enabled: false,
|
||||
volumePercent: 30,
|
||||
});
|
||||
|
||||
const partialPath = join(dir, "partial.json");
|
||||
writeFileSync(partialPath, JSON.stringify({ voiceDucking: { enabled: true } }));
|
||||
expect(loadConfig(partialPath).voiceDucking).toEqual({
|
||||
enabled: true,
|
||||
volumePercent: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("loadConfig preserves valid voiceDucking values including range endpoints", () => {
|
||||
const dir = makeTmpDir();
|
||||
for (const volumePercent of [0, 37.5, 100]) {
|
||||
const path = join(dir, `voice-ducking-${volumePercent}.json`);
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({ voiceDucking: { enabled: true, volumePercent } }),
|
||||
);
|
||||
expect(loadConfig(path).voiceDucking).toEqual({ enabled: true, volumePercent });
|
||||
}
|
||||
});
|
||||
|
||||
it("loadConfig strictly sanitizes malformed voiceDucking values", () => {
|
||||
const dir = makeTmpDir();
|
||||
const malformed: Array<{ name: string; json: string }> = [
|
||||
{ name: "null-block", json: JSON.stringify({ voiceDucking: null }) },
|
||||
{ name: "array-block", json: JSON.stringify({ voiceDucking: [true, 10] }) },
|
||||
{ name: "string-block", json: JSON.stringify({ voiceDucking: "on" }) },
|
||||
{
|
||||
name: "wrong-types",
|
||||
json: JSON.stringify({ voiceDucking: { enabled: "yes", volumePercent: "25" } }),
|
||||
},
|
||||
{
|
||||
name: "below-range",
|
||||
json: JSON.stringify({ voiceDucking: { enabled: true, volumePercent: -1 } }),
|
||||
},
|
||||
{
|
||||
name: "above-range",
|
||||
json: JSON.stringify({ voiceDucking: { enabled: true, volumePercent: 101 } }),
|
||||
},
|
||||
// JSON.parse("1e309") produces Infinity, exercising the finite-number guard.
|
||||
{
|
||||
name: "non-finite",
|
||||
json: '{"voiceDucking":{"enabled":true,"volumePercent":1e309}}',
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of malformed) {
|
||||
const path = join(dir, `${testCase.name}.json`);
|
||||
writeFileSync(path, testCase.json);
|
||||
const loaded = loadConfig(path).voiceDucking;
|
||||
if (testCase.name === "below-range" || testCase.name === "above-range" || testCase.name === "non-finite") {
|
||||
expect(loaded).toEqual({ enabled: true, volumePercent: 30 });
|
||||
} else {
|
||||
expect(loaded).toEqual({ enabled: false, volumePercent: 30 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to the online sources with jellyfin as opt-in (disabled)", () => {
|
||||
const config = getDefaultConfig();
|
||||
expect(config.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
|
||||
|
||||
@@ -54,6 +54,12 @@ export interface AudioQualityConfig {
|
||||
jellyfin: string;
|
||||
}
|
||||
|
||||
export interface VoiceDuckingConfig {
|
||||
enabled: boolean;
|
||||
/** Percentage of the normal playback volume retained while someone speaks. */
|
||||
volumePercent: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers gated by `enabledProviders`. Not listed here:
|
||||
* - "local" → governed by the existing `localAudioEnabled` flag
|
||||
@@ -113,6 +119,8 @@ export interface BotConfig {
|
||||
adminGroups: number[];
|
||||
autoReturnDelay: number;
|
||||
autoPauseOnEmpty: boolean;
|
||||
/** Lower music volume while voice from another client is being received. */
|
||||
voiceDucking: VoiceDuckingConfig;
|
||||
idleTimeoutMinutes: number;
|
||||
/** Enable uploading and playback of server-stored local audio files. */
|
||||
localAudioEnabled: boolean;
|
||||
@@ -176,6 +184,10 @@ export function getDefaultConfig(): BotConfig {
|
||||
// command, which is unreliable on some servers (it can time out when other
|
||||
// clients are present). Users can opt in from the web UI.
|
||||
autoPauseOnEmpty: false,
|
||||
voiceDucking: {
|
||||
enabled: false,
|
||||
volumePercent: 30,
|
||||
},
|
||||
idleTimeoutMinutes: 0,
|
||||
localAudioEnabled: true,
|
||||
savedQueuesEnabled: false,
|
||||
@@ -383,6 +395,31 @@ export function loadConfig(path: string): BotConfig {
|
||||
const savedQueuesEnabled = partial.savedQueuesEnabled === true;
|
||||
const playKeepsQueue = partial.playKeepsQueue === true;
|
||||
|
||||
// Voice ducking is opt-in and the retained-volume percentage is consumed
|
||||
// directly by the audio path. Only a plain-object block with correctly
|
||||
// typed, finite and in-range fields may override the safe defaults.
|
||||
const rawVoiceDucking = partial.voiceDucking;
|
||||
const partialVoiceDucking =
|
||||
rawVoiceDucking !== null &&
|
||||
typeof rawVoiceDucking === "object" &&
|
||||
!Array.isArray(rawVoiceDucking)
|
||||
? (rawVoiceDucking as Partial<VoiceDuckingConfig>)
|
||||
: {};
|
||||
const rawVolumePercent = partialVoiceDucking.volumePercent;
|
||||
const voiceDucking: VoiceDuckingConfig = {
|
||||
enabled:
|
||||
typeof partialVoiceDucking.enabled === "boolean"
|
||||
? partialVoiceDucking.enabled
|
||||
: defaults.voiceDucking.enabled,
|
||||
volumePercent:
|
||||
typeof rawVolumePercent === "number" &&
|
||||
Number.isFinite(rawVolumePercent) &&
|
||||
rawVolumePercent >= 0 &&
|
||||
rawVolumePercent <= 100
|
||||
? rawVolumePercent
|
||||
: defaults.voiceDucking.volumePercent,
|
||||
};
|
||||
|
||||
// defaultPlatform → an explicit operator default (issue #126). Keep it only
|
||||
// when it names a KNOWN gateable provider that is ALSO currently enabled;
|
||||
// anything else (unknown value, disabled source, wrong type, missing) becomes
|
||||
@@ -420,6 +457,7 @@ export function loadConfig(path: string): BotConfig {
|
||||
enabledProviders,
|
||||
savedQueuesEnabled,
|
||||
playKeepsQueue,
|
||||
voiceDucking,
|
||||
defaultPlatform: defaultPlatformPref,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import pino from "pino";
|
||||
import { TS3Client } from "./client.js";
|
||||
|
||||
@@ -83,3 +83,59 @@ describe("TS3Client.getClientServerGroups — live query + parse smoke test", ()
|
||||
expect(await ts.getClientServerGroups(5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TS3Client stable identity UID", () => {
|
||||
it("derives the same client UID after exporting and restoring an identity", () => {
|
||||
const first = makeClient();
|
||||
const restored = new TS3Client(
|
||||
{
|
||||
host: "localhost",
|
||||
port: 9987,
|
||||
queryPort: 10011,
|
||||
nickname: "RestoredBot",
|
||||
identity: first.getIdentityExport(),
|
||||
},
|
||||
pino({ level: "silent" }),
|
||||
);
|
||||
|
||||
expect(first.getClientUid()).toBeTruthy();
|
||||
expect(restored.getClientUid()).toBe(first.getClientUid());
|
||||
});
|
||||
});
|
||||
|
||||
type VisibleUidHarness = {
|
||||
visibleClientUids: Map<number, string>;
|
||||
rememberVisibleClientUid(clientId: number, clientUid: string): void;
|
||||
releaseVisibleClientUid(clientId: number): void;
|
||||
clearVisibleClientUids(): void;
|
||||
};
|
||||
|
||||
describe("TS3Client visible client UID grace", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("retains a leaving client's UID for final reordered voice packets", () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = makeClient() as unknown as VisibleUidHarness;
|
||||
cache.rememberVisibleClientUid(7, "managed-bot-uid=");
|
||||
|
||||
cache.releaseVisibleClientUid(7);
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(cache.visibleClientUids.get(7)).toBe("managed-bot-uid=");
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(cache.visibleClientUids.has(7)).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a new clientEnter overwrite a reused id and cancel stale cleanup", () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = makeClient() as unknown as VisibleUidHarness;
|
||||
cache.rememberVisibleClientUid(7, "old-managed-bot-uid=");
|
||||
cache.releaseVisibleClientUid(7);
|
||||
|
||||
cache.rememberVisibleClientUid(7, "new-human-uid=");
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
expect(cache.visibleClientUids.get(7)).toBe("new-human-uid=");
|
||||
cache.clearVisibleClientUids();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { Readable } from "node:stream";
|
||||
import {
|
||||
Client as TS3FullClient,
|
||||
generateIdentity as genTS3Identity,
|
||||
getUidFromPublicKey,
|
||||
identityFromString,
|
||||
sendTextMessage,
|
||||
listChannels,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
type ClientInfo,
|
||||
type ClientLeftViewEvent,
|
||||
type ClientMovedEvent,
|
||||
type VoiceData,
|
||||
type FileUploadInfo,
|
||||
} from "@honeybbq/teamspeak-client";
|
||||
import type { Logger } from "../logger.js";
|
||||
@@ -23,6 +25,10 @@ import {
|
||||
type ServerProtocol,
|
||||
} from "./protocol-detect.js";
|
||||
import { TS6HttpQuery } from "./http-query.js";
|
||||
import {
|
||||
TrackingVoiceEndpointResolver,
|
||||
type ResolvedVoiceEndpoint,
|
||||
} from "./voice-endpoint.js";
|
||||
|
||||
export { CODEC_OPUS_MUSIC } from "./voice.js";
|
||||
export type { ServerProtocol } from "./protocol-detect.js";
|
||||
@@ -65,6 +71,20 @@ export interface TS3TextMessage {
|
||||
invokerGroups: string[]; // sender's TS server-group ids; [] when not in view cache
|
||||
}
|
||||
|
||||
/** Lightweight voice-packet signal used for activity detection. The encoded
|
||||
* payload is intentionally not forwarded beyond this protocol wrapper. */
|
||||
export interface TS3VoiceActivity {
|
||||
clientId: number;
|
||||
codec: number;
|
||||
/** Stable TeamSpeak identity when the sender is present in the client view. */
|
||||
clientUid?: string;
|
||||
}
|
||||
|
||||
// Command notifications and UDP voice packets can be reordered in flight.
|
||||
// Retain a leaving client's UID briefly so its final packet is still
|
||||
// attributable; a new clientEnter for the same id cancels and overwrites it.
|
||||
const VISIBLE_CLIENT_UID_RELEASE_GRACE_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Map the library's TextMessage to our wrapper. Preserves invokerGroups (the
|
||||
* sender's TS server groups), which the library populates only when the sender
|
||||
@@ -85,12 +105,19 @@ export function toTS3TextMessage(msg: TextMessage): TS3TextMessage {
|
||||
export class TS3Client extends EventEmitter {
|
||||
private client: TS3FullClient | null = null;
|
||||
private identity: Identity;
|
||||
private readonly clientUid: string;
|
||||
private clientId = 0;
|
||||
private readonly visibleClientUids = new Map<number, string>();
|
||||
private readonly visibleClientUidReleaseTimers = new Map<
|
||||
number,
|
||||
ReturnType<typeof setTimeout>
|
||||
>();
|
||||
private logger: Logger;
|
||||
private disconnecting = false;
|
||||
private detectedProtocol: ServerProtocol = "unknown";
|
||||
private httpQuery: TS6HttpQuery | null = null;
|
||||
private udpErrorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly voiceEndpointResolver = new TrackingVoiceEndpointResolver();
|
||||
|
||||
constructor(private options: TS3ClientOptions, logger: Logger) {
|
||||
super();
|
||||
@@ -101,6 +128,7 @@ export class TS3Client extends EventEmitter {
|
||||
} else {
|
||||
this.identity = genTS3Identity(8);
|
||||
}
|
||||
this.clientUid = getUidFromPublicKey(this.identity.publicKeyBase64());
|
||||
}
|
||||
|
||||
/** The detected (or forced) server protocol after connect(). */
|
||||
@@ -114,6 +142,8 @@ export class TS3Client extends EventEmitter {
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.voiceEndpointResolver.reset();
|
||||
this.clearVisibleClientUids();
|
||||
// Clean up any existing connection before creating a new one
|
||||
if (this.client) {
|
||||
this.logger.info("Cleaning up previous connection before reconnecting");
|
||||
@@ -213,6 +243,7 @@ export class TS3Client extends EventEmitter {
|
||||
// Forward server password to the protocol library so it can be
|
||||
// included in clientinit for password-protected servers
|
||||
serverPassword: this.options.serverPassword,
|
||||
resolver: this.voiceEndpointResolver,
|
||||
logger: {
|
||||
debug: (msg) => this.logger.debug(msg),
|
||||
info: (msg) => this.logger.info(msg),
|
||||
@@ -222,16 +253,32 @@ export class TS3Client extends EventEmitter {
|
||||
});
|
||||
|
||||
this.client.on("textMessage", (msg: TextMessage) => {
|
||||
if (msg.invokerID === this.clientId) return;
|
||||
this.emit("textMessage", toTS3TextMessage(msg));
|
||||
});
|
||||
|
||||
this.client.on("voiceData", (voice: VoiceData) => {
|
||||
// The library normally suppresses our own packets; retain the explicit
|
||||
// guard so a future protocol change cannot make a bot duck itself.
|
||||
if (voice.clientId === this.clientId) return;
|
||||
const clientUid = this.visibleClientUids.get(voice.clientId);
|
||||
const activity: TS3VoiceActivity = {
|
||||
clientId: voice.clientId,
|
||||
codec: voice.codec,
|
||||
...(clientUid ? { clientUid } : {}),
|
||||
};
|
||||
this.emit("voiceActivity", activity);
|
||||
});
|
||||
|
||||
this.client.on("disconnected", (err) => {
|
||||
this.logger.warn({ err: err?.message }, "Connection closed");
|
||||
this.clientId = 0;
|
||||
this.clearVisibleClientUids();
|
||||
this.emit("disconnected");
|
||||
});
|
||||
|
||||
this.client.on("clientEnter", (info: ClientInfo) => {
|
||||
this.rememberVisibleClientUid(info.id, info.uid);
|
||||
this.logger.debug(
|
||||
{ nickname: info.nickname, id: info.id },
|
||||
"Client entered"
|
||||
@@ -240,6 +287,7 @@ export class TS3Client extends EventEmitter {
|
||||
});
|
||||
|
||||
this.client.on("clientLeave", (ev: ClientLeftViewEvent) => {
|
||||
this.releaseVisibleClientUid(ev.id);
|
||||
this.logger.debug({ id: ev.id }, "Client left");
|
||||
this.emit("clientLeave", ev);
|
||||
});
|
||||
@@ -429,6 +477,52 @@ export class TS3Client extends EventEmitter {
|
||||
return this.clientId;
|
||||
}
|
||||
|
||||
/** Actual endpoint selected by the SDK's SRV/TSDNS discovery and DNS lookup. */
|
||||
getResolvedVoiceEndpoint(): ResolvedVoiceEndpoint | null {
|
||||
return this.voiceEndpointResolver.getEndpoint();
|
||||
}
|
||||
|
||||
/** Stable identity of this managed TeamSpeak client. */
|
||||
getClientUid(): string {
|
||||
return this.clientUid;
|
||||
}
|
||||
|
||||
private rememberVisibleClientUid(clientId: number, clientUid: string): void {
|
||||
const pendingRelease = this.visibleClientUidReleaseTimers.get(clientId);
|
||||
if (pendingRelease) clearTimeout(pendingRelease);
|
||||
this.visibleClientUidReleaseTimers.delete(clientId);
|
||||
|
||||
if (clientId > 0 && clientUid) {
|
||||
this.visibleClientUids.set(clientId, clientUid);
|
||||
} else {
|
||||
this.visibleClientUids.delete(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
private releaseVisibleClientUid(clientId: number): void {
|
||||
const clientUid = this.visibleClientUids.get(clientId);
|
||||
if (!clientUid) return;
|
||||
|
||||
const previous = this.visibleClientUidReleaseTimers.get(clientId);
|
||||
if (previous) clearTimeout(previous);
|
||||
const timer = setTimeout(() => {
|
||||
if (this.visibleClientUids.get(clientId) === clientUid) {
|
||||
this.visibleClientUids.delete(clientId);
|
||||
}
|
||||
this.visibleClientUidReleaseTimers.delete(clientId);
|
||||
}, VISIBLE_CLIENT_UID_RELEASE_GRACE_MS);
|
||||
timer.unref?.();
|
||||
this.visibleClientUidReleaseTimers.set(clientId, timer);
|
||||
}
|
||||
|
||||
private clearVisibleClientUids(): void {
|
||||
for (const timer of this.visibleClientUidReleaseTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.visibleClientUidReleaseTimers.clear();
|
||||
this.visibleClientUids.clear();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.client && !this.disconnecting) {
|
||||
this.disconnecting = true;
|
||||
@@ -441,6 +535,7 @@ export class TS3Client extends EventEmitter {
|
||||
});
|
||||
}
|
||||
this.clientId = 0;
|
||||
this.clearVisibleClientUids();
|
||||
this.httpQuery = null;
|
||||
this.detectedProtocol = "unknown";
|
||||
if (this.udpErrorTimer) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AddrResolver, ResolvedAddr } from "@honeybbq/teamspeak-client";
|
||||
import { TrackingVoiceEndpointResolver } from "./voice-endpoint.js";
|
||||
|
||||
function result(addr: string): ResolvedAddr {
|
||||
return { addr, source: "test", expiry: new Date(0) };
|
||||
}
|
||||
|
||||
function delegate(...addresses: string[]): AddrResolver {
|
||||
return {
|
||||
resolve: vi.fn(async () => addresses.map(result)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("TrackingVoiceEndpointResolver", () => {
|
||||
it("pins a DNS alias to the IPv4 endpoint used by the UDP connection", async () => {
|
||||
const resolveHost = vi.fn(async () => "203.0.113.20");
|
||||
const resolver = new TrackingVoiceEndpointResolver(
|
||||
delegate("voice-alias.example.com:9987"),
|
||||
resolveHost,
|
||||
);
|
||||
|
||||
const resolved = await resolver.resolve("voice.example.com:9987");
|
||||
|
||||
expect(resolveHost).toHaveBeenCalledWith("voice-alias.example.com");
|
||||
expect(resolved[0]?.addr).toBe("203.0.113.20:9987");
|
||||
expect(resolver.getEndpoint()).toEqual({ host: "203.0.113.20", port: 9987 });
|
||||
});
|
||||
|
||||
it("preserves the port chosen by SRV/TSDNS discovery", async () => {
|
||||
const resolver = new TrackingVoiceEndpointResolver(
|
||||
delegate("srv-target.example.com:12000"),
|
||||
async () => "198.51.100.8",
|
||||
);
|
||||
|
||||
expect((await resolver.resolve("voice.example.com:9987"))[0]?.addr).toBe(
|
||||
"198.51.100.8:12000",
|
||||
);
|
||||
expect(resolver.getEndpoint()?.port).toBe(12000);
|
||||
});
|
||||
|
||||
it("keeps the SDK target as a safe fallback when A-record lookup fails", async () => {
|
||||
const original = "voice.example.com:9987";
|
||||
const resolver = new TrackingVoiceEndpointResolver(
|
||||
delegate(original),
|
||||
async () => {
|
||||
throw new Error("dns unavailable");
|
||||
},
|
||||
);
|
||||
|
||||
expect((await resolver.resolve(original))[0]?.addr).toBe(original);
|
||||
expect(resolver.getEndpoint()).toEqual({ host: "voice.example.com", port: 9987 });
|
||||
});
|
||||
|
||||
it("does not mutate secondary SDK candidates", async () => {
|
||||
const resolver = new TrackingVoiceEndpointResolver(
|
||||
delegate("first.example.com:9987", "second.example.com:9988"),
|
||||
async () => "192.0.2.4",
|
||||
);
|
||||
|
||||
const resolved = await resolver.resolve("voice.example.com:9987");
|
||||
expect(resolved.map((candidate) => candidate.addr)).toEqual([
|
||||
"192.0.2.4:9987",
|
||||
"second.example.com:9988",
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears the observed endpoint before a reconnect", async () => {
|
||||
const resolver = new TrackingVoiceEndpointResolver(
|
||||
delegate("voice.example.com:9987"),
|
||||
async () => "192.0.2.5",
|
||||
);
|
||||
await resolver.resolve("voice.example.com:9987");
|
||||
|
||||
resolver.reset();
|
||||
|
||||
expect(resolver.getEndpoint()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
import { Resolver } from "@honeybbq/teamspeak-client/discovery";
|
||||
import type {
|
||||
AddrResolver,
|
||||
ResolvedAddr,
|
||||
} from "@honeybbq/teamspeak-client";
|
||||
|
||||
export interface ResolvedVoiceEndpoint {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
type ResolveIpv4 = (host: string) => Promise<string>;
|
||||
|
||||
function parseVoiceAddress(address: string): ResolvedVoiceEndpoint | null {
|
||||
let host: string;
|
||||
let rawPort: string;
|
||||
|
||||
if (address.startsWith("[")) {
|
||||
const closingBracket = address.indexOf("]");
|
||||
if (closingBracket < 0 || address[closingBracket + 1] !== ":") return null;
|
||||
host = address.slice(1, closingBracket);
|
||||
rawPort = address.slice(closingBracket + 2);
|
||||
} else {
|
||||
const separator = address.lastIndexOf(":");
|
||||
if (separator <= 0) return null;
|
||||
host = address.slice(0, separator);
|
||||
rawPort = address.slice(separator + 1);
|
||||
}
|
||||
|
||||
const port = Number(rawPort);
|
||||
if (
|
||||
host.length === 0 ||
|
||||
!Number.isInteger(port) ||
|
||||
port < 1 ||
|
||||
port > 65_535
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
function formatVoiceAddress(endpoint: ResolvedVoiceEndpoint): string {
|
||||
return endpoint.host.includes(":")
|
||||
? `[${endpoint.host}]:${endpoint.port}`
|
||||
: `${endpoint.host}:${endpoint.port}`;
|
||||
}
|
||||
|
||||
async function resolveIpv4(host: string): Promise<string> {
|
||||
if (isIP(host) === 4) return host;
|
||||
return (await lookup(host, { family: 4 })).address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the SDK's normal SRV/TSDNS discovery, then pins its selected hostname
|
||||
* to the IPv4 address that the UDP connection will use. Besides making the
|
||||
* connection target observable, this gives all bots a common registry scope
|
||||
* when one is configured with a DNS alias and another with the underlying IP.
|
||||
*/
|
||||
export class TrackingVoiceEndpointResolver implements AddrResolver {
|
||||
private endpoint: ResolvedVoiceEndpoint | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly delegate: AddrResolver = new Resolver(),
|
||||
private readonly resolveHost: ResolveIpv4 = resolveIpv4,
|
||||
) {}
|
||||
|
||||
async resolve(input: string, signal?: AbortSignal): Promise<ResolvedAddr[]> {
|
||||
this.endpoint = null;
|
||||
const candidates = await this.delegate.resolve(input, signal);
|
||||
const selected = candidates[0];
|
||||
if (!selected) return candidates;
|
||||
|
||||
const parsed = parseVoiceAddress(selected.addr);
|
||||
if (!parsed) return candidates;
|
||||
|
||||
try {
|
||||
const pinned = {
|
||||
host: await this.resolveHost(parsed.host),
|
||||
port: parsed.port,
|
||||
};
|
||||
this.endpoint = pinned;
|
||||
return [
|
||||
{ ...selected, addr: formatVoiceAddress(pinned) },
|
||||
...candidates.slice(1),
|
||||
];
|
||||
} catch {
|
||||
// Preserve the SDK's original target if local A-record resolution fails.
|
||||
// The connection may still succeed through platform-specific resolution;
|
||||
// the registry then falls back to the logical host + resolved port.
|
||||
this.endpoint = parsed;
|
||||
return candidates;
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.endpoint = null;
|
||||
}
|
||||
|
||||
getEndpoint(): ResolvedVoiceEndpoint | null {
|
||||
return this.endpoint ? { ...this.endpoint } : null;
|
||||
}
|
||||
}
|
||||
+65
-2
@@ -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
@@ -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,
|
||||
|
||||
+186
-1
@@ -778,6 +778,70 @@
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="profile-toggle behavior-toggle">
|
||||
<div class="profile-toggle-text">
|
||||
<div class="profile-toggle-label">语音闪避</div>
|
||||
<div class="profile-toggle-hint">检测到其他客户端说话时自动压低音乐音量,说话结束后恢复。默认关闭。</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="voiceDuckingEnabled"
|
||||
type="checkbox"
|
||||
class="profile-toggle-switch"
|
||||
:disabled="voiceDuckingControlsDisabled"
|
||||
@change="saveVoiceDucking"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="setting-row voice-ducking-volume">
|
||||
<div class="setting-label">
|
||||
<Icon icon="mdi:volume-minus" class="setting-icon" />
|
||||
<div>
|
||||
<div>说话时保留原音量</div>
|
||||
<div class="voice-ducking-hint">例如设为 30%,有人说话时音乐将降至原音量的 30%。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="voice-ducking-controls">
|
||||
<input
|
||||
v-model.number="voiceDuckingVolumePercent"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="voice-ducking-range"
|
||||
:disabled="voiceDuckingControlsDisabled"
|
||||
aria-label="说话时保留原音量百分比"
|
||||
/>
|
||||
<div class="prefix-input-wrap">
|
||||
<input
|
||||
v-model.number="voiceDuckingVolumePercent"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="input input-sm"
|
||||
style="max-width:80px"
|
||||
:disabled="voiceDuckingControlsDisabled"
|
||||
aria-label="说话时保留原音量百分比"
|
||||
@blur="normalizeVoiceDuckingVolume"
|
||||
/>
|
||||
<span class="voice-ducking-unit">%</span>
|
||||
<button class="btn-primary" :disabled="voiceDuckingControlsDisabled" @click="saveVoiceDucking">
|
||||
{{ !voiceDuckingLoaded ? '加载设置…' : voiceDuckingSaving ? '保存中…' : '保存比例' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="voiceDuckingMessage"
|
||||
class="voice-ducking-message"
|
||||
:class="`tone-${voiceDuckingMessageTone}`"
|
||||
:role="voiceDuckingMessageTone === 'warn' ? 'alert' : 'status'"
|
||||
:aria-live="voiceDuckingMessageTone === 'warn' ? 'assertive' : 'polite'"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{{ voiceDuckingMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="profile-toggle behavior-toggle">
|
||||
<div class="profile-toggle-text">
|
||||
<div class="profile-toggle-label">本地音频播放</div>
|
||||
@@ -1595,16 +1659,59 @@ async function savePrefix() {
|
||||
const idleTimeout = ref(0);
|
||||
// Defaults OFF to match the backend default (config.ts getDefaultConfig).
|
||||
const autoPauseOnEmpty = ref(false);
|
||||
// Voice ducking defaults OFF and retains 30% of the configured player volume.
|
||||
const voiceDuckingEnabled = ref(false);
|
||||
const voiceDuckingVolumePercent = ref(30);
|
||||
const voiceDuckingLoaded = ref(false);
|
||||
const voiceDuckingSaving = ref(false);
|
||||
const voiceDuckingControlsDisabled = computed(
|
||||
() => !voiceDuckingLoaded.value || voiceDuckingSaving.value,
|
||||
);
|
||||
const voiceDuckingMessage = ref('');
|
||||
const voiceDuckingMessageTone = ref<'ok' | 'warn'>('ok');
|
||||
let savedVoiceDucking = { enabled: false, volumePercent: 30 };
|
||||
let voiceDuckingRequestRevision = 0;
|
||||
const localAudioEnabled = ref(true);
|
||||
// Saved-queues + play-keeps-queue toggles (#119), both default OFF.
|
||||
const savedQueuesEnabled = ref(false);
|
||||
const playKeepsQueue = ref(false);
|
||||
|
||||
function normalizeVoiceDuckingVolume(): number {
|
||||
const raw = voiceDuckingVolumePercent.value as number | string;
|
||||
const value = raw === '' ? Number.NaN : Number(raw);
|
||||
voiceDuckingVolumePercent.value = Number.isFinite(value)
|
||||
? Math.min(100, Math.max(0, value))
|
||||
: savedVoiceDucking.volumePercent;
|
||||
return voiceDuckingVolumePercent.value;
|
||||
}
|
||||
|
||||
function applyVoiceDuckingConfig(config: unknown) {
|
||||
if (!config || typeof config !== 'object') return;
|
||||
const value = config as { enabled?: unknown; volumePercent?: unknown };
|
||||
voiceDuckingEnabled.value = typeof value.enabled === 'boolean' ? value.enabled : false;
|
||||
const percent = value.volumePercent;
|
||||
voiceDuckingVolumePercent.value = typeof percent === 'number' && Number.isFinite(percent)
|
||||
? Math.min(100, Math.max(0, percent))
|
||||
: 30;
|
||||
savedVoiceDucking = {
|
||||
enabled: voiceDuckingEnabled.value,
|
||||
volumePercent: voiceDuckingVolumePercent.value,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadIdleTimeout() {
|
||||
const voiceDuckingLoadRevision = voiceDuckingRequestRevision;
|
||||
try {
|
||||
const res = await axios.get('/api/bot/settings');
|
||||
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
|
||||
// A later save owns the state. Do not let an older GET response overwrite
|
||||
// it if this loader is ever re-entered while a POST is in flight.
|
||||
if (voiceDuckingLoadRevision === voiceDuckingRequestRevision) {
|
||||
applyVoiceDuckingConfig(res.data.voiceDucking ?? { enabled: false, volumePercent: 30 });
|
||||
voiceDuckingLoaded.value = true;
|
||||
voiceDuckingMessage.value = '';
|
||||
}
|
||||
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
|
||||
savedQueuesEnabled.value = res.data.savedQueuesEnabled ?? false;
|
||||
playKeepsQueue.value = res.data.playKeepsQueue ?? false;
|
||||
@@ -1620,7 +1727,12 @@ async function loadIdleTimeout() {
|
||||
}
|
||||
// null (unset) → "" so the select shows "自动(按优先级)".
|
||||
defaultPlatformForm.value = res.data.defaultPlatform ?? '';
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
if (!voiceDuckingLoaded.value) {
|
||||
voiceDuckingMessageTone.value = 'warn';
|
||||
voiceDuckingMessage.value = '语音闪避设置加载失败,请刷新页面重试';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveIdleTimeout() {
|
||||
@@ -1635,6 +1747,29 @@ async function saveAutoPause() {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function saveVoiceDucking() {
|
||||
if (!voiceDuckingLoaded.value || voiceDuckingSaving.value) return;
|
||||
voiceDuckingSaving.value = true;
|
||||
voiceDuckingRequestRevision++;
|
||||
voiceDuckingMessage.value = '';
|
||||
const submitted = {
|
||||
enabled: voiceDuckingEnabled.value,
|
||||
volumePercent: normalizeVoiceDuckingVolume(),
|
||||
};
|
||||
try {
|
||||
const res = await axios.post('/api/bot/settings', { voiceDucking: submitted });
|
||||
applyVoiceDuckingConfig(res.data?.voiceDucking ?? submitted);
|
||||
voiceDuckingMessageTone.value = 'ok';
|
||||
voiceDuckingMessage.value = '已保存';
|
||||
} catch {
|
||||
applyVoiceDuckingConfig(savedVoiceDucking);
|
||||
voiceDuckingMessageTone.value = 'warn';
|
||||
voiceDuckingMessage.value = '保存失败,请稍后重试';
|
||||
} finally {
|
||||
voiceDuckingSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLocalAudioEnabled() {
|
||||
try {
|
||||
const res = await axios.post('/api/bot/settings', { localAudioEnabled: localAudioEnabled.value });
|
||||
@@ -2912,6 +3047,45 @@ onUnmounted(() => {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.voice-ducking-volume {
|
||||
padding: 4px 0 14px;
|
||||
}
|
||||
|
||||
.voice-ducking-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.voice-ducking-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.voice-ducking-range {
|
||||
flex: 1 1 240px;
|
||||
min-width: 160px;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.voice-ducking-unit {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.voice-ducking-message {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
|
||||
&.tone-ok { color: var(--color-online); }
|
||||
&.tone-warn { color: #e26a6a; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-bot-header {
|
||||
padding: 14px 12px;
|
||||
@@ -2939,6 +3113,17 @@ onUnmounted(() => {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.voice-ducking-controls {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.voice-ducking-range {
|
||||
flex-basis: auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// --- User Management ---
|
||||
|
||||
Reference in new issue
Block a user