Skip to main content
This example demonstrates how to connect to voice channels and handle audio streams. The bot will echo back any audio it receives, creating a real-time audio feedback loop.

Overview

The Audio Echo bot will:
  • Connect to voice channels via text commands
  • Receive audio from users in the voice channel
  • Echo the combined audio back to all users
  • Handle audio in PCM format at 20ms intervals
Audio features require additional dependencies for the DAVE (Discord Audio & Video End-to-End Encryption) protocol. See the setup section for details.

Required Dependencies

1

Add DAVE Session Factory

JDA requires a DAVE session factory implementation for voice encryption:
<dependency>
    <groupId>club.minnced</groupId>
    <artifactId>jdave</artifactId>
    <version>VERSION</version>
</dependency>
See jdave on GitHub for details.
2

Optional: UDP Queue (Recommended)

To avoid audio stutter caused by JVM garbage collection pauses:
<dependency>
    <groupId>club.minnced</groupId>
    <artifactId>udpqueue</artifactId>
    <version>VERSION</version>
</dependency>
See udpqueue.rs on GitHub for details.

Audio Module Configuration

Configure JDA with the required audio modules:
import club.minnced.discord.jdave.interop.JDaveSessionFactory;
import net.dv8tion.jda.api.audio.AudioModuleConfig;
import net.dv8tion.jda.api.audio.dave.DaveSessionFactory;

// [REQUIRED] DAVE protocol implementation
DaveSessionFactory daveSessionFactory = new JDaveSessionFactory();

// [OPTIONAL] Native audio send factory to reduce stutter
// AudioSendFactory audioSendFactory = new NativeAudioSendFactory();

AudioModuleConfig audioModuleConfig = new AudioModuleConfig()
    // .withAudioSendFactory(audioSendFactory)  // Optional
    .withDaveSessionFactory(daveSessionFactory);

Gateway Intents for Audio

Enable the necessary intents:
EnumSet<GatewayIntent> intents = EnumSet.of(
    // We need messages in guilds to accept commands from users
    GatewayIntent.GUILD_MESSAGES,
    // We need voice states to connect to the voice channel
    GatewayIntent.GUILD_VOICE_STATES,
    // Enable access to message.getContentRaw()
    GatewayIntent.MESSAGE_CONTENT
);

Building the JDA Instance

JDABuilder.createDefault(token, intents)
    .addEventListeners(new AudioEchoExample())
    .setActivity(Activity.listening("to jams"))
    .setStatus(OnlineStatus.DO_NOT_DISTURB)
    // Enable the VOICE_STATE cache to find a user's connected voice channel
    .enableCache(CacheFlag.VOICE_STATE)
    // Configure the JDA audio module
    .setAudioModuleConfig(audioModuleConfig)
    .build();

Handling Voice Commands

Implement message commands to join voice channels:
@Override
public void onMessageReceived(MessageReceivedEvent event) {
    Message message = event.getMessage();
    User author = message.getAuthor();
    String content = message.getContentRaw();
    Guild guild = event.getGuild();

    // Ignore messages from bots
    if (author.isBot()) {
        return;
    }

    // Only handle guild messages
    if (!event.isFromGuild()) {
        return;
    }

    if (content.startsWith("!echo ")) {
        String arg = content.substring("!echo ".length());
        onEchoCommand(event, guild, arg);
    } else if (content.equals("!echo")) {
        onEchoCommand(event);
    }
}

Join User’s Current Channel

private void onEchoCommand(MessageReceivedEvent event) {
    Member member = event.getMember();
    GuildVoiceState voiceState = member.getVoiceState();
    AudioChannel channel = voiceState.getChannel();
    
    if (channel != null) {
        connectTo(channel);
        onConnecting(channel, event.getChannel());
    } else {
        onUnknownChannel(event.getChannel(), "your voice channel");
    }
}

Join Specified Channel

