Voice-Bridge: Plugin, Bridge-Dienst und Protokoll
This commit is contained in:
commit
c0c290d3dc
35 files changed
+2497
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user