diff --git a/src/audio/player.test.ts b/src/audio/player.test.ts index 9033ff4..9c85d31 100644 --- a/src/audio/player.test.ts +++ b/src/audio/player.test.ts @@ -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 { diff --git a/src/audio/player.ts b/src/audio/player.ts index 57d780f..888bd11 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -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; } -} \ No newline at end of file +} diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 834b253..137c907 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -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, + ) => Promise; + + function makeConnectContext(connectPromise: Promise) { + 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; + + (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. */ diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 3efdd34..86a7c12 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -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 = { 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 { 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 () => { diff --git a/src/bot/managed-voice-clients.test.ts b/src/bot/managed-voice-clients.test.ts new file mode 100644 index 0000000..f8ec425 --- /dev/null +++ b/src/bot/managed-voice-clients.test.ts @@ -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); + }); +}); diff --git a/src/bot/managed-voice-clients.ts b/src/bot/managed-voice-clients.ts new file mode 100644 index 0000000..d4426fa --- /dev/null +++ b/src/bot/managed-voice-clients.ts @@ -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 + >(); + private readonly ownersByClientUid = new Map< + string, + Set + >(); + + /** + * 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; + } +} diff --git a/src/bot/manager.ts b/src/bot/manager.ts index ae1b011..d7e4770 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -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(); + 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, }); diff --git a/src/bot/voice-ducking.test.ts b/src/bot/voice-ducking.test.ts new file mode 100644 index 0000000..a57a709 --- /dev/null +++ b/src/bot/voice-ducking.test.ts @@ -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); + }); +}); diff --git a/src/bot/voice-ducking.ts b/src/bot/voice-ducking.ts new file mode 100644 index 0000000..2963fb4 --- /dev/null +++ b/src/bot/voice-ducking.ts @@ -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 = { + attackMs: 50, + holdMs: 700, + releaseMs: 500, +}; + +interface VoiceDuckingControllerOptions { + timing?: Partial; + 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(); + private expiryTimer: ReturnType | 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; + } +} diff --git a/src/data/config.test.ts b/src/data/config.test.ts index 119aca6..62deb52 100644 --- a/src/data/config.test.ts +++ b/src/data/config.test.ts @@ -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"]); diff --git a/src/data/config.ts b/src/data/config.ts index baf671c..898a5bc 100755 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -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) + : {}; + 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, }; } diff --git a/src/ts-protocol/client-groups.test.ts b/src/ts-protocol/client-groups.test.ts index 261a3d6..1981982 100644 --- a/src/ts-protocol/client-groups.test.ts +++ b/src/ts-protocol/client-groups.test.ts @@ -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; + 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(); + }); +}); diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index 655b100..aa8a4c5 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -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(); + private readonly visibleClientUidReleaseTimers = new Map< + number, + ReturnType + >(); private logger: Logger; private disconnecting = false; private detectedProtocol: ServerProtocol = "unknown"; private httpQuery: TS6HttpQuery | null = null; private udpErrorTimer: ReturnType | 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 { + 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), @@ -226,13 +257,28 @@ export class TS3Client extends EventEmitter { 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" @@ -241,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); }); @@ -430,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; @@ -442,6 +535,7 @@ export class TS3Client extends EventEmitter { }); } this.clientId = 0; + this.clearVisibleClientUids(); this.httpQuery = null; this.detectedProtocol = "unknown"; if (this.udpErrorTimer) { diff --git a/src/ts-protocol/voice-endpoint.test.ts b/src/ts-protocol/voice-endpoint.test.ts new file mode 100644 index 0000000..52dfad3 --- /dev/null +++ b/src/ts-protocol/voice-endpoint.test.ts @@ -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(); + }); +}); diff --git a/src/ts-protocol/voice-endpoint.ts b/src/ts-protocol/voice-endpoint.ts new file mode 100644 index 0000000..3f92fed --- /dev/null +++ b/src/ts-protocol/voice-endpoint.ts @@ -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; + +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 { + 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 { + 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; + } +} diff --git a/src/web/api/bot.test.ts b/src/web/api/bot.test.ts index bac40fe..dd111dd 100644 --- a/src/web/api/bot.test.ts +++ b/src/web/api/bot.test.ts @@ -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") diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index 4653b4c..043a578 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -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, diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index 7d3d04c..dc9207f 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -778,6 +778,70 @@ /> + + +
+
+ +
+
说话时保留原音量
+
例如设为 30%,有人说话时音乐将降至原音量的 30%。
+
+
+
+ +
+ + % + +
+
+

+ {{ voiceDuckingMessage }} +

+
+