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
Add DAVE Session Factory
JDA requires a DAVE session factory implementation for voice encryption:See jdave on GitHub for details.
<dependency>
<groupId>club.minnced</groupId>
<artifactId>jdave</artifactId>
<version>VERSION</version>
</dependency>
Optional: UDP Queue (Recommended)
To avoid audio stutter caused by JVM garbage collection pauses:See udpqueue.rs on GitHub for details.
<dependency>
<groupId>club.minnced</groupId>
<artifactId>udpqueue</artifactId>
<version>VERSION</version>
</dependency>
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 bothAudioSendHandler 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 returntrueif 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.