diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 849312d..137c907 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -137,6 +137,10 @@ describe("BotInstance voice-ducking lifecycle integration", () => { 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(), @@ -172,6 +176,18 @@ describe("BotInstance voice-ducking lifecycle integration", () => { 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; @@ -179,7 +195,13 @@ describe("BotInstance voice-ducking lifecycle integration", () => { tsClient.getClientId = () => 10; const managedVoiceClients = new ManagedVoiceClientRegistry(); const voiceServerScope = { host: "voice.example.com", voicePort: 9987 }; - managedVoiceClients.register(voiceServerScope, 20, {}); + 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, @@ -194,8 +216,19 @@ describe("BotInstance voice-ducking lifecycle integration", () => { } as Record; (BotInstance.prototype as any).setupTsEvents.call(ctx); - tsClient.emit("voiceActivity", { clientId: 20, codec: 5 }); - tsClient.emit("voiceActivity", { clientId: 21, codec: 5 }); + 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); @@ -207,12 +240,19 @@ describe("BotInstance voice-ducking lifecycle integration", () => { const managedVoiceClients = new ManagedVoiceClientRegistry(); const voiceServerScope = { host: "voice.example.com", voicePort: 9987 }; const owner = {}; - managedVoiceClients.register(voiceServerScope, 20, 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); @@ -220,11 +260,13 @@ describe("BotInstance voice-ducking lifecycle integration", () => { // 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(); } diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 7946da8..86a7c12 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -155,9 +155,12 @@ export class BotInstance extends EventEmitter { 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; @@ -222,10 +225,11 @@ export class BotInstance extends EventEmitter { ); this.managedVoiceClients = options.managedVoiceClients ?? new ManagedVoiceClientRegistry(); - this.voiceServerScope = { + 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 @@ -422,7 +426,10 @@ export class BotInstance extends EventEmitter { this.tsClient.on("voiceActivity", (activity: TS3VoiceActivity) => { if (!this.connected) return; - if (this.managedVoiceClients.has(this.voiceServerScope, activity.clientId)) { + if ( + this.managedVoiceClients.hasClientUid(activity.clientUid) || + this.managedVoiceClients.has(this.voiceServerScope, activity.clientId) + ) { return; } this.voiceDucking.handleVoiceActivity(activity.clientId); @@ -461,22 +468,31 @@ export class BotInstance extends EventEmitter { if (!Number.isSafeInteger(clientId) || clientId <= 0) return; const owner = {}; - if (this.managedVoiceClients.register(this.voiceServerScope, clientId, 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 scope = { ...this.voiceServerScope }; + 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); + this.managedVoiceClients.unregister(scope, clientId, owner, clientUid); }; if (graceMs > 0) { const timer = setTimeout(unregister, graceMs); @@ -523,12 +539,12 @@ export class BotInstance extends EventEmitter { this.disconnectEmitted = false; await this.tsClient.connect(); const resolvedEndpoint = this.tsClient.getResolvedVoiceEndpoint(); - if (resolvedEndpoint) { - this.voiceServerScope = { - host: resolvedEndpoint.host, - voicePort: resolvedEndpoint.port, - }; - } + 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 diff --git a/src/bot/managed-voice-clients.test.ts b/src/bot/managed-voice-clients.test.ts index 9154b11..f8ec425 100644 --- a/src/bot/managed-voice-clients.test.ts +++ b/src/bot/managed-voice-clients.test.ts @@ -39,6 +39,7 @@ describe("managed voice client scope normalization", () => { }), ).toBeNull(); }); + }); describe("ManagedVoiceClientRegistry", () => { @@ -74,6 +75,50 @@ describe("ManagedVoiceClientRegistry", () => { ).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( @@ -93,13 +138,21 @@ describe("ManagedVoiceClientRegistry", () => { const oldConnection = Symbol("old connection"); const newConnection = Symbol("new connection"); - registry.register(scope, 12, oldConnection); - registry.register(scope, 12, newConnection); + registry.register(scope, 12, oldConnection, "managed-client-uid="); + registry.register(scope, 12, newConnection, "managed-client-uid="); - expect(registry.unregister(scope, 12, oldConnection)).toBe(false); + // 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.unregister(scope, 12, newConnection)).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])( diff --git a/src/bot/managed-voice-clients.ts b/src/bot/managed-voice-clients.ts index ef3ed78..d4426fa 100644 --- a/src/bot/managed-voice-clients.ts +++ b/src/bot/managed-voice-clients.ts @@ -80,8 +80,17 @@ 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 owned by bot connections in this process. + * 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 @@ -92,15 +101,21 @@ export class ManagedVoiceClientRegistry { string, Map >(); + private readonly ownersByClientUid = new Map< + string, + Set + >(); /** - * Register (or replace) the connection that owns a client id. + * 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; @@ -111,6 +126,16 @@ export class ManagedVoiceClientRegistry { 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; } @@ -124,16 +149,26 @@ export class ManagedVoiceClientRegistry { 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 || clients.get(clientId) !== ownerToken) return false; + if (clients?.get(clientId) === ownerToken) { + clients.delete(clientId); + if (clients.size === 0) this.clientsByScope.delete(key); + removed = true; + } - clients.delete(clientId); - if (clients.size === 0) this.clientsByScope.delete(key); - return 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 { @@ -141,4 +176,12 @@ export class ManagedVoiceClientRegistry { 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/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 5f6aec3..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, @@ -75,8 +76,15 @@ export interface TS3TextMessage { 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 @@ -97,7 +105,13 @@ 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"; @@ -114,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(). */ @@ -128,6 +143,7 @@ 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"); @@ -245,9 +261,11 @@ export class TS3Client extends EventEmitter { // 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); }); @@ -255,10 +273,12 @@ export class TS3Client extends EventEmitter { 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" @@ -267,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); }); @@ -461,6 +482,47 @@ export class TS3Client extends EventEmitter { 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; @@ -473,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/web/src/views/Settings.vue b/web/src/views/Settings.vue index c6b43f6..dc9207f 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -787,7 +787,7 @@ v-model="voiceDuckingEnabled" type="checkbox" class="profile-toggle-switch" - :disabled="voiceDuckingSaving" + :disabled="voiceDuckingControlsDisabled" @change="saveVoiceDucking" /> @@ -808,7 +808,7 @@ max="100" step="0.1" class="voice-ducking-range" - :disabled="voiceDuckingSaving" + :disabled="voiceDuckingControlsDisabled" aria-label="说话时保留原音量百分比" />
@@ -820,17 +820,24 @@ step="0.1" class="input input-sm" style="max-width:80px" - :disabled="voiceDuckingSaving" + :disabled="voiceDuckingControlsDisabled" aria-label="说话时保留原音量百分比" @blur="normalizeVoiceDuckingVolume" /> % -
-

+

{{ voiceDuckingMessage }}

@@ -1655,10 +1662,15 @@ const autoPauseOnEmpty = ref(false); // Voice ducking defaults OFF and retains 30% of the configured player volume. const voiceDuckingEnabled = ref(false); const voiceDuckingVolumePercent = ref(30); +const voiceDuckingLoaded = ref(false); const voiceDuckingSaving = ref(false); +const voiceDuckingControlsDisabled = computed( + () => !voiceDuckingLoaded.value || voiceDuckingSaving.value, +); const voiceDuckingMessage = ref(''); const voiceDuckingMessageTone = ref<'ok' | 'warn'>('ok'); let savedVoiceDucking = { enabled: false, volumePercent: 30 }; +let voiceDuckingRequestRevision = 0; const localAudioEnabled = ref(true); // Saved-queues + play-keeps-queue toggles (#119), both default OFF. const savedQueuesEnabled = ref(false); @@ -1688,11 +1700,18 @@ function applyVoiceDuckingConfig(config: unknown) { } async function loadIdleTimeout() { + const voiceDuckingLoadRevision = voiceDuckingRequestRevision; try { const res = await axios.get('/api/bot/settings'); idleTimeout.value = res.data.idleTimeoutMinutes ?? 0; autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false; - applyVoiceDuckingConfig(res.data.voiceDucking ?? { enabled: false, volumePercent: 30 }); + // A later save owns the state. Do not let an older GET response overwrite + // it if this loader is ever re-entered while a POST is in flight. + if (voiceDuckingLoadRevision === voiceDuckingRequestRevision) { + applyVoiceDuckingConfig(res.data.voiceDucking ?? { enabled: false, volumePercent: 30 }); + voiceDuckingLoaded.value = true; + voiceDuckingMessage.value = ''; + } localAudioEnabled.value = res.data.localAudioEnabled ?? true; savedQueuesEnabled.value = res.data.savedQueuesEnabled ?? false; playKeepsQueue.value = res.data.playKeepsQueue ?? false; @@ -1708,7 +1727,12 @@ async function loadIdleTimeout() { } // null (unset) → "" so the select shows "自动(按优先级)". defaultPlatformForm.value = res.data.defaultPlatform ?? ''; - } catch { /* ignore */ } + } catch { + if (!voiceDuckingLoaded.value) { + voiceDuckingMessageTone.value = 'warn'; + voiceDuckingMessage.value = '语音闪避设置加载失败,请刷新页面重试'; + } + } } async function saveIdleTimeout() { @@ -1724,8 +1748,9 @@ async function saveAutoPause() { } async function saveVoiceDucking() { - if (voiceDuckingSaving.value) return; + if (!voiceDuckingLoaded.value || voiceDuckingSaving.value) return; voiceDuckingSaving.value = true; + voiceDuckingRequestRevision++; voiceDuckingMessage.value = ''; const submitted = { enabled: voiceDuckingEnabled.value,