Voice-Bridge: Plugin, Bridge-Dienst und Protokoll

This commit is contained in:
MineTec committed 2026-10-18 12:46:59 +02:00
commit c0c290d3dc
35 files changed
+2497

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
repositories {
maven {
name = 'papermc'
url = 'https://repo.papermc.io/repository/maven-public/'
}
maven {
name = 'henkelmax'
url = 'https://maven.maxhenkel.de/repository/public'
}
}
dependencies {
// Paper 26.3 auf dem Server, gebaut gegen die 26.2-API wie die anderen Plugins
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
// Simple Voice Chat 2.6.24 auf dem Server
compileOnly 'de.maxhenkel.voicechat:voicechat-api:2.6.0'
implementation project(':protocol')
}
processResources {
inputs.property 'version', project.version
filesMatching('plugin.yml') {
expand version: project.version
}
}
jar {
archiveFileName = 'voice-bridge.jar'
dependsOn ':protocol:jar'
from(project(':protocol').sourceSets.main.output)
}
@@ -0,0 +1,91 @@
package eu.mhsl.voicebridge.plugin;
import eu.mhsl.voicebridge.protocol.Message;
import eu.mhsl.voicebridge.protocol.Protocol;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetSocketAddress;
import java.net.SocketException;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;
/** UDP-Verbindung zum Bridge-Dienst. Das Plugin meldet sich jede Sekunde, die Bridge antwortet. */
final class BridgeLink {
private static final long ALIVE_MILLIS = 5000;
private final InetSocketAddress bridge;
private final Logger logger;
private final Consumer<Message> handler;
private final DatagramSocket socket;
private final Thread receiver;
private volatile long lastReply;
private volatile boolean wasAlive;
BridgeLink(InetSocketAddress bridge, Logger logger, Consumer<Message> handler) throws SocketException {
this.bridge = bridge;
this.logger = logger;
this.handler = handler;
this.socket = new DatagramSocket();
this.receiver = Thread.ofPlatform().daemon().name("VoiceBridge-Link").unstarted(this::receiveLoop);
}
void start() {
receiver.start();
}
boolean isBridgeAlive() {
return System.currentTimeMillis() - lastReply < ALIVE_MILLIS;
}
void sendHello(Map<String, Integer> members) {
boolean alive = isBridgeAlive();
if (wasAlive && !alive) logger.warning("Bridge antwortet nicht mehr.");
wasAlive = alive;
send(new Message.Hello(Protocol.VERSION, members));
}
void sendAudio(String room, UUID player, byte[] opus) {
send(new Message.Audio(room, player, opus));
}
private void send(Message message) {
byte[] data = Protocol.encode(message);
try {
socket.send(new DatagramPacket(data, data.length, bridge));
} catch (IOException e) {
if (!socket.isClosed()) logger.log(Level.FINE, "Senden an die Bridge fehlgeschlagen", e);
}
}
private void receiveLoop() {
byte[] buffer = new byte[Protocol.MAX_DATAGRAM];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
while (!socket.isClosed()) {
try {
packet.setLength(buffer.length);
socket.receive(packet);
if (!packet.getAddress().equals(bridge.getAddress()) || packet.getPort() != bridge.getPort()) continue;
Message message = Protocol.decode(packet.getData(), packet.getOffset(), packet.getLength());
if (message instanceof Message.Rooms) {
if (!isBridgeAlive()) logger.info("Bridge verbunden.");
lastReply = System.currentTimeMillis();
}
handler.accept(message);
} catch (IOException e) {
if (!socket.isClosed()) logger.log(Level.WARNING, "Ungültiges Paket von der Bridge", e);
} catch (RuntimeException e) {
logger.log(Level.WARNING, "Fehler beim Verarbeiten eines Bridge-Pakets", e);
}
}
}
void close() {
socket.close();
}
}
@@ -0,0 +1,234 @@
package eu.mhsl.voicebridge.plugin;
import de.maxhenkel.voicechat.api.Group;
import de.maxhenkel.voicechat.api.VoicechatConnection;
import de.maxhenkel.voicechat.api.VoicechatPlugin;
import de.maxhenkel.voicechat.api.VoicechatServerApi;
import de.maxhenkel.voicechat.api.events.CreateGroupEvent;
import de.maxhenkel.voicechat.api.events.EventRegistration;
import de.maxhenkel.voicechat.api.events.JoinGroupEvent;
import de.maxhenkel.voicechat.api.events.MicrophonePacketEvent;
import de.maxhenkel.voicechat.api.events.VoicechatServerStartedEvent;
import de.maxhenkel.voicechat.api.events.VoicechatServerStoppedEvent;
import eu.mhsl.voicebridge.protocol.Message;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitTask;
import java.net.InetSocketAddress;
import java.net.SocketException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Hängt sich in Simple Voice Chat: legt die Crosstalk-Gruppen an, schickt das Mikrofon-Audio
* ihrer Mitglieder an die Bridge und spielt das Audio von Discord/TeamSpeak in der Gruppe ab.
*/
final class BridgeVoicechat implements VoicechatPlugin {
static final String CATEGORY = "voicebridge";
private final VoiceBridgePlugin plugin;
private final InetSocketAddress bridgeAddress;
/** Räume nach Schlüssel und nach Gruppen-ID. Wird nur auf dem Main-Thread verändert. */
private final Map<String, CrosstalkRoom> rooms = new ConcurrentHashMap<>();
private final Map<UUID, CrosstalkRoom> roomsByGroup = new ConcurrentHashMap<>();
private volatile VoicechatServerApi api;
private volatile BridgeLink link;
private BukkitTask tickTask;
BridgeVoicechat(VoiceBridgePlugin plugin, InetSocketAddress bridgeAddress) {
this.plugin = plugin;
this.bridgeAddress = bridgeAddress;
}
@Override
public String getPluginId() {
return "voicebridge";
}
@Override
public void registerEvents(EventRegistration registration) {
registration.registerEvent(VoicechatServerStartedEvent.class, e -> start(e.getVoicechat()));
registration.registerEvent(VoicechatServerStoppedEvent.class, e -> stop());
registration.registerEvent(MicrophonePacketEvent.class, this::onMicrophone);
registration.registerEvent(JoinGroupEvent.class, this::onJoinGroup);
registration.registerEvent(CreateGroupEvent.class, this::onCreateGroup);
}
private void start(VoicechatServerApi api) {
this.api = api;
api.registerVolumeCategory(api.volumeCategoryBuilder()
.setId(CATEGORY)
.setName("Discord/TeamSpeak")
.setDescription("Lautstärke der Leute aus Discord und TeamSpeak in Crosstalk-Gruppen")
.build());
try {
link = new BridgeLink(bridgeAddress, plugin.getLogger(), this::onBridgeMessage);
link.start();
} catch (SocketException e) {
plugin.getLogger().severe("UDP-Socket zur Bridge ging nicht auf: " + e.getMessage());
return;
}
Bukkit.getScheduler().runTask(plugin, () ->
tickTask = Bukkit.getScheduler().runTaskTimer(plugin, this::tick, 20L, 20L));
plugin.getLogger().info("VoiceBridge spricht mit der Bridge auf " + bridgeAddress);
}
void stop() {
if (tickTask != null) {
tickTask.cancel();
tickTask = null;
}
if (link != null) {
link.close();
link = null;
}
}
/** Main-Thread, jede Sekunde: Mitglieder einsammeln, Herzschlag an die Bridge. */
private void tick() {
VoicechatServerApi api = this.api;
BridgeLink link = this.link;
if (api == null || link == null) return;
Map<UUID, List<VoicechatConnection>> byGroup = new HashMap<>();
for (Player player : Bukkit.getOnlinePlayers()) {
VoicechatConnection connection = api.getConnectionOf(player.getUniqueId());
if (connection == null || !connection.isConnected() || connection.isDisabled()) continue;
Group group = connection.getGroup();
if (group == null || !roomsByGroup.containsKey(group.getId())) continue;
byGroup.computeIfAbsent(group.getId(), k -> new ArrayList<>()).add(connection);
}
Map<String, Integer> counts = new HashMap<>();
for (CrosstalkRoom room : rooms.values()) {
List<VoicechatConnection> members = byGroup.getOrDefault(room.groupId(), List.of());
room.setMembers(members);
room.dropIdleSpeakers();
counts.put(room.key(), members.size());
}
link.sendHello(counts);
}
/** Netzwerk-Thread der Bridge-Verbindung. */
private void onBridgeMessage(Message message) {
switch (message) {
case Message.Rooms update -> Bukkit.getScheduler().runTask(plugin, () -> syncRooms(update.rooms()));
case Message.Audio audio -> {
CrosstalkRoom room = rooms.get(audio.room());
if (room != null) room.play(audio.source(), audio.opus());
}
case Message.Hello ignored -> {}
}
}
/** Main-Thread: Gruppen an die Räume der Bridge angleichen. */
private void syncRooms(List<Message.RoomInfo> infos) {
VoicechatServerApi api = this.api;
if (api == null) return;
Map<String, Message.RoomInfo> wanted = new HashMap<>();
for (Message.RoomInfo info : infos) wanted.put(info.key(), info);
for (CrosstalkRoom room : List.copyOf(rooms.values())) {
Message.RoomInfo info = wanted.get(room.key());
if (info == null || !info.name().equals(room.name()) || !info.groupId().equals(room.groupId())) {
removeRoom(room);
}
}
for (Message.RoomInfo info : infos) {
CrosstalkRoom room = rooms.get(info.key());
if (room == null) {
// api.getGroup() liefert für unbekannte IDs eine leere Hülle statt null
Group group = api.getGroups().stream()
.filter(g -> g.getId().equals(info.groupId()))
.findFirst().orElse(null);
if (group == null || !group.getName().equals(info.name())) {
if (group != null) api.removeGroup(group.getId());
group = api.groupBuilder()
.setId(info.groupId())
.setName(info.name())
.setPersistent(true)
.setType(Group.Type.NORMAL)
.build();
}
room = new CrosstalkRoom(api, info.key(), info.name(), group.getId());
rooms.put(room.key(), room);
roomsByGroup.put(room.groupId(), room);
plugin.getLogger().info("Crosstalk-Gruppe bereit: " + info.name());
}
room.setInfo(info);
}
}
private void removeRoom(CrosstalkRoom room) {
rooms.remove(room.key());
roomsByGroup.remove(room.groupId());
room.dropAllSpeakers();
if (api != null) api.removeGroup(room.groupId());
plugin.getLogger().info("Crosstalk-Gruppe entfernt: " + room.name());
}
/** Voicechat-Thread. */
private void onMicrophone(MicrophonePacketEvent event) {
BridgeLink link = this.link;
VoicechatConnection sender = event.getSenderConnection();
if (link == null || sender == null) return;
Group group = sender.getGroup();
if (group == null) return;
CrosstalkRoom room = roomsByGroup.get(group.getId());
if (room == null || !room.active() || !link.isBridgeAlive()) return;
link.sendAudio(room.key(), sender.getPlayer().getUuid(), event.getPacket().getOpusEncodedData());
}
private void onJoinGroup(JoinGroupEvent event) {
CrosstalkRoom room = roomsByGroup.get(event.getGroup().getId());
if (room == null) return;
UUID playerId = event.getConnection().getPlayer().getUuid();
BridgeLink link = this.link;
boolean alive = link != null && link.isBridgeAlive();
Bukkit.getScheduler().runTask(plugin, () -> {
Player player = Bukkit.getPlayer(playerId);
if (player == null) return;
player.sendMessage(Component.text(room.name() + " ist mit Discord und TeamSpeak verbunden. "
+ "Was du hier sagst, hören auch alle im gleichnamigen Channel dort.", NamedTextColor.GOLD));
Message.RoomInfo info = room.info();
if (!alive) {
player.sendMessage(Component.text("Die Verbindung zur Bridge ist gerade getrennt.", NamedTextColor.RED));
} else if (info != null) {
player.sendMessage(Component.text("Gerade dort: " + info.teamspeak() + " im TeamSpeak, "
+ info.discord() + " im Discord.", NamedTextColor.GRAY));
}
});
}
private void onCreateGroup(CreateGroupEvent event) {
VoicechatConnection connection = event.getConnection();
Group group = event.getGroup();
if (connection == null || group == null) return;
boolean reserved = rooms.values().stream()
.anyMatch(room -> room.name().equalsIgnoreCase(group.getName())
&& !Objects.equals(room.groupId(), group.getId()));
if (!reserved) return;
event.cancel();
UUID playerId = connection.getPlayer().getUuid();
Bukkit.getScheduler().runTask(plugin, () -> {
Player player = Bukkit.getPlayer(playerId);
if (player != null) player.sendMessage(Component.text(
"Der Name ist für die Crosstalk-Gruppe reserviert. Tritt einfach der bestehenden bei.",
NamedTextColor.RED));
});
}
}
@@ -0,0 +1,97 @@
package eu.mhsl.voicebridge.plugin;
import de.maxhenkel.voicechat.api.VoicechatConnection;
import de.maxhenkel.voicechat.api.VoicechatServerApi;
import de.maxhenkel.voicechat.api.audiochannel.StaticAudioChannel;
import eu.mhsl.voicebridge.protocol.Message;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Eine Crosstalk-Gruppe im Spiel. Jeder externe Sprecher (Discord-User, TS-Client) bekommt einen
* eigenen {@link StaticAudioChannel} an alle Gruppenmitglieder; gemischt wird im Client.
*/
final class CrosstalkRoom {
private static final long SPEAKER_IDLE_MILLIS = 1000;
private final VoicechatServerApi api;
private final String key;
private final String name;
private final UUID groupId;
private final Map<UUID, Speaker> speakers = new HashMap<>();
private List<VoicechatConnection> members = List.of();
private volatile Message.RoomInfo info;
private record Speaker(StaticAudioChannel channel, long[] lastPacket) {}
CrosstalkRoom(VoicechatServerApi api, String key, String name, UUID groupId) {
this.api = api;
this.key = key;
this.name = name;
this.groupId = groupId;
}
String key() { return key; }
String name() { return name; }
UUID groupId() { return groupId; }
Message.RoomInfo info() { return info; }
void setInfo(Message.RoomInfo info) { this.info = info; }
boolean active() {
Message.RoomInfo current = info;
return current != null && current.active();
}
synchronized void setMembers(List<VoicechatConnection> members) {
if (sameMembers(this.members, members)) return;
this.members = List.copyOf(members);
for (Speaker speaker : speakers.values()) applyTargets(speaker.channel());
}
synchronized void play(UUID source, byte[] opus) {
if (members.isEmpty()) return;
Speaker speaker = speakers.get(source);
if (speaker == null) {
StaticAudioChannel channel = api.createStaticAudioChannel(source);
if (channel == null) return;
channel.setCategory(BridgeVoicechat.CATEGORY);
applyTargets(channel);
speaker = new Speaker(channel, new long[1]);
speakers.put(source, speaker);
}
speaker.lastPacket()[0] = System.currentTimeMillis();
speaker.channel().send(opus);
}
synchronized void dropIdleSpeakers() {
long now = System.currentTimeMillis();
speakers.values().removeIf(speaker -> {
if (now - speaker.lastPacket()[0] < SPEAKER_IDLE_MILLIS) return false;
speaker.channel().flush();
return true;
});
}
synchronized void dropAllSpeakers() {
speakers.values().forEach(speaker -> speaker.channel().flush());
speakers.clear();
members = List.of();
}
private void applyTargets(StaticAudioChannel channel) {
channel.clearTargets();
for (VoicechatConnection member : members) channel.addTarget(member);
}
private static boolean sameMembers(List<VoicechatConnection> a, List<VoicechatConnection> b) {
if (a.size() != b.size()) return false;
for (int i = 0; i < a.size(); i++) {
if (!a.get(i).getPlayer().getUuid().equals(b.get(i).getPlayer().getUuid())) return false;
}
return true;
}
}
@@ -0,0 +1,33 @@
package eu.mhsl.voicebridge.plugin;
import de.maxhenkel.voicechat.api.BukkitVoicechatService;
import org.bukkit.plugin.java.JavaPlugin;
import java.net.InetSocketAddress;
public final class VoiceBridgePlugin extends JavaPlugin {
private BridgeVoicechat voicechat;
@Override
public void onEnable() {
saveDefaultConfig();
BukkitVoicechatService service = getServer().getServicesManager().load(BukkitVoicechatService.class);
if (service == null) {
getLogger().severe("Simple Voice Chat nicht gefunden, VoiceBridge bleibt aus.");
getServer().getPluginManager().disablePlugin(this);
return;
}
InetSocketAddress bridge = new InetSocketAddress(
getConfig().getString("bridge.host", "127.0.0.1"),
getConfig().getInt("bridge.port", 24470));
voicechat = new BridgeVoicechat(this, bridge);
service.registerPlugin(voicechat);
}
@Override
public void onDisable() {
if (voicechat != null) voicechat.stop();
}
}
+4
View File
@@ -0,0 +1,4 @@
# Adresse des Bridge-Dienstes (UDP). Die Räume selbst stehen in der Config der Bridge.
bridge:
host: 127.0.0.1
port: 24470
+8
View File
@@ -0,0 +1,8 @@
name: VoiceBridge
version: '${version}'
main: eu.mhsl.voicebridge.plugin.VoiceBridgePlugin
api-version: '1.21'
depend: [ voicechat ]
authors: [ MineTec ]
description: Verbindet Simple-Voice-Chat-Gruppen mit Discord und TeamSpeak
website: mhsl.eu