mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat: add voice ducking
This commit is contained in:
1 parent
b51b5a2317
commit
97e8a87305
17 files changed
+1550
-10
No files matched your search
@@ -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,113 @@ 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 })),
|
||||
},
|
||||
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("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(voiceServerScope, 20, {});
|
||||
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 });
|
||||
tsClient.emit("voiceActivity", { clientId: 21, codec: 5 });
|
||||
|
||||
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);
|
||||
const ctx = {
|
||||
managedVoiceClients,
|
||||
voiceServerScope,
|
||||
registeredVoiceClientId: 20,
|
||||
registeredVoiceClientOwner: owner,
|
||||
};
|
||||
|
||||
(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);
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(managedVoiceClients.has(voiceServerScope, 20)).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. */
|
||||
|
||||
+108
-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,11 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
private tsClient: TS3Client;
|
||||
private player: AudioPlayer;
|
||||
private voiceDucking: VoiceDuckingController;
|
||||
private managedVoiceClients: ManagedVoiceClientRegistry;
|
||||
private voiceServerScope: ManagedVoiceClientScope;
|
||||
private registeredVoiceClientId = 0;
|
||||
private registeredVoiceClientOwner: ManagedVoiceClientOwnerToken | null = null;
|
||||
private spotifyController: SpotifyController;
|
||||
private queue: PlayQueue;
|
||||
private neteaseProvider: MusicProvider;
|
||||
@@ -197,6 +216,16 @@ 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.voiceServerScope = {
|
||||
host: options.tsOptions.host,
|
||||
voicePort: options.tsOptions.port,
|
||||
};
|
||||
this.queue = new PlayQueue();
|
||||
|
||||
// Restore persisted per-bot player settings (#125): volume + play mode
|
||||
@@ -358,6 +387,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 +420,14 @@ export class BotInstance extends EventEmitter {
|
||||
this._startJellyfinReportPoller();
|
||||
});
|
||||
|
||||
this.tsClient.on("voiceActivity", (activity: TS3VoiceActivity) => {
|
||||
if (!this.connected) return;
|
||||
if (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 +439,51 @@ 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 = {};
|
||||
if (this.managedVoiceClients.register(this.voiceServerScope, clientId, owner)) {
|
||||
this.registeredVoiceClientId = clientId;
|
||||
this.registeredVoiceClientOwner = owner;
|
||||
}
|
||||
}
|
||||
|
||||
private unregisterManagedVoiceClient(graceMs = 0): void {
|
||||
const clientId = this.registeredVoiceClientId;
|
||||
const owner = this.registeredVoiceClientOwner;
|
||||
const scope = { ...this.voiceServerScope };
|
||||
this.registeredVoiceClientId = 0;
|
||||
this.registeredVoiceClientOwner = null;
|
||||
if (clientId <= 0 || owner === null) return;
|
||||
|
||||
const unregister = () => {
|
||||
this.managedVoiceClients.unregister(scope, clientId, owner);
|
||||
};
|
||||
if (graceMs > 0) {
|
||||
const timer = setTimeout(unregister, graceMs);
|
||||
timer.unref?.();
|
||||
} else {
|
||||
unregister();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,6 +522,13 @@ export class BotInstance extends EventEmitter {
|
||||
async connect(): Promise<void> {
|
||||
this.disconnectEmitted = false;
|
||||
await this.tsClient.connect();
|
||||
const resolvedEndpoint = this.tsClient.getResolvedVoiceEndpoint();
|
||||
if (resolvedEndpoint) {
|
||||
this.voiceServerScope = {
|
||||
host: resolvedEndpoint.host,
|
||||
voicePort: resolvedEndpoint.port,
|
||||
};
|
||||
}
|
||||
// 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 +537,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 +553,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 +574,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 +600,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,128 @@
|
||||
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("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);
|
||||
registry.register(scope, 12, newConnection);
|
||||
|
||||
expect(registry.unregister(scope, 12, oldConnection)).toBe(false);
|
||||
expect(registry.has(scope, 12)).toBe(true);
|
||||
expect(registry.unregister(scope, 12, newConnection)).toBe(true);
|
||||
expect(registry.has(scope, 12)).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,144 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks voice client ids owned by bot connections in this process.
|
||||
*
|
||||
* 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>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Register (or replace) the connection that owns a client id.
|
||||
* Returns false when the scope or client id is invalid.
|
||||
*/
|
||||
register(
|
||||
scope: ManagedVoiceClientScope,
|
||||
clientId: number,
|
||||
ownerToken: ManagedVoiceClientOwnerToken,
|
||||
): 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);
|
||||
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,
|
||||
): boolean {
|
||||
const key = scopeKey(scope);
|
||||
if (!key || !validClientId(clientId)) return false;
|
||||
|
||||
const clients = this.clientsByScope.get(key);
|
||||
if (!clients || clients.get(clientId) !== ownerToken) return false;
|
||||
|
||||
clients.delete(clientId);
|
||||
if (clients.size === 0) this.clientsByScope.delete(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
has(scope: ManagedVoiceClientScope, clientId: number): boolean {
|
||||
const key = scopeKey(scope);
|
||||
if (!key || !validClientId(clientId)) return false;
|
||||
return this.clientsByScope.get(key)?.has(clientId) ?? 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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user