From b9a934735670ebd544e377e3bb3fc6a284b92f1a Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 11:07:31 +0100 Subject: [PATCH] Fix: Separate voice groups so radio + soundboard play in parallel Each plugin now uses its own @discordjs/voice group: - Radio: group='radio' - Soundboard: group='soundboard' This prevents joinVoiceChannel from one bot overwriting the other bot's connection. Both bots can now play simultaneously in the same voice channel. Removed claimVoice system (not needed with separate bots). Co-Authored-By: Claude Opus 4.6 --- server/src/plugins/radio/index.ts | 10 +++------- server/src/plugins/soundboard/index.ts | 21 +++++++++------------ 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/server/src/plugins/radio/index.ts b/server/src/plugins/radio/index.ts index bad1fa0..54482a6 100644 --- a/server/src/plugins/radio/index.ts +++ b/server/src/plugins/radio/index.ts @@ -7,7 +7,6 @@ import { import type { VoiceBasedChannel } from 'discord.js'; import { ChannelType } from 'discord.js'; import type { Plugin, PluginContext } from '../../core/plugin.js'; -import { claimVoice, releaseVoice } from '../../core/plugin.js'; import { sseBroadcast } from '../../core/sse.js'; import { getState, setState } from '../../core/persistence.js'; import { @@ -67,9 +66,8 @@ function stopStream(guildId: string): void { if (!state) return; try { state.ffmpeg.kill('SIGKILL'); } catch {} try { state.player.stop(true); } catch {} - try { getVoiceConnection(guildId)?.destroy(); } catch {} + try { getVoiceConnection(guildId, 'radio')?.destroy(); } catch {} guildRadioState.delete(guildId); - releaseVoice(guildId, 'radio'); broadcastState(guildId); console.log(`[Radio] Stopped stream in guild ${guildId}`); } @@ -97,6 +95,7 @@ async function startStream( guildId, adapterCreator: guild.voiceAdapterCreator, selfDeaf: true, + group: 'radio', }); try { @@ -128,7 +127,7 @@ async function startStream( if (guildRadioState.has(guildId)) { console.log(`[Radio] ffmpeg exited (code ${code}), cleaning up`); guildRadioState.delete(guildId); - try { connection.destroy(); } catch {} + try { getVoiceConnection(guildId, 'radio')?.destroy(); } catch {} broadcastState(guildId); } }); @@ -150,9 +149,6 @@ async function startStream( stopStream(guildId); }); - // Claim voice for this guild (stops other plugins like soundboard) - claimVoice(guildId, 'radio', () => stopStream(guildId)); - // State tracken const channelName = 'name' in channel ? (channel as any).name : voiceChannelId; guildRadioState.set(guildId, { diff --git a/server/src/plugins/soundboard/index.ts b/server/src/plugins/soundboard/index.ts index 75bb3c3..70020c4 100644 --- a/server/src/plugins/soundboard/index.ts +++ b/server/src/plugins/soundboard/index.ts @@ -16,7 +16,6 @@ import sodium from 'libsodium-wrappers'; import nacl from 'tweetnacl'; import { ChannelType, Events, type VoiceState, type Message } from 'discord.js'; import type { Plugin, PluginContext } from '../../core/plugin.js'; -import { claimVoice } from '../../core/plugin.js'; import { sseBroadcast } from '../../core/sse.js'; // ── Config (env) ── @@ -239,7 +238,7 @@ async function ensureConnectionReady(connection: VoiceConnection, channelId: str try { connection.destroy(); } catch {} guildAudioState.delete(guildId); console.log(`${SB} Creating fresh connection (attempt 3)...`); - const newConn = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false }); + const newConn = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' }); newConn.on('stateChange', (o: any, n: any) => console.log(`${SB} [fresh-conn] ${o.status} → ${n.status}`)); newConn.on('error', (err: any) => console.error(`${SB} [fresh-conn] ERROR: ${err?.message ?? err}`)); try { await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (fresh)`); return newConn; } @@ -270,13 +269,13 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) { catch { if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { reconnectAttempts++; console.log(`${SB} Rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); } else { reconnectAttempts = 0; console.log(`${SB} Max reconnect attempts reached, creating fresh connection`); try { connection.destroy(); } catch {} - const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false }); + const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' }); state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); } } } else if (newS.status === VoiceConnectionStatus.Destroyed) { console.warn(`${SB} Connection destroyed, recreating...`); connectedSince.delete(state.guildId); - const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false }); + const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' }); state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); } else if (newS.status === VoiceConnectionStatus.Connecting || newS.status === VoiceConnectionStatus.Signalling) { isReconnecting = true; @@ -287,7 +286,7 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) { console.warn(`${SB} Timeout waiting for Ready from ${newS.status} (attempt ${reconnectAttempts}): ${(e as Error)?.message ?? e}`); if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { await new Promise(r => setTimeout(r, reconnectAttempts * 2000)); isReconnecting = false; connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); } else { reconnectAttempts = 0; isReconnecting = false; console.error(`${SB} Max attempts from ${newS.status}, fresh connection`); try { connection.destroy(); } catch {} - const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false }); + const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' }); state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); } } } @@ -333,8 +332,6 @@ let _pluginCtx: PluginContext | null = null; async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise { console.log(`${SB} playFilePath: guild=${guildId} channel=${channelId} file=${path.basename(filePath)} vol=${volume ?? 'default'}`); - // Claim voice for this guild (stops radio if playing) - claimVoice(guildId, 'soundboard', () => { /* soundboard cleanup handled by lifecycle */ }); const ctx = _pluginCtx!; const guild = ctx.client.guilds.cache.get(guildId); if (!guild) { console.error(`${SB} Guild ${guildId} not found in cache (cached: ${ctx.client.guilds.cache.map(g => g.id).join(', ')})`); throw new Error('Guild nicht gefunden'); } @@ -342,7 +339,7 @@ async function playFilePath(guildId: string, channelId: string, filePath: string let state = guildAudioState.get(guildId); if (!state) { console.log(`${SB} No existing audio state, creating new connection...`); - const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false }); + const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' }); // Debug: catch ALL state transitions and errors from the start connection.on('stateChange', (o: any, n: any) => { console.log(`${SB} [conn] ${o.status} → ${n.status}`); @@ -364,10 +361,10 @@ async function playFilePath(guildId: string, channelId: string, filePath: string // Channel-Wechsel try { - const current = getVoiceConnection(guildId); + const current = getVoiceConnection(guildId, 'soundboard'); if (current && current.joinConfig?.channelId !== channelId) { current.destroy(); - const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false }); + const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' }); const player = state.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } }); connection.subscribe(player); state = { connection, player, guildId, channelId, currentVolume: state.currentVolume ?? getPersistedVolume(guildId) }; @@ -377,8 +374,8 @@ async function playFilePath(guildId: string, channelId: string, filePath: string } } catch {} - if (!getVoiceConnection(guildId)) { - const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false }); + if (!getVoiceConnection(guildId, 'soundboard')) { + const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' }); const player = state?.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } }); connection.subscribe(player); state = { connection, player, guildId, channelId, currentVolume: state?.currentVolume ?? getPersistedVolume(guildId) };