diff --git a/composer.json b/composer.json index efa1f1181..2bf94f1f9 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,8 @@ "react/async": "^4.0 || ^3.0", "react/cache": "^0.5 || ^0.6 || ^1.0", "react/promise": "^3.0.0", - "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "discord-php-helpers/voice": "dev-main" }, "require-dev": { "symfony/var-dumper": "*", diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index 5b0dc96d2..f4d41a231 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -1527,7 +1527,7 @@ protected function voiceServerUpdate(VoiceServerUpdate $vs, Channel $channel, ar $data['dnsConfig'] = $this->options['dnsConfig']; $this->logger->info('received token and endpoint for voice session', ['guild' => $channel->guild_id, 'token' => $vs->token, 'endpoint' => $vs->endpoint]); - $vc = new VoiceClient($this, $this->ws, $this->voice_sessions, $channel, $data); + $vc = new VoiceClient($this, $channel, $data); $vc->once('ready', function () use ($vc, $deferred, $channel) { $this->logger->info('voice client is ready'); diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php deleted file mode 100644 index 3c7b734c0..000000000 --- a/src/Discord/Voice/VoiceClient.php +++ /dev/null @@ -1,2087 +0,0 @@ - - * - * This file is subject to the MIT license that is bundled - * with this source code in the LICENSE.md file. - */ - -namespace Discord\Voice; - -use Discord\Discord; -use Discord\Exceptions\FFmpegNotFoundException; -use Discord\Exceptions\FileNotFoundException; -use Discord\Exceptions\LibSodiumNotFoundException; -use Discord\Helpers\Buffer as RealBuffer; -use Discord\Helpers\Collection; -use Discord\Parts\Channel\Channel; -use Discord\Parts\Guild\Guild; -use Discord\Parts\WebSockets\VoiceStateUpdate; -use Discord\WebSockets\Payload; -use Discord\WebSockets\Op; -use Evenement\EventEmitter; -use Ratchet\Client\Connector as WsFactory; -use Ratchet\Client\WebSocket; -use React\Datagram\Factory as DatagramFactory; -use React\Datagram\Socket; -use React\Dns\Config\Config; -use React\Dns\Resolver\Factory as DNSFactory; -use React\ChildProcess\Process; -use React\Promise\Deferred; -use React\Promise\PromiseInterface; -use React\Stream\ReadableResourceStream as Stream; -use React\EventLoop\TimerInterface; -use React\Stream\ReadableResourceStream; -use React\Stream\ReadableStreamInterface; - -use function React\Promise\reject; -use function React\Promise\resolve; - -/** - * The Discord voice client. - * - * @link https://discord.com/developers/docs/topics/voice-connections - * - * @since 3.2.0 - */ -class VoiceClient extends EventEmitter -{ - /** Not speaking. */ - public const NOT_SPEAKING = 0; - /** Normal transmission of voice audio. */ - public const MICROPHONE = 1 << 0; - /** Transmission of context audio for video, no speaking indicator. */ - public const SOUNDSHARE = 1 << 1; - /** Priority speaker, lowering audio of other speakers. */ - public const PRIORITY_SPEAKER = 1 << 2; - - /** - * The Opus Silence Frame. - * - * @link https://discord.com/developers/docs/topics/voice-connections#voice-data-interpolation - * - * @var string The silence frame. - */ - public const SILENCE_FRAME = "\xF8\xFF\xFE"; - - /** - * Supported encryption modes for voice connections. - * - * @link https://discord.com/developers/docs/topics/voice-connections#transport-encryption-modes - * - * @var string[] The supported transport encryption modes. - */ - public const SUPPORTED_MODES = [ - 'aead_aes256_gcm_rtpsize', - 'aead_xchacha20_poly1305_rtpsize', - ]; - - /** - * Maximum DAVE protocol version supported. - * - * @link https://discord.com/developers/docs/topics/voice-connections#endtoend-encryption-dave-protocol - * - * @var int The maximum DAVE protocol version supported. - */ - public const MAX_DAVE_PROTOCOL_VERSION = 0; - - /** - * Dispatch table mapping Discord Voice Gateway opcodes to handler methods. - * - * @var array Method name indexed by opcode constant. - */ - public const VOICE_OP_HANDLERS = [ - Op::VOICE_READY => 'handleReady', - Op::VOICE_SESSION_DESCRIPTION => 'handleSessionDescription', - Op::VOICE_SPEAKING => 'handleSpeaking', - Op::VOICE_HEARTBEAT_ACK => 'heartbeatAck', - Op::VOICE_HELLO => 'handleHello', - Op::VOICE_RESUMED => 'handleResumed', - Op::VOICE_CLIENT_CONNECT => 'handleClientConnect', - Op::VOICE_CLIENT_DISCONNECT => 'handleClientDisconnect', - Op::VOICE_CLIENT_UNKNOWN_15 => 'handleAny', - Op::VOICE_CLIENT_UNKNOWN_18 => 'handleFlags', - Op::VOICE_CLIENT_PLATFORM => 'handlePlatform', - Op::VOICE_DAVE_PREPARE_TRANSITION => 'handleDavePrepareTransition', - Op::VOICE_DAVE_EXECUTE_TRANSITION => 'handleDaveExecuteTransition', - Op::VOICE_DAVE_TRANSITION_READY => 'handleDaveTransitionReady', - Op::VOICE_DAVE_PREPARE_EPOCH => 'handleDavePrepareEpoch', - Op::VOICE_DAVE_MLS_EXTERNAL_SENDER => 'handleDaveMlsExternalSender', - Op::VOICE_DAVE_MLS_KEY_PACKAGE => 'handleDaveMlsKeyPackage', - Op::VOICE_DAVE_MLS_PROPOSALS => 'handleDaveMlsProposals', - Op::VOICE_DAVE_MLS_COMMIT_WELCOME => 'handleDaveMlsCommitWelcome', - Op::VOICE_DAVE_MLS_ANNOUNCE_COMMIT_TRANSITION => 'handleDaveMlsAnnounceCommitTransition', - Op::VOICE_DAVE_MLS_WELCOME => 'handleDaveMlsWelcome', - Op::VOICE_DAVE_MLS_INVALID_COMMIT_WELCOME => 'handleDaveMlsInvalidCommitWelcome', - - Op::CLOSE_VOICE_DISCONNECTED => 'handleCloseVoiceDisconnected', - ]; - - /** - * The Discord client. - * - * @var Discord The Discord client. - */ - protected $discord; - - /** - * Is the voice client ready? - * - * @var bool Whether the voice client is ready. - */ - protected $ready = false; - - /** - * The FFmpeg binary location. - * - * @var string - */ - protected $ffmpeg; - - /** - * The array of voice session IDs. - * - * @var string[] The voice sessions. - */ - protected $voice_sessions; - - /** - * The main WebSocket instance. - * - * @var WebSocket The main WebSocket client. - */ - protected $mainWebsocket; - - /** - * The voice WebSocket instance. - * - * @var WebSocket The voice WebSocket client. - */ - protected $voiceWebsocket; - - /** - * The UDP client. - * - * @var Socket The voiceUDP client. - */ - public $client; - - /** - * The MLS Group for handling multi-user encryption. - * - * @var VoiceGroupCrypto The MLS Group instance. - */ - public VoiceGroupCryptoInterface $crypto; - - /** - * The Channel that we are connecting to. - * - * @var string The channel that we are connecting to. - */ - protected $channel_id; - - /** - * The ID of the Channel's Guild. - * - * @var string The ID of the channel's guild. - */ - protected $guild_id; - - /** - * Data from the main WebSocket. - * - * @var array Information required for the voice WebSocket. - */ - protected $data; - - /** - * The Voice WebSocket endpoint. - * - * @var string The endpoint the Voice WebSocket and UDP client will connect to. - */ - protected $endpoint; - - /** - * The Voice connection protocol. - * - * @var string The protocol to use for the voice connection. - */ - protected $protocol = 'udp'; - - /** - * The IP the UDP client will use. - * - * @var string The IP that the UDP client will connect to. - */ - protected $udpIp; - - /** - * The port the UDP client will use. - * - * @var int The port that the UDP client will connect to. - */ - protected $udpPort; - - /** - * The supported transport encryption modes the voice server expects. - * - * @var string[] The supported transport encryption modes. - */ - protected $supportedModes; - - /** - * The UDP heartbeat interval. - * - * @var int How often we send a heartbeat packet. - */ - protected $heartbeat_interval; - - /** - * The Voice WebSocket heartbeat timer. - * - * @var TimerInterface The heartbeat periodic timer. - */ - protected $heartbeat; - - /** - * The UDP heartbeat timer. - * - * @var TimerInterface The heartbeat periodic timer. - */ - protected $udpHeartbeat; - - /** - * The UDP heartbeat sequence. - * - * @var int The heartbeat sequence. - */ - protected $heartbeatSeq = 0; - - /** - * The SSRC value. - * - * @var int The SSRC value used for RTP. - */ - public $ssrc; - - /** - * The sequence of audio packets being sent. - * - * @var int The sequence of audio packets. - */ - protected $seq = 0; - - /** - * The timestamp of the last packet. - * - * @var float The timestamp the last packet was constructed. - */ - protected $timestamp = 0.0; - - /** - * The Voice WebSocket mode. - * - * @var string The transport encryption mode. - */ - protected $mode = 'aead_aes256_gcm_rtpsize'; - - /** - * The secret key used for encrypting voice. - * - * @var string The secret key. - */ - protected $secret_key; - - /** - * The speaking mode. - * - * @var int The speaking mode. - */ - protected $speaking = self::NOT_SPEAKING; - - /** - * Whether we are set as mute. - * - * @var bool Whether we are set as mute. - */ - protected $mute = false; - - /** - * Whether we are set as deaf. - * - * @var bool Whether we are set as deaf. - */ - protected $deaf = false; - - /** - * Whether the voice client is currently paused. - * - * @var bool Whether the voice client is currently paused. - */ - protected $paused = false; - - /** - * Have we sent the login frame yet? - * - * @var bool Whether we have sent the login frame. - */ - protected $sentLoginFrame = false; - - /** - * The time we started sending packets. - * - * @var float|null The time we started sending packets. - */ - protected $startTime; - - /** - * The stream time of the last packet. - * - * @var float The time we sent the last packet. - */ - protected $streamTime = 0.0; - - /** - * The size of audio frames, in milliseconds. - * - * @var int The size of audio frames. - */ - protected $frameSize = 20; - - /** - * Collection of the status of people speaking. - * - * @var ExCollectionInterface|Speaking[] Status of people speaking. - */ - public $speakingStatus; - - /** - * Array of voice decoders. - * - * @var Process[] Voice decoders. - */ - public $voiceDecoders; - - /** - * Voice audio receive streams. - * - * @var ReceiveStream[] Voice audio receive streams. - */ - public $receiveStreams = []; - - /** - * The volume the audio will be encoded with. - * - * @var int The volume that the audio will be encoded in. - */ - protected $volume = 100; - - /** - * The audio application to encode with. - * - * Available: voip, audio (default), lowdelay - * - * @var string The audio application. - */ - protected $audioApplication = 'audio'; - - /** - * Is the voice client reconnecting? - * - * @var bool Whether the voice client is reconnecting. - */ - protected $reconnecting = false; - - /** - * Is the voice client being closed by user? - * - * @var bool Whether the voice client is being closed by user. - */ - protected $userClose = false; - - /** - * The Discord voice gateway version. - * - * @var int Voice version. - */ - protected $version = 8; - - /** - * The configuration for DNS Resolver. - * - * @var Config|string The DNS configuration. - */ - protected $dnsConfig; - - /** - * Silence Frame Remain Count. - * - * @var int Amount of silence frames remaining. - */ - protected $silenceRemaining = 5; - - /** - * readopus Timer. - * - * @var ?TimerInterface Timer - */ - protected $readOpusTimer = null; - - /** - * Audio Buffer. - * - * @var RealBuffer The Audio Buffer - */ - protected $buffer; - - /** - * Constructs the Voice Client instance. - * - * @param Discord &$discord The Discord client. - * @param WebSocket &$websocket The main WebSocket client. - * @param Channel|string $channel The channel we are connecting to. - * @param array $data More information related to the voice client. - * - * @throws \InvalidArgumentException If connecting to a voice channel that could not be found, or if that channel is not in a guild. - */ - public function __construct(Discord &$discord, WebSocket &$websocket, array &$voice_sessions, $channel, array $data) - { - $this->discord = $discord; - $this->mainWebsocket = $websocket; - $this->voice_sessions = $voice_sessions; - if (! is_string($channel)) { - $channel = $channel->id; - } - if (! $channel = $this->discord->getChannel($channel)) { - throw new \InvalidArgumentException('Could not retrieve voice channel to connect to: '.$channel); - } - static $allowed = [Channel::TYPE_GUILD_VOICE, Channel::TYPE_GUILD_STAGE_VOICE]; - if (! in_array($channel->type, $allowed) || ! $channel->guild_id) { - throw new \InvalidArgumentException('Channel must be a guild voice or stage channel: '.$channel->id); - } - $this->channel_id = $channel->id; - $this->guild_id = $channel->guild_id; - - $this->data = $data; - $this->deaf = $data['deaf']; - $this->mute = $data['mute']; - $this->endpoint = str_replace([':80', ':443'], '', $data['endpoint']); - $this->speakingStatus = new Collection([], 'ssrc'); - $this->dnsConfig = $data['dnsConfig']; - } - - /** - * Closes the voice client when destroyed. - */ - public function __destruct() - { - $this->close(); - } - - /** - * Sets the transport encryption mode for the voice client. - * - * @param string $mode The transport encryption mode to set for the voice client. - * - * @throws \InvalidArgumentException If the provided mode is not supported. - */ - public function setMode(string $mode): void - { - if (! in_array($mode, self::SUPPORTED_MODES)) { - $this->discord->getLogger()->error("{$mode} is either a deprecated or unsupported transport encryption connection mode. Please use a supported mode: ".implode(', ', self::SUPPORTED_MODES)); - - throw new \InvalidArgumentException("Invalid transport encryption mode: {$mode}"); - } - - $this->mode = $mode; - $this->crypto = new VoiceGroupCrypto('', $mode); - } - - /** - * Starts the voice client. - * - * @return bool|void - */ - public function start() - { - if ( - ! $this->checkForFFmpeg() || - ! $this->checkForLibsodium() - ) { - return false; - } - - $this->initSockets(); - } - - /** - * Initilizes the WebSocket and UDP socket. - */ - public function initSockets(): void - { - $wsfac = new WsFactory(); - /** @var PromiseInterface */ - $promise = $wsfac("wss://{$this->endpoint}?v={$this->version}"); - - $promise->then([$this, 'handleWebSocketConnection'], [$this, 'handleWebSocketError']); - } - - /** - * Sends an identify payload to the voice gateway to authenticate the client. - * - * @link https://discord.com/developers/docs/topics/voice-connections#establishing-a-voice-websocket-connection-example-voice-identify-payload - * - * @since 10.19.0 - */ - protected function identify(): void - { - $data = [ - 'server_id' => $this->guild_id, - 'user_id' => $this->data['user_id'], - 'token' => $this->data['token'], - 'max_dave_protocol_version' => self::MAX_DAVE_PROTOCOL_VERSION, - ]; - if (isset($this->voice_sessions[$this->guild_id])) { - $data['session_id'] = $this->voice_sessions[$this->guild_id]; - } - - $payload = Payload::new( - Op::VOICE_IDENTIFY, - $data - ); - - $this->discord->getLogger()->debug('sending identify', ['packet' => $payload]); - - $this->send($payload); - } - - /** - * Sends a heartbeat payload to the voice server to maintain the connection. - * - * @link https://discord.com/developers/docs/topics/voice-connections#heartbeating - * - * @since 10.19.0 - */ - protected function heartbeat(): void - { - $payload = Payload::new( - Op::VOICE_HEARTBEAT, - [ - 't' => (int) microtime(true), - 'seq_ack' => $this->data['seq'] ?? -1, - ] - ); - - $this->discord->getLogger()->debug('sending heartbeat', ['packet' => $payload]); - - $this->send($payload); - - $this->emit('ws-heartbeat', []); - } - - /** - * Handles the heartbeat acknowledgement from the voice WebSocket connection. - * - * @param Payload $data - * - * @since 10.19.0 - */ - protected function heartbeatAck($data): void - { - $diff = (microtime(true) - $data->d['t']) * 1000; - - $this->discord->getLogger()->debug('received heartbeat ack', ['response_time' => $diff]); - $this->emit('ws-ping', [$diff]); - $this->emit('ws-heartbeat-ack', [$data->d]); - } - - /** - * Handles the "Hello" event from the Discord voice server. - * - * @param Payload $data - * - * @since 10.19.0 - */ - protected function handleHello($data): void - { - /** @var Hello */ - $hello = $this->discord->factory(Hello::class, (array) $data->d, true); - - $this->heartbeat_interval = $hello->heartbeat_interval; - $this->heartbeat(); - $this->heartbeat = $this->discord->getLoop()->addPeriodicTimer($this->heartbeat_interval / 1000, fn () => $this->heartbeat()); - } - - /** - * Selects the UDP protocol for the voice connection and sends the selection payload. - * - * @param string $ip The IP address to use for the voice connection. - * @param int $port The port number to use for the voice connection. - * - * @since 10.19.0 - */ - protected function selectProtocol($ip, $port): void - { - if (! in_array($this->mode, $this->supportedModes)) { - $this->discord->getLogger()->warning("{$this->mode} is not a valid transport encryption connection mode. Valid modes are: ".implode(', ', $this->supportedModes)); - $fallback = $this->supportedModes[0]; - $this->discord->getLogger()->info('Switching voice transport encryption mode to: '.$fallback); - $this->setMode($fallback); - } - - $payload = Payload::new( - Op::VOICE_SELECT_PROTOCOL, - [ - 'protocol' => $this->protocol, - 'data' => [ - 'address' => $ip, - 'port' => (int) $port, - 'mode' => $this->mode, - ], - ] - ); - - $this->discord->getLogger()->debug('sending voice select protocol', ['packet' => $payload]); - - $this->send($payload); - } - - /** - * Handles the "ready" event for the voice client, initializing UDP connection and heartbeat. - * - * @param Payload $data The data object containing voice server connection details: - * - $data->d['ssrc']: The synchronization source identifier. - * - $data->d['ip']: The IP address for the UDP connection. - * - $data->d['port']: The port for the UDP connection. - * - $data->d['modes']: Supported encryption modes. - * - * @since 10.19.0 - */ - protected function handleReady(object $data): void - { - /** @var Ready */ - $ready = $this->discord->factory(Ready::class, (array) $data->d, true); - - $this->ssrc = $ready->ssrc; - $this->udpIp = $ready->ip; - $this->udpPort = $ready->port; - $this->supportedModes = $ready->modes; - - $this->discord->getLogger()->debug('received voice ready packet', ['data' => $ready]); - - $udpfac = new DatagramFactory(null, (new DNSFactory())->createCached($this->dnsConfig)); - $udpfac->createClient("{$this->udpIp}:".$this->udpPort)->then(function (Socket $client): void { - $this->client = $client; - - $buffer = new Buffer(74); - $buffer[1] = "\x01"; - $buffer[3] = "\x46"; - $buffer->writeUInt32BE($this->ssrc, 4); - - $this->udpHeartbeat = $this->discord->getLoop()->addPeriodicTimer(5, function () { - $buffer = new Buffer(9); - $buffer[0] = "\xC9"; - $buffer->writeUInt64LE($this->heartbeatSeq, 1); - ++$this->heartbeatSeq; - - $this->client->send((string) $buffer); - $this->discord->getLogger()->debug('sent udp heartbeat', ['seq' => $this->heartbeatSeq]); - $this->emit('udp-heartbeat', []); - }); - - $this->client->on('error', function (\Throwable $e): void { - $this->discord->getLogger()->error('UDP error', ['e' => $e->getMessage()]); - $this->emit('udp-error', [$e]); - }); - - $this->client->once('message', fn (string $message) => $this->decodeUDP($message)); - - $this->discord->getLoop()->addTimer(0.1, fn () => $this->client->send((string) $buffer)); - }, function (\Throwable $e): void { - $this->discord->getLogger()->error('error while connecting to udp', ['e' => $e->getMessage()]); - $this->emit('error', [$e]); - }); - } - - /** - * Handles the session description packet received from the Discord voice server. - * - * @param Payload $data - * - * @since 10.19.0 - */ - protected function handleSessionDescription(object $data): void - { - /** @var SessionDescription */ - $sd = $this->discord->factory(SessionDescription::class, (array) $data->d, true); - - $this->ready = true; - $this->setMode($sd->mode); - $this->crypto->secret_key = $sd->secret_key; - - $this->discord->getLogger()->debug('received description packet, vc ready', ['data' => $sd]); - - if (! $this->reconnecting) { - $this->emit('ready', [$this]); - } else { - $this->reconnecting = false; - $this->emit('resumed', [$this]); - } - } - - /** - * Handles the 'resumed' event for the voice client. - * - * @param Payload $data - * - * Data associated with the resumed event. - */ - protected function handleResumed(object $data): void - { - /** @var Resumed */ - $resumed = $this->discord->factory(Resumed::class, (array) $data->d, true); - $this->discord->getLogger()->debug('received resumed packet', ['data' => $resumed]); - } - - /** - * Handles the event when a client connects to the voice server. - * - * @param Payload $data - * - * @since 10.19.0 - */ - protected function handleClientConnect(object $data): void - { - $this->discord->getLogger()->debug('received client connect packet', ['data' => $data]); - } - - /** - * Handles the event when a client disconnects from the voice server. - * - * @param Payload $data - * - * @since 10.19.0 - */ - protected function handleClientDisconnect(object $data): void - { - $this->discord->getLogger()->debug('received client disconnect packet', ['data' => $data]); - } - - /** - * Handles the any event from the voice server. - * - * @param Payload $data - * - * @since 10.40.0 - */ - public function handleAny(object $data): void - { - $any = $this->discord->factory(Any::class, (array) $data->d, true); - - $this->emit('any', [$any, $this]); - } - - /** - * Handles the flags event from the voice server. - * - * @param Payload $data - * - * @since 10.40.0 - */ - protected function handleFlags(object $data): void - { - $flags = $this->discord->factory(Flags::class, (array) $data->d, true); - - $this->emit('flags', [$flags, $this]); - } - - /** - * Handles the platform event from the voice server. - * - * @param Payload $data - * - * @since 10.40.0 - */ - protected function handlePlatform(object $data): void - { - $platform = $this->discord->factory(Platform::class, (array) $data->d, true); - - $this->emit('platform', [$platform, $this]); - } - - /** - * Handles undocumented voice opcodes not intended for use by bots. - * - * @param Payload $data - * - * @since 10.39.2 - */ - protected function handleUndocumented(object $data): void - { - } - - protected function handleDavePrepareTransition(object $data): void - { - $this->discord->getLogger()->debug('DAVE Prepare Transition', ['data' => $data]); - // Prepare local state necessary to perform the transition - $this->send(Payload::new( - Op::VOICE_DAVE_TRANSITION_READY, - [ - 'transition_id' => $data->d['transition_id'], - ], - )); - } - - protected function handleDaveExecuteTransition(object $data): void - { - $this->discord->getLogger()->debug('DAVE Execute Transition', ['data' => $data]); - // Execute the transition - // Update local state to reflect the new protocol context - } - - protected function handleDaveTransitionReady(object $data): void - { - $this->discord->getLogger()->debug('DAVE Transition Ready', ['data' => $data]); - // Handle transition ready state - } - - protected function handleDavePrepareEpoch(object $data): void - { - $this->discord->getLogger()->debug('DAVE Prepare Epoch', ['data' => $data]); - // Prepare local MLS group with parameters appropriate for the DAVE protocol version - $this->send(Payload::new( - Op::VOICE_DAVE_MLS_KEY_PACKAGE, - [ - 'epoch_id' => $data->d['epoch_id'], - 'key_package' => $this->generateKeyPackage(), - ], - )); - } - - protected function handleDaveMlsExternalSender(object $data) - { - $this->discord->getLogger()->debug('DAVE MLS External Sender', ['data' => $data]); - // Handle external sender public key and credential - } - - protected function handleDaveMlsKeyPackage(object $data): void - { - $this->discord->getLogger()->debug('DAVE MLS Key Package', ['data' => $data]); - // Handle MLS key package - } - - protected function handleDaveMlsProposals(object $data): void - { - $this->discord->getLogger()->debug('DAVE MLS Proposals', ['data' => $data]); - // Handle MLS proposals - $this->send(Payload::new( - Op::VOICE_DAVE_MLS_COMMIT_WELCOME, - [ - 'commit' => $this->generateCommit(), - 'welcome' => $this->generateWelcome(), - ], - )); - } - - protected function handleDaveMlsCommitWelcome(object $data): void - { - $this->discord->getLogger()->debug('DAVE MLS Commit Welcome', ['data' => $data]); - // Handle MLS commit and welcome messages - } - - protected function handleDaveMlsAnnounceCommitTransition(object $data): void - { - // Handle MLS announce commit transition - $this->discord->getLogger()->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); - } - - protected function handleDaveMlsWelcome(object $data): void - { - // Handle MLS welcome message - $this->discord->getLogger()->debug('DAVE MLS Welcome', ['data' => $data]); - } - - protected function handleDaveMlsInvalidCommitWelcome(object $data): void - { - $this->discord->getLogger()->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); - // Handle invalid commit or welcome message - // Reset local group state and generate a new key package - $this->send(Payload::new( - Op::VOICE_DAVE_MLS_KEY_PACKAGE, - [ - 'key_package' => $this->generateKeyPackage(), - ], - )); - } - - protected function handleCloseVoiceDisconnected(): void - { - $this->discord->getLogger()->info('voice client disconnected from channel', ['channel_id' => $this->channel_id]); - unset($this->voice_sessions[$this->guild_id]); - } - - /** - * Handles the speaking state of a user. - * - * @param Payload $data The data object received from the WebSocket. - */ - protected function handleSpeaking(object $data): void - { - $this->emit('speaking', [$data->d['speaking'], $data->d['user_id'], $this]); - $this->emit("speaking.{$data->d['user_id']}", [$data->d['speaking'], $this]); - - $this->discord->getLogger()->debug('received speaking packet', ['data' => $data]); - - $speaking = $this->discord->factory(Speaking::class, (array) $data->d, true); - - $this->speakingStatus[$speaking->ssrc] = $speaking; - } - - /** - * Resumes a previously established voice connection. - * - * @since 10.19.0 - */ - protected function resume(): void - { - $payload = Payload::new( - Op::VOICE_RESUME, - [ - 'server_id' => $this->guild_id, - 'session_id' => $this->voice_sessions[$this->guild_id], - 'token' => $this->data['token'], - 'seq_ack' => $this->data['seq'], - ] - ); - - $this->discord->getLogger()->debug('sending identify', ['packet' => $payload]); - - $this->send($payload); - } - - /** - * Handles a WebSocket connection. - * - * @param WebSocket $ws The WebSocket instance. - */ - public function handleWebSocketConnection(WebSocket $ws): void - { - $this->discord->getLogger()->debug('connected to voice websocket'); - - $this->voiceWebsocket = $ws; - - $ws->on('message', function ($message) { - if (($data = json_decode($message->getPayload(), true)) === false) { - return; - } - $data = Payload::fromArray($data); - - $this->emit('ws-message', [$message, $this]); - - $this->discord->getLogger()->debug('received voice op', ['op' => $data->op]); - if (isset(self::VOICE_OP_HANDLERS[$data->op])) { - $handler = self::VOICE_OP_HANDLERS[$data->op]; - $this->$handler($data); - } else { - $this->discord->getLogger()->debug('unknown voice op', ['op' => $data->op]); - $this->handleUndocumented($data); - } - }); - - $ws->on('error', function ($e) { - $this->discord->getLogger()->error('error with voice websocket', ['e' => $e->getMessage()]); - $this->emit('ws-error', [$e]); - }); - - $ws->on('close', [$this, 'handleWebSocketClose']); - - if (! $this->sentLoginFrame) { - $this->identify(); - $this->sentLoginFrame = true; - } elseif (isset( - $this->data['token'], - $this->data['seq'], - $this->voice_sessions[$this->guild_id] - )) { - $this->resume(); - } else { - $this->discord->getLogger()->debug('existing voice session or data not found, re-sending identify', ['guild_id' => $this->guild_id]); - $this->identify(); - } - } - - /** - * Decodes a UDP message to extract the IP address and port, then selects the protocol for voice communication. - * - * @param string $message The raw UDP message received from the server. - * @param string &$ip Reference to a variable where the extracted IP address will be stored. - * @param string &$port Reference to a variable where the extracted port number will be stored. - */ - protected function decodeUDP(string $message): void - { - /** - * Unpacks the message into an array. - * - * C2 (unsigned char) | Type | 2 bytes | Values 0x1 and 0x2 indicate request and response, respectively - * n (unsigned short) | Length | 2 bytes | Length of the following data - * I (unsigned int) | SSRC | 4 bytes | The SSRC of the sender - * A64 (string) | Address | 64 bytes | The IP address of the sender - * n (unsigned short) | Port | 2 bytes | The port of the sender - * - * @see https://discord.com/developers/docs/topics/voice-connections#ip-discovery - * @see https://www.php.net/manual/en/function.unpack.php - * @see https://www.php.net/manual/en/function.pack.php For the formats - */ - $unpackedMessageArray = \unpack('C2Type/nLength/ISSRC/A64Address/nPort', (string) $message); - $this->ssrc = $unpackedMessageArray['SSRC'] ?? -1; - $ip = $unpackedMessageArray['Address']; - $port = $unpackedMessageArray['Port']; - - $this->discord->getLogger()->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); - - $this->selectProtocol($ip, $port); - - $this->client->on('message', [$this, 'handleAudioData']); - } - - /** - * Handles a WebSocket error. - * - * @param \Exception $e The error. - */ - public function handleWebSocketError(\Exception $e): void - { - $this->discord->getLogger()->error('error with voice websocket', ['e' => $e->getMessage()]); - $this->emit('error', [$e]); - } - - /** - * Handles a WebSocket close. - * - * @param int $op - * @param string $reason - */ - public function handleWebSocketClose(int $op, string $reason): void - { - $this->discord->getLogger()->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); - $this->emit('ws-close', [$op, $reason, $this]); - - // Cancel heartbeat timers - if (isset($this->heartbeat)) { - $this->discord->getLoop()->cancelTimer($this->heartbeat); - $this->heartbeat = null; - } - - if (isset($this->udpHeartbeat)) { - $this->discord->getLoop()->cancelTimer($this->udpHeartbeat); - $this->udpHeartbeat = null; - } - - // Close UDP socket. - if ($this->client) { - $this->client->close(); - } - - // Remove voice session when leaving. - if ($op === Op::CLOSE_VOICE_DISCONNECTED) { - $this->handleCloseVoiceDisconnected(); - - return; - } - - // Don't reconnect on a critical opcode or if closed by user. - if (in_array($op, Op::getCriticalVoiceCloseCodes()) || $this->userClose) { - $this->discord->getLogger()->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); - $this->voice_sessions[$this->guild_id] = null; - $this->emit('close'); - } else { - $this->discord->getLogger()->warning('reconnecting in 2 seconds'); - - // Retry connect after 2 seconds - $this->discord->getLoop()->addTimer(2, function () { - $this->reconnecting = true; - $this->sentLoginFrame = false; - - $this->initSockets(); - }); - } - } - - /** - * Handles a voice server change. - * - * @param array $data New voice server information. - */ - public function handleVoiceServerChange(array $data = []): void - { - $this->discord->getLogger()->debug('voice server has changed, dynamically changing servers in the background', ['data' => $data]); - $this->reconnecting = true; - $this->sentLoginFrame = false; - $this->pause(); - - $this->client->close(); - $this->voiceWebsocket->close(); - - $this->discord->getLoop()->cancelTimer($this->heartbeat); - $this->discord->getLoop()->cancelTimer($this->udpHeartbeat); - - $this->data['token'] = $data['token']; // set the token if it changed - $this->endpoint = str_replace([':80', ':443'], '', $data['endpoint']); - - $this->initSockets(); - - $this->on('resumed', function () { - $this->discord->getLogger()->debug('voice client resumed'); - $this->unpause(); - $this->speaking = self::NOT_SPEAKING; - $this->setSpeaking(self::MICROPHONE); - }); - } - - /** - * Plays a file/url on the voice stream. - * - * @param string $file The file/url to play. - * @param int $channels Deprecated, Discord only supports 2 channels. - * - * @throws FileNotFoundException - * @throws \RuntimeException - * - * @return PromiseInterface - */ - public function playFile(string $file, int $channels = 2): PromiseInterface - { - $deferred = new Deferred(); - - if (filter_var($file, FILTER_VALIDATE_URL) === false && ! file_exists($file)) { - $deferred->reject(new FileNotFoundException("Could not find the file \"{$file}\".")); - - return $deferred->promise(); - } - - if (! $this->ready) { - $deferred->reject(new \RuntimeException('Voice Client is not ready.')); - - return $deferred->promise(); - } - - if ($this->speaking) { - $deferred->reject(new \RuntimeException('Audio already playing.')); - - return $deferred->promise(); - } - - $process = $this->ffmpegEncode($file); - $process->start(); - - return $this->playOggStream($process); - } - - /** - * Plays a raw PCM16 stream. - * - * @param resource|Stream $stream The stream to be encoded and sent. - * @param int $channels How many audio channels the PCM16 was encoded with. - * @param int $audioRate Audio sampling rate the PCM16 was encoded with. - * - * @throws \RuntimeException - * @throws \InvalidArgumentException Thrown when the stream passed to playRawStream is not a valid resource. - * - * @return PromiseInterface - */ - public function playRawStream($stream, int $channels = 2, int $audioRate = 48000): PromiseInterface - { - $deferred = new Deferred(); - - if (! $this->ready) { - $deferred->reject(new \RuntimeException('Voice Client is not ready.')); - - return $deferred->promise(); - } - - if ($this->speaking) { - $deferred->reject(new \RuntimeException('Audio already playing.')); - - return $deferred->promise(); - } - - if (! is_resource($stream) && ! $stream instanceof Stream) { - $deferred->reject(new \InvalidArgumentException('The stream passed to playRawStream was not an instance of resource or ReactPHP Stream.')); - - return $deferred->promise(); - } - - if (is_resource($stream)) { - $stream = new Stream($stream); - } - - $process = $this->ffmpegEncode(preArgs: [ - '-f', 's16le', - '-ac', $channels, - '-ar', $audioRate, - ]); - $process->start(); - $stream->pipe($process->stdin); - - return $this->playOggStream($process); - } - - /** - * Plays an Ogg Opus stream. - * - * @param resource|Process|Stream $stream The Ogg Opus stream to be sent. - * - * @throws \RuntimeException - * @throws \InvalidArgumentException - * - * @return PromiseInterface - */ - public function playOggStream($stream): PromiseInterface - { - $deferred = new Deferred(); - - if (! $this->isReady()) { - $deferred->reject(new \RuntimeException('Voice client is not ready yet.')); - - return $deferred->promise(); - } - - if ($this->speaking) { - $deferred->reject(new \RuntimeException('Audio already playing.')); - - return $deferred->promise(); - } - - if ($stream instanceof Process) { - $stream->stderr->on('data', function ($d) { - if (empty($d)) { - return; - } - - $this->emit('stderr', [$d, $this]); - }); - - $stream = $stream->stdout; - } - - if (is_resource($stream)) { - $stream = new ReadableResourceStream($stream); - } - - if (! ($stream instanceof ReadableStreamInterface)) { - $deferred->reject(new \InvalidArgumentException('The stream passed to playOggStream was not an instance of resource, ReactPHP Process, ReactPHP Readable Stream')); - - return $deferred->promise(); - } - - $this->buffer = new RealBuffer(); - $stream->on('data', fn ($d) => $this->buffer->write($d)); - - $loops = 0; - - $this->setSpeaking(self::MICROPHONE); - - OggStream::fromBuffer($this->buffer)->then(function (OggStream $os) use ($deferred, $loops) { - $this->startTime = microtime(true) + 0.5; - $this->readOpusTimer = $this->discord->getLoop()->addTimer(0.5, fn () => $this->readOpus($deferred, $os, $loops)); - }); - - return $deferred->promise(); - } - - /** - * Reads and processes Opus audio packets from an OGG stream. - * - * @param Deferred $deferred The deferred promise that will be resolved when the stream ends. - * @param OggStream &$ogg Reference to the OGG stream object to read packets from. - * @param int &$loops Reference to the loop counter used for timing calculations. - * - * - * @throws \Exception If packet retrieval fails. - */ - public function readOpus(Deferred $deferred, OggStream &$ogg, int &$loops) - { - $this->readOpusTimer = null; - - $loops += 1; - - // If the client is paused, delay by frame size and check again. - if ($this->paused) { - $this->insertSilence(); - $this->readOpusTimer = $this->discord->getLoop()->addTimer($this->frameSize / 1000, fn () => $this->readOpus($deferred, $ogg, $loops)); - - return; - } - - $ogg->getPacket()->then(function (string|null $packet) use ($deferred, &$ogg, &$loops) { - // EOF for Ogg stream. - if ($packet === null) { - $this->reset(); - $deferred->resolve(null); - - return; - } - - // increment sequence - // uint16 overflow protection - if (++$this->seq >= 2 ** 16) { - $this->seq = 0; - } - - $this->sendBuffer($packet); - - // increment timestamp - // uint32 overflow protection - if (($this->timestamp += ($this->frameSize * 48)) >= 2 ** 32) { - $this->timestamp = 0.0; - } - - $nextTime = $this->startTime + (20.0 / 1000.0) * $loops; - $delay = $nextTime - microtime(true); - - $this->readOpusTimer = $this->discord->getLoop()->addTimer($delay, fn () => $this->readOpus($deferred, $ogg, $loops)); - }, function ($e) use ($deferred) { - $this->reset(); - $deferred->resolve(null); - }); - } - - /** - * Resets the voice client. - */ - protected function reset(): void - { - if ($this->readOpusTimer) { - $this->discord->getLoop()->cancelTimer($this->readOpusTimer); - $this->readOpusTimer = null; - } - - $this->setSpeaking(self::NOT_SPEAKING); - $this->streamTime = 0.0; - $this->startTime = 0.0; - $this->paused = false; - $this->silenceRemaining = 5; - } - - /** - * Sends a buffer to the UDP socket. - * - * @param string $data The data to send to the UDP server. - */ - protected function sendBuffer(string $data): void - { - if (! $this->ready) { - return; - } - - $packet = new VoicePacket($data, $this->ssrc, $this->seq, $this->timestamp); - //$this->discord->getLogger()->debug('valid packet?' , ['valid' => $packet::validatePacket($packet)]); - - $encrypted = $this->crypto->encryptRTPPacket($packet, $this->seq); - //$this->discord->getLogger()->debug('valid encrypted packet?' , ['valid' => $this->crypto::validateEncryptedRTPPacket($encrypted)]); - - $this->client->send($encrypted); - - $this->streamTime = microtime(true); - - $this->emit('packet-sent', [$packet]); - } - - /** - * Sets the speaking value of the client. - * - * @link https://discord.com/developers/docs/topics/voice-connections#speaking - * - * @param int|bool $speaking The speaking mode. - * - * @throws \RuntimeException - */ - public function setSpeaking(int|bool $speaking = self::MICROPHONE): void - { - $speaking = (int) $speaking; - - if ($this->speaking === $speaking) { - return; - } - - if (! $this->ready) { - throw new \RuntimeException('Voice Client is not ready.'); - } - - $this->send(Payload::new( - Op::VOICE_SPEAKING, - [ - 'speaking' => $speaking, - 'delay' => 0, - 'ssrc' => $this->ssrc, - ], - )); - - $this->speaking = $speaking; - } - - /** - * Switches voice channels. - * - * @param Channel $channel The channel to switch to. - * - * @throws \InvalidArgumentException - */ - public function switchChannel(Channel $channel): void - { - if (! $channel->isVoiceBased()) { - throw new \InvalidArgumentException("Channel must be a voice channel to be able to switch, given type {$channel->type}."); - } - - $this->mainSend(Payload::new( - Op::OP_UPDATE_VOICE_STATE, - [ - 'guild_id' => $this->guild_id, - 'channel_id' => $channel->id, - 'self_mute' => $this->mute, - 'self_deaf' => $this->deaf, - ], - )); - - $this->channel_id = $channel; - } - - /** - * Sets the bitrate. - * - * For voice channels, normal servers can set bitrate up to 96000, - * servers with Boost level 1 can set up to 128000, - * servers with Boost level 2 can set up to 256000, - * and servers with Boost level 3 or the VIP_REGIONS guild feature can set up to 384000. - * For stage channels, bitrate can be set up to 64000. - * - * @param int $bitrate The bitrate to set. - * - * @throws \DomainException - * @throws \RuntimeException - * - * @deprecated 10.40.0 This data is now taken from the Channel. - */ - public function setBitrate(int $bitrate): void - { - } - - /** - * Sets the volume. - * - * @param int $volume The volume to set. - * - * @throws \DomainException - * @throws \RuntimeException - */ - public function setVolume(int $volume): void - { - if ($volume < 0 || $volume > 100) { - throw new \DomainException("{$volume}% is not a valid option. The volume must be between 0% and 100%."); - } - - if ($this->speaking) { - throw new \RuntimeException('Cannot change volume while playing.'); - } - - $this->volume = $volume; - } - - /** - * Sets the audio application. - * - * @param string $app The audio application to set. - * - * @throws \DomainException - * @throws \RuntimeException - */ - public function setAudioApplication(string $app): void - { - static $allowed = ['voip', 'audio', 'lowdelay']; - - if (! in_array($app, $allowed)) { - throw new \DomainException("{$app} is not a valid audio application. Valid options are: ".implode(', ', $allowed)); - } - - if ($this->speaking) { - throw new \RuntimeException('Cannot change audio application while playing.'); - } - - $this->audioApplication = $app; - } - - /** - * Sends a message to the voice websocket. - * - * @param Payload|array $data The data to send to the voice WebSocket. - */ - protected function send($data): void - { - $this->voiceWebsocket->send(json_encode($data)); - } - - /** - * Sends a message to the main websocket. - * - * @param Payload|array $data The data to send to the main WebSocket. - */ - protected function mainSend($data): void - { - $this->mainWebsocket->send(json_encode($data)); - } - - /** - * Changes your mute and deaf value. - * - * @param bool $mute Whether you should be muted. - * @param bool $deaf Whether you should be deaf. - * - * @throws \RuntimeException - */ - public function setMuteDeaf(bool $mute, bool $deaf): void - { - if (! $this->ready) { - throw new \RuntimeException('The voice client must be ready before you can set mute or deaf.'); - } - - $this->mute = $mute; - $this->deaf = $deaf; - - $this->mainSend(Payload::new( - Op::OP_UPDATE_VOICE_STATE, - [ - 'guild_id' => $this->guild_id, - 'channel_id' => $this->channel_id, - 'self_mute' => $mute, - 'self_deaf' => $deaf, - ], - )); - } - - /** - * Pauses the current sound. - * - * @throws \RuntimeException - * - * @return PromiseInterface - */ - public function pause(): PromiseInterface - { - if (! $this->speaking) { - return reject(new \RuntimeException('Audio must be playing to pause it.')); - } - - if ($this->paused) { - return reject(new \RuntimeException('Audio is already paused.')); - } - - $this->paused = true; - $this->silenceRemaining = 5; - - return resolve(null); - } - - /** - * Unpauses the current sound. - * - * @throws \RuntimeException - * - * @return PromiseInterface - */ - public function unpause(): PromiseInterface - { - if (! $this->speaking) { - return reject(new \RuntimeException('Audio must be playing to unpause it.')); - } - - if (! $this->paused) { - return reject(new \RuntimeException('Audio is already playing.')); - } - - $this->paused = false; - $this->timestamp = microtime(true) * 1000; - - return resolve(null); - } - - /** - * Stops the current sound. - * - * @throws \RuntimeException - * - * @return PromiseInterface - */ - public function stop(): PromiseInterface - { - if (! $this->speaking) { - return reject(new \RuntimeException('Audio must be playing to stop it.')); - } - - $this->buffer->end(); - $this->reset(); - - return $this->insertSilence(); - } - - /** - * Closes the voice client. - * - * @throws \RuntimeException - */ - public function close(): void - { - if (! $this->ready) { - throw new \RuntimeException('Voice Client is not connected.'); - } - - if ($this->speaking) { - $this->stop(); - $this->setSpeaking(self::NOT_SPEAKING); - } - - $this->ready = false; - - $this->mainSend(Payload::new( - Op::OP_UPDATE_VOICE_STATE, - [ - 'guild_id' => $this->guild_id, - 'channel_id' => null, - 'self_mute' => true, - 'self_deaf' => true, - ], - )); - - $this->userClose = true; - $this->client->close(); - $this->voiceWebsocket->close(); - - $this->heartbeat_interval = null; - - if (isset($this->heartbeat)) { - $this->discord->getLoop()->cancelTimer($this->heartbeat); - $this->heartbeat = null; - } - - if (isset($this->udpHeartbeat)) { - $this->discord->getLoop()->cancelTimer($this->udpHeartbeat); - $this->udpHeartbeat = null; - } - - $this->seq = 0; - $this->timestamp = 0.0; - $this->sentLoginFrame = false; - $this->startTime = null; - $this->streamTime = 0.0; - $this->speakingStatus = new Collection([], 'ssrc'); - - $this->emit('close'); - } - - /** - * Checks if the user is speaking. - * - * @param string|int|null $id Either the User ID or SSRC (if null, return bots speaking status). - * - * @return bool Whether the user is speaking. - */ - public function isSpeaking($id = null): bool - { - if (! isset($id) || $id === $this->discord->id) { - return $this->speaking !== 0; - } - if ($user = $this->speakingStatus->get('user_id', $id)) { - /** @var Speaking $user */ - return $user->speaking !== 0; - } - if ($ssrc = $this->speakingStatus->get('ssrc', $id)) { - /** @var Speaking $ssrc */ - return $ssrc->speaking !== 0; - } - - return false; - } - - /** - * Checks if we are paused. - * - * @return bool Whether we are paused. - */ - public function isPaused(): bool - { - return $this->paused; - } - - /** - * Handles a voice state update. - * NOTE: This object contains the data as the VoiceStateUpdate Part. - * - * @see \Discord\Parts\WebSockets\VoiceStateUpdate - * - * @param VoiceStateUpdate $data The WebSocket data. - */ - public function handleVoiceStateUpdate(object $data): void - { - if (! isset($data->user_id) || ! $ss = $this->speakingStatus->get('user_id', $data->user_id)) { - return; // not in our channel - } - - if ($data->channel_id && $data->channel_id === $this->channel_id) { - return; // ignore, just a mute/deaf change - } - - $this->removeDecoder($ss); - } - - /** - * Removes the voice decoder associated with the given SSRC. - * - * @param object $ss - */ - protected function removeDecoder($ss) - { - if (! $decoder = $this->voiceDecoders[$ss->ssrc] ?? null) { - return; // no voice decoder to remove - } - - $decoder->close(); - unset($this->voiceDecoders[$ss->ssrc]); - unset($this->speakingStatus[$ss->ssrc]); - } - - /** - * Gets a receive voice stream. - * - * @param int|string $id Either a SSRC or User ID. - * - * @return ReceiveStream - * - * @deprecated use self::getReceiveStream() - */ - public function getRecieveStream($id): ?ReceiveStream - { - return $this->getReceiveStream($id); - } - - /** - * Gets a receive voice stream. - * - * @param int|string $id Either a SSRC or User ID. - * - * @return ReceiveStream - */ - public function getReceiveStream($id): ?ReceiveStream - { - if (isset($this->receiveStreams[$id])) { - return $this->receiveStreams[$id]; - } - - foreach ($this->speakingStatus as $status) { - if ($status->user_id === $id) { - return $this->receiveStreams[$status->ssrc]; - } - } - - return null; - } - - /** - * Handles raw opus data from the UDP server. - * - * @param string $message The data from the UDP server. - */ - protected function handleAudioData($message): void - { - return; - - if ($this->deaf) { - $this->discord->getLogger()->debug('ignoring voice data, client is deafened'); - - return; - } - - //$this->discord->getLogger()->debug('received voice data', ['message' => $message]); - - $voicePacket = VoicePacket::make($message); - - if (($decrypted = $this->crypto->decryptRTPPacket($voicePacket, $this->seq)) === false) { - $this->discord->getLogger()->debug('failed to decrypt voice packet', ['packet' => $voicePacket]); - - return; - } - - $this->emit('raw', [$decrypted, $this, $voicePacket]); - - self::decodeVoicePacket($decrypted, $this, $voicePacket); - } - - /** - * Decodes voice packet data. - * - * @param string $decrypted The decrypted voice data. - * @param VoiceClient $vc The voice client instance. - * @param VoicePacket $voicePacket The voice packet to decode. - * - * @todo - */ - public static function decodeVoicePacket(string $decrypted, VoiceClient $vc, VoicePacket $voicePacket): void - { - $vp = VoicePacket::make($voicePacket->getHeader().$decrypted); - - if (! $ss = $vc->speakingStatus->get('ssrc', $vp->getSSRC())) { - return; // for some reason we don't have a speaking status - } - /** @var Speaking $ss */ - if (! $decoder = $vc->voiceDecoders[$vp->getSSRC()] ?? null) { - // make a decoder - if (! isset($vc->receiveStreams[$ss->ssrc])) { - $vc->receiveStreams[$ss->ssrc] = new ReceiveStream(); - $vc->receiveStreams[$ss->ssrc]->on('pcm', fn ($d) => $vc->emit('channel-pcm', [$d, $vc])); - $vc->receiveStreams[$ss->ssrc]->on('opus', fn ($d) => $vc->emit('channel-opus', [$d, $vc])); - - $vc->emit('receiveStream', [$vc->receiveStreams[$ss->ssrc], $vc]); - } - if (! $decoder = $vc->createDecoder($ss)) { - return; - } - - $vc->voiceDecoders[$ss->ssrc] = $decoder; - } - - $buff = new Buffer(strlen($vp->getData()) + 2); - $buff->write(pack('s', strlen($vp->getData())), 0); - $buff->write($vp->getData(), 2); - - $decoder->stdin->write((string) $buff); - } - - /** - * Creates and starts a decoder process for the given stream source. - * - * @todo Implement MLS decoding and use the appropriate decoder. - * - * @param object $ss The stream source object containing ssrc and user_id properties. - * - * @return Process|null The created decoder ReactPHP Child Process, or null if decoding is not implemented or supported. - */ - protected function createDecoder(object $ss): ?Process - { - return null; // Decoding not implemented yet - - //$decoder = $this->opusDecode(); - $decoder->start(); - $decoder->stdout->on('data', function ($data) use ($ss) { - $this->receiveStreams[$ss->ssrc]->writePCM($data); - }); - $decoder->stderr->on('data', function ($data) use ($ss) { - $this->emit("voice.{$ss->ssrc}.stderr", [$data, $this]); - $this->emit("voice.{$ss->user_id}.stderr", [$data, $this]); - }); - $decoder->on('exit', function ($code, $term) use ($ss) { - if ($code > 0) { - $this->emit('decoder-error', [$code, $term, $ss]); - $this->createDecoder($ss); - } - }); - - return $decoder; - } - - protected function generateKeyPackage() - { - // Generate and return a new MLS key package - } - - protected function generateCommit() - { - // Generate and return an MLS commit message - } - - protected function generateWelcome() - { - // Generate and return an MLS welcome message - } - - /** - * Returns whether the voice client is ready. - * - * @return bool Whether the voice client is ready. - */ - public function isReady(): bool - { - return $this->ready; - } - - /** - * Checks if FFmpeg is installed. - * - * @return bool Whether FFmpeg is installed or not. - */ - protected function checkForFFmpeg(): bool - { - if ($output = $this->checkForExecutable('ffmpeg')) { - $this->ffmpeg = $output; - - return true; - } - $this->emit('error', [new FFmpegNotFoundException('No FFmpeg binary was found.')]); - - return false; - } - - /** - * Checks if libsodium-php is installed. - * - * @return bool - */ - protected function checkForLibsodium(): bool - { - if (! function_exists('sodium_crypto_secretbox')) { - $this->emit('error', [new LibSodiumNotFoundException('libsodium-php could not be found.')]); - - return false; - } - - return true; - } - - /** - * Checks if an executable exists on the system. - * - * @param string $executable - * @return string|null - */ - protected static function checkForExecutable(string $executable): ?string - { - $systemOs = substr(PHP_OS, 0, 3); - $which = 'command -v'; - if (strtoupper($systemOs) === 'WIN') { - $which = 'where'; - } - - $shellExecutable = shell_exec("$which $executable"); - if ($shellExecutable === false) { - // Unable to establish pipe - return null; - } - if ($shellExecutable === null) { - // Error or the command produced no output - return null; - } - $executable = rtrim((string) explode(PHP_EOL, $shellExecutable)[0]); - - return is_executable($executable) ? $executable : null; - } - - /** - * Creates a process that will run FFmpeg and encode `$filename` into Ogg - * Opus format. - * - * If `$filename` is null, the process will expect some sort of audio data - * to be piped in via stdin. It is highly recommended to set `$preArgs` to - * contain the format of the piped data when using a pipe as an input. You - * may also want to provide some arguments to FFmpeg via `$preArgs`, which - * will be appended to the FFmpeg command _before_ setting the input - * arguments. - * - * @link https://discord.com/developers/docs/topics/voice-connections#transport-encryption-and-sending-voice - * - * @param ?string $filename Path to file to be converted into Ogg Opus, or - * null for pipe via stdin. - * @param ?array $preArgs A list of arguments to be appended before the - * input filename. - * - * @return Process A ReactPHP child process, or a Windows compatible process replacement. - */ - public function ffmpegEncode(?string $filename = null, ?array $preArgs = null): Process - { - $dB = match ($this->volume) { - 0 => -100, - 100 => 0, - default => -40 + ($this->volume / 100) * 40, - }; - - $flags = [ - '-i', $filename ?? 'pipe:0', - '-map_metadata', '-1', - '-f', 'opus', - '-c:a', 'libopus', - '-ar', 48000, - '-af', 'volume='.$dB.'dB', - '-ac', '2', - '-b:a', $this->getChannel()->bitrate, - '-loglevel', 'warning', - 'pipe:1', - ]; - - if ($preArgs) { - $flags = array_merge($preArgs, $flags); - } - - $flags = implode(' ', $flags); - $cmd = "{$this->ffmpeg} {$flags}"; - - return new Process($cmd, null, null, [ - ['socket'], - ['socket'], - ['socket'], - ]); - } - - /** - * Returns the connected guild. - * - * @return Guild The connected guild. - * - * @since 10.41.0 - */ - public function getGuild(): Guild - { - return $this->discord->guilds->get('id', $this->guild_id); - } - - /** - * Returns the connected channel. - * - * @return Channel The connected channel. - */ - public function getChannel(): Channel - { - return $this->getGuild()->channels->get('id', $this->channel_id); - } - - /** - * Sends five frames of Opus silence to avoid unintended interpolation when there is a break in the sent data. - * - * @link https://discord.com/developers/docs/topics/voice-connections#voice-data-interpolation - * - * @return PromiseInterface Resolves after all silence frames have been sent. - */ - protected function insertSilence(): PromiseInterface - { - $deferred = new Deferred(); - $this->__insertSilence($deferred); - - return $deferred->promise(); - } - - /** - * Inserts silence frames recursively. - * - * @param Deferred $deferred The deferred promise to resolve when done. - */ - protected function __insertSilence(Deferred $deferred): void - { - if ($this->silenceRemaining > 0) { - $this->sendBuffer(self::SILENCE_FRAME); - $this->silenceRemaining--; - $this->discord->getLoop()->addTimer($this->frameSize / 1000, fn () => $this->__insertSilence($deferred)); - } else { - $deferred->resolve(null); - } - } -}