private void onEchoCommand(MessageReceivedEvent event, Guild guild, String arg) {
    boolean isNumber = arg.matches("\\d+");
    VoiceChannel channel = null;

    // Try to find by ID
    if (isNumber) {
        channel = guild.getVoiceChannelById(arg);
    }

    // Try to find by name
    if (channel == null) {
        List<VoiceChannel> channels = guild.getVoiceChannelsByName(arg, true);
        if (!channels.isEmpty()) {
            channel = channels.get(0);
        }
    }

    if (channel == null) {
        onUnknownChannel(event.getChannel(), arg);
        return;
    }

    connectTo(channel);
    onConnecting(channel, event.getChannel());
}

Connecting to Voice Channels

private void connectTo(AudioChannel channel) {
    Guild guild = channel.getGuild();
    AudioManager audioManager = guild.getAudioManager();
    EchoHandler handler = new EchoHandler();

    // Set the send and receive handlers
    audioManager.setSendingHandler(handler);
    audioManager.setReceivingHandler(handler);
    
    // Connect to the voice channel
    audioManager.openAudioConnection(channel);
}

Echo Handler Implementation

The echo handler implements both AudioSendHandler and AudioReceiveHandler:
public static class EchoHandler implements AudioSendHandler, AudioReceiveHandler {
    private final Queue<byte[]> queue = new ConcurrentLinkedQueue<>();

    /* Receive Handling */
    
    @Override
    public boolean canReceiveCombined() {
        // Limit queue to 10 entries to prevent overflow
        return queue.size() < 10;
    }

    @Override
    public void handleCombinedAudio(CombinedAudio combinedAudio) {
        // Only queue audio when users are actually speaking
        if (combinedAudio.getUsers().isEmpty()) {
            return;
        }

        byte[] data = combinedAudio.getAudioData(1.0f); // volume at 100%
        queue.add(data);
    }

    /* Send Handling */
    
    @Override
    public boolean canProvide() {
        return !queue.isEmpty();
    }

    @Override
    public ByteBuffer provide20MsAudio() {
        byte[] data = queue.poll();
        return data == null ? null : ByteBuffer.wrap(data);
    }

    @Override
    public boolean isOpus() {
        // We're sending PCM audio, not Opus
        return false;
    }
}

Audio Processing Details

All methods in the echo handler are called by JDA threads when resources are available/ready for processing.

Receiving Audio

  • The receiver gets 20ms chunks of PCM stereo audio
  • You can receive audio even while deafened
  • Combined audio merges all users into a single stream
  • Volume can be adjusted (1.0 = 100%, 0.5 = 50%)

Sending Audio

  • JDA requests 20ms chunks when ready
  • Audio is automatically marked as “speaking” when provided
  • PCM format is raw audio data (not compressed)
  • Use isOpus() to return true if sending Opus-encoded audio

Queue Management

private final Queue<byte[]> queue = new ConcurrentLinkedQueue<>();

@Override
public boolean canReceiveCombined() {
    // Prevent buffer overflow by limiting queue size
    return queue.size() < 10;
}

Complete Example

import club.minnced.discord.jdave.interop.JDaveSessionFactory;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.OnlineStatus;
import net.dv8tion.jda.api.audio.AudioModuleConfig;
import net.dv8tion.jda.api.audio.AudioReceiveHandler;
import net.dv8tion.jda.api.audio.AudioSendHandler;
import net.dv8tion.jda.api.audio.CombinedAudio;
import net.dv8tion.jda.api.audio.dave.DaveSessionFactory;
import net.dv8tion.jda.api.entities.*;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.managers.AudioManager;
import net.dv8tion.jda.api.requests.GatewayIntent;
import net.dv8tion.jda.api.utils.cache.CacheFlag;

import java.nio.ByteBuffer;
import java.util.EnumSet;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;

public class AudioEchoExample extends ListenerAdapter {
    public static void main(String[] args) {
        if (args.length == 0) {
            System.err.println("Unable to start without token!");
            System.exit(1);
        }
        String token = args[0];

        EnumSet<GatewayIntent> intents = EnumSet.of(
            GatewayIntent.GUILD_MESSAGES,
            GatewayIntent.GUILD_VOICE_STATES,
            GatewayIntent.MESSAGE_CONTENT
        );

        DaveSessionFactory daveSessionFactory = new JDaveSessionFactory();
        AudioModuleConfig audioModuleConfig = new AudioModuleConfig()
            .withDaveSessionFactory(daveSessionFactory);

        JDABuilder.createDefault(token, intents)
            .addEventListeners(new AudioEchoExample())
            .setActivity(Activity.listening("to jams"))
            .setStatus(OnlineStatus.DO_NOT_DISTURB)
            .enableCache(CacheFlag.VOICE_STATE)
            .setAudioModuleConfig(audioModuleConfig)
            .build();
    }

    @Override
    public void onMessageReceived(MessageReceivedEvent event) {
        Message message = event.getMessage();
        User author = message.getAuthor();
        String content = message.getContentRaw();
        Guild guild = event.getGuild();

        if (author.isBot() || !event.isFromGuild()) {
            return;
        }

        if (content.startsWith("!echo ")) {
            String arg = content.substring("!echo ".length());
            onEchoCommand(event, guild, arg);
        } else if (content.equals("!echo")) {
            onEchoCommand(event);
        }
    }

    private void onEchoCommand(MessageReceivedEvent event) {
        Member member = event.getMember();
        GuildVoiceState voiceState = member.getVoiceState();
        AudioChannel channel = voiceState.getChannel();
        
        if (channel != null) {
            connectTo(channel);
            event.getChannel().sendMessage("Connecting to " + channel.getName()).queue();
        } else {
            event.getChannel().sendMessage("Unable to connect to your voice channel!").queue();
        }
    }

    private void onEchoCommand(MessageReceivedEvent event, Guild guild, String arg) {
        boolean isNumber = arg.matches("\\d+");
        VoiceChannel channel = null;

        if (isNumber) {
            channel = guild.getVoiceChannelById(arg);
        }

        if (channel == null) {
            List<VoiceChannel> channels = guild.getVoiceChannelsByName(arg, true);
            if (!channels.isEmpty()) {
                channel = channels.get(0);
            }
        }

        if (channel == null) {
            event.getChannel().sendMessage("Unable to connect to ``" + arg + "``, no such channel!").queue();
            return;
        }

        connectTo(channel);
        event.getChannel().sendMessage("Connecting to " + channel.getName()).queue();
    }

    private void connectTo(AudioChannel channel) {
        Guild guild = channel.getGuild();
        AudioManager audioManager = guild.getAudioManager();
        EchoHandler handler = new EchoHandler();

        audioManager.setSendingHandler(handler);
        audioManager.setReceivingHandler(handler);
        audioManager.openAudioConnection(channel);
    }

    public static class EchoHandler implements AudioSendHandler, AudioReceiveHandler {
        private final Queue<byte[]> queue = new ConcurrentLinkedQueue<>();

        @Override
        public boolean canReceiveCombined() {
            return queue.size() < 10;
        }

        @Override
        public void handleCombinedAudio(CombinedAudio combinedAudio) {
            if (combinedAudio.getUsers().isEmpty()) {
                return;
            }
            byte[] data = combinedAudio.getAudioData(1.0f);
            queue.add(data);
        }

        @Override
        public boolean canProvide() {
            return !queue.isEmpty();
        }

        @Override
        public ByteBuffer provide20MsAudio() {
            byte[] data = queue.poll();
            return data == null ? null : ByteBuffer.wrap(data);
        }

        @Override
        public boolean isOpus() {
            return false;
        }
    }
}
For production bots, consider implementing a disconnect command and automatic timeout to leave channels when inactive.

Build docs developers (and LLMs) love