import { debugLog, waitForEvent } from '../utils.js' import config from '../../config.js' import constants from '../../constants.js' import sources from '../sources.js' import Filters from '../filters.js' import inputHandler from './inputHandler.js' import voiceUtils from '../voice/utils.js' import discordVoice from '@performanc/voice' globalThis.nodelinkPlayersCount = 0 globalThis.nodelinkPlayingPlayersCount = 0 class VoiceConnection { constructor(guildId, client) { nodelinkPlayersCount++ this.client = { userId: client.userId, ws: client.ws } this.cache = { url: null, protocol: null, track: null } this.config = { guildId, track: null, volume: 100, paused: false, filters: {}, voice: { token: null, endpoint: null, sessionId: null } } this._setupVoice() } _setupVoice() { this.connection = discordVoice.joinVoiceChannel({ guildId: this.config.guildId, userId: this.client.userId, encryption: config.audio.encryption }) this.connection.on('speakStart', (userId, ssrc) => inputHandler.handleStartSpeaking(ssrc, userId, this.config.guildId)) this.connection.on('stateChange', async (oldState, newState) => { switch (newState.status) { case 'disconnected': { debugLog('websocketClosed', 2, { track: this.config.track?.info, exception: constants.VoiceWSCloseCodes[newState.closeCode] }) this.connection.destroy() this.connection = null this._stopTrack() this.client.ws.send(JSON.stringify({ op: 'event', type: 'WebSocketClosedEvent', guildId: this.config.guildId, code: newState.code, reason: constants.VoiceWSCloseCodes[newState.code], byRemote: true })) break } } }) this.connection.on('playerStateChange', (_oldState, newState) => { if (newState.status === 'idle' && [ 'stopped', 'finished' ].includes(newState.reason)) { nodelinkPlayingPlayersCount-- debugLog('trackEnd', 2, { track: this.config.track.info, reason: newState.reason }) this.client.ws.send(JSON.stringify({ op: 'event', type: 'TrackEndEvent', guildId: this.config.guildId, track: this.config.track, reason: newState.reason })) this._stopTrack() } if (newState.status === 'playing' && newState.reason === 'requested') { nodelinkPlayingPlayersCount++ debugLog('trackStart', 2, { track: this.config.track.info }) this.client.ws.send(JSON.stringify({ op: 'event', type: 'TrackStartEvent', guildId: this.config.guildId, track: this.config.track })) } }) this.connection.on('error', (error) => { debugLog('trackException', 2, { track: this.config.track?.info, exception: error.message }) this.client.ws.send(JSON.stringify({ op: 'event', type: 'TrackExceptionEvent', guildId: this.config.guildId, track: this.config.track, exception: { message: error.message, severity: 'fault', cause: `${error.name}: ${error.message}` } })) this.client.ws.send(JSON.stringify({ op: 'event', type: 'TrackEndEvent', guildId: this.config.guildId, track: this.config.track, reason: 'loadFailed' })) this._stopTrack() }) } _stopTrack() { this.cache = { url: null, protocol: null, track: null } this.config = { ...this.config, track: null, paused: false } } _getRealTime() { return this.connection.statistics.packetsExpected * 20 } updateVoice(buffer) { this.config.voice = buffer if (!this.connection) this._setupVoice() this.connection.voiceStateUpdate({ session_id: buffer.sessionId }) this.connection.voiceServerUpdate({ token: buffer.token, endpoint: buffer.endpoint }) if (!this.connection.ws) this.connection.connect() } destroy() { if (this.connection) { this.connection.destroy() this.connection = null } this._stopTrack() } async getResource(decodedTrack, urlInfo) { const streamInfo = await sources.getTrackStream(decodedTrack, urlInfo.url, urlInfo.protocol, urlInfo.additionalData) if (streamInfo.exception) return streamInfo return { stream: voiceUtils.createAudioResource(streamInfo.stream, urlInfo.format) } } async play(track, decodedTrack, noReplace) { if (noReplace && this.config.track) return this.config const urlInfo = await sources.getTrackURL(decodedTrack) if (urlInfo.exception) { this._stopTrack() this.client.ws.send(JSON.stringify({ op: 'event', type: 'TrackExceptionEvent', guildId: this.config.guildId, track: { encoded: track, info: decodedTrack }, exception: urlInfo.exception })) this.client.ws.send(JSON.stringify({ op: 'event', type: 'TrackEndEvent', guildId: this.config.guildId, track: { encoded: track, info: decodedTrack, userData: this.config.track?.userData }, reason: 'loadFailed' })) return this.config } if (this.config.track?.encoded) { debugLog('trackEnd', 2, { track: this.config.track.info, reason: 'replaced' }) this.client.ws.send(JSON.stringify({ op: 'event', type: 'TrackEndEvent', guildId: this.config.guildId, track: this.config.track, reason: 'replaced' })) debugLog('trackStart', 2, { track: decodedTrack }) this.client.ws.send(JSON.stringify({ op: 'event', type: 'TrackStartEvent', guildId: this.config.guildId, track: { encoded: track, info: decodedTrack, userData: this.config.track?.userData } })) } let resource = null if (Object.keys(this.config.filters).length > 0) { const filter = new Filters() this.config.filters = filter.configure(this.config.filters) resource = await filter.getResource(decodedTrack, urlInfo.protocol, urlInfo.url, null, null, this.cache.ffmpeg, urlInfo.additionalData) } else { resource = await this.getResource(decodedTrack, urlInfo) } if (resource.exception) { this._stopTrack() debugLog('trackException', 2, { track: decodedTrack, exception: resource.exception.message }) this.client.ws.send(JSON.stringify({ op: 'event', type: 'TrackExceptionEvent', guildId: this.config.guildId, track: { encoded: track, info: decodedTrack, userData: this.config.track?.userData }, exception: resource.exception })) this.client.ws.send(JSON.stringify({ op: 'event', type: 'TrackEndEvent', guildId: this.config.guildId, track: { encoded: track, info: decodedTrack, userData: this.config.track?.userData }, reason: 'loadFailed' })) return this.config } this.cache.url = urlInfo.url this.cache.protocol = urlInfo.protocol this.config.track = { encoded: track, info: decodedTrack } this.config.paused = false if (this.config.volume !== 100) resource.stream.setVolume(this.config.volume / 100) if (!this.connection) return this.config if (!this.connection.udpInfo?.secretKey) await waitForEvent(this.connection, 'stateChange', (_oldState, newState) => newState.status === 'connected', config.options.threshold || undefined) const oldResource = this.connection.audioStream this.connection.play(resource.stream) if (oldResource) resource.stream.once('readable', () => oldResource.destroy()) return this.config } stop() { if (!this.config.track) return this.config if (this.connection.audioStream) this.connection.stop() else this._stopTrack() } volume(volume) { if (this.connection.audioStream) this.connection.audioStream.setVolume(volume / 100) this.config.volume = volume return this.config } pause(pause) { if (this.connection.audioStream) { if (pause) this.connection.pause() else this.connection.unpause() } this.config.paused = pause return this.config } async filters(filters) { if (!this.config.track?.encoded || !config.filters.enabled) return this.config const filter = new Filters() this.config.filters = filter.configure(filters, this.config.track.info) if (!this.config.track) return this.config const resource = await filter.getResource(this.config.track.info, this.cache.protocol, this.cache.url, this._getRealTime(), filters.endTime, null, null) if (resource.exception) { this._stopTrack() this.client.ws.send(JSON.stringify({ op: 'event', type: 'TrackExceptionEvent', guildId: this.config.guildId, track: this.config.track, exception: resource.exception })) this.client.ws.send(JSON.stringify({ op: 'event', type: 'TrackEndEvent', guildId: this.config.guildId, track: this.config.track, reason: 'loadFailed' })) return this.config } resource.stream.setVolume(filters.volume || (this.config.volume / 100)) this.config.volume = (filters.volume * 100) || this.config.volume if (!this.connection) return this.config if (!this.connection.udpInfo?.secretKey) await waitForEvent(this.connection, 'stateChange', (_oldState, newState) => newState.status === 'connected', config.options.threshold || undefined) this.connection.play(resource.stream) return this.config } } export default VoiceConnection