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

+8
View File
@@ -0,0 +1,8 @@
.gradle/
build/
*.iml
.idea/
run/
config.yml
!plugin/src/main/resources/config.yml
data/
+3
View File
@@ -0,0 +1,3 @@
[submodule "third_party/ts3j"]
path = third_party/ts3j
url = https://github.com/Manevolent/ts3j.git
+44
View File
@@ -0,0 +1,44 @@
apply plugin: 'application'
apply plugin: 'com.gradleup.shadow'
dependencies {
implementation project(':protocol')
implementation project(':ts3j')
implementation 'net.dv8tion:JDA:6.7.0'
// DAVE (Ende-zu-Ende-Verschlüsselung für Discord-Voice), braucht Java 25
implementation 'club.minnced:jdave-api:0.1.8'
implementation 'club.minnced:jdave-native-linux-x86-64:0.1.8'
// Opus in reinem Java fürs Mischen
implementation 'io.github.jaredmdobson:concentus:1.0.2'
implementation 'org.yaml:snakeyaml:2.7'
implementation 'org.slf4j:slf4j-simple:2.0.17'
testImplementation platform('org.junit:junit-bom:5.13.4')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
application {
mainClass = 'eu.mhsl.voicebridge.bridge.Main'
applicationDefaultJvmArgs = ['--enable-native-access=ALL-UNNAMED']
}
tasks.named('test') {
useJUnitPlatform()
}
shadowJar {
archiveFileName = 'voice-bridge.jar'
mergeServiceFiles()
// Signaturen von BouncyCastle passen nach dem Zusammenfügen nicht mehr
exclude 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA'
manifest {
attributes 'Enable-Native-Access': 'ALL-UNNAMED'
}
}
tasks.named('run') {
workingDir = rootProject.file('run/bridge')
doFirst { workingDir.mkdirs() }
}
+50
View File
@@ -0,0 +1,50 @@
# Config des Bridge-Dienstes. Die echte config.yml enthält Bot-Tokens und Passwörter:
# niemals ins Repo (steht in .gitignore).
plugin:
# Hier lauscht die Bridge auf das Paper-Plugin (UDP).
bind: 127.0.0.1
port: 24470
# Ordner für die TeamSpeak-Identitäten (werden beim ersten Start erzeugt).
dataDir: data
# Wie lange ein Raum bzw. ein Bot noch verbunden bleibt, nachdem er nicht mehr gebraucht wird.
lingerSeconds: 15
teamspeak:
# Leer lassen, um TeamSpeak abzuschalten.
host: ts.example.org
port: 9987
password: ""
# Unter diesem Channel legt die Bridge fehlende Raum-Channels an (Channel-ID, 0 = oberste Ebene).
parentChannel: 0
# Name des Beobachter-Clients, der die Channels im Blick behält.
nickname: "Crosstalk"
# Name der Raum-Clients; %room% wird durch den Raumnamen ersetzt.
roomNickname: "%room% (Minecraft/Discord)"
securityLevel: 8
# Optional, falls der Server eine bestimmte Client-Version verlangt:
# clientVersion:
# platform: "Linux"
# version: "3.6.2 [Build: ...]"
# sign: "..."
discord:
guild: "000000000000000000"
# In dieser Kategorie legt die Bridge fehlende Raum-Channels an (leer = ohne Kategorie).
category: "000000000000000000"
# Server-Nickname der Bots; %room% wird durch den Raumnamen ersetzt.
nickname: "%room% (Minecraft/TS)"
rooms:
- key: crosstalk-1
name: Crosstalk 1
# Ein eigener Bot pro Raum (Discord erlaubt pro Bot nur einen Voice-Channel). Leer = ohne Discord.
discordToken: ""
# Optional feste Channels statt automatisch anlegen:
# discordChannel: "000000000000000000"
# teamspeakChannel: 0
- key: crosstalk-2
name: Crosstalk 2
discordToken: ""
@@ -0,0 +1,128 @@
package eu.mhsl.voicebridge.bridge;
import eu.mhsl.voicebridge.bridge.discord.DiscordRoomBot;
import eu.mhsl.voicebridge.bridge.teamspeak.TeamspeakSide;
import eu.mhsl.voicebridge.protocol.Message;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Steuert die Räume: jede Sekunde wird gezählt, wer auf welcher Seite ist. Ein Raum ist aktiv, sobald
* auf mindestens zwei Seiten jemand ist. Die Bots einer Seite verbinden sich nur, wenn der Raum aktiv
* ist und auf ihrer Seite jemand sitzt; danach bleiben sie noch {@code lingerSeconds} verbunden.
*/
public final class Bridge {
private static final Logger LOG = LoggerFactory.getLogger(Bridge.class);
private final BridgeConfig config;
private final Map<String, Room> rooms = new LinkedHashMap<>();
private final Map<String, DiscordRoomBot> discordBots = new HashMap<>();
private final Map<String, Presence> presence = new HashMap<>();
private final PluginLink plugin;
private final TeamspeakSide teamspeak;
private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor(
Thread.ofPlatform().name("bridge-tick").factory());
private volatile List<Message.RoomInfo> roomInfos = List.of();
/** Zeitpunkte, bis zu denen Raum bzw. Bot noch gebraucht werden (für das Nachlaufen). */
private static final class Presence {
long roomWanted, teamspeakWanted, discordWanted;
boolean teamspeakConnected, discordConnected;
}
public Bridge(BridgeConfig config) throws IOException {
this.config = config;
for (BridgeConfig.RoomConfig roomConfig : config.rooms()) {
Room room = new Room(roomConfig);
rooms.put(room.key(), room);
presence.put(room.key(), new Presence());
if (roomConfig.discordEnabled()) discordBots.put(room.key(), new DiscordRoomBot(config.discord(), room));
}
this.plugin = new PluginLink(config.pluginBind(), config.pluginPort(), rooms, () -> roomInfos);
for (Room room : rooms.values()) room.setMinecraftSink(plugin::sendAudio);
this.teamspeak = config.teamspeak().enabled()
? new TeamspeakSide(config.teamspeak(), config.dataDir(), List.copyOf(rooms.values()))
: null;
roomInfos = buildInfos();
}
public void start() {
LOG.info("Starte Bridge mit {} Räumen (TeamSpeak {}, Discord-Bots {})", rooms.size(),
teamspeak != null ? "an" : "aus", discordBots.size());
plugin.start();
if (teamspeak != null) teamspeak.start();
discordBots.values().forEach(DiscordRoomBot::start);
ticker.scheduleAtFixedRate(this::safeTick, 1, 1, TimeUnit.SECONDS);
}
private void safeTick() {
try {
tick();
} catch (RuntimeException e) {
LOG.error("Fehler im Bridge-Takt", e);
}
}
private void tick() {
long now = System.currentTimeMillis();
long linger = config.lingerMillis();
for (Room room : rooms.values()) {
Presence p = presence.get(room.key());
DiscordRoomBot bot = discordBots.get(room.key());
int mc = plugin.minecraftMembers(room.key());
int ts = teamspeak != null ? teamspeak.humansIn(room) : 0;
int dc = bot != null ? bot.humans() : 0;
int sides = (mc > 0 ? 1 : 0) + (ts > 0 ? 1 : 0) + (dc > 0 ? 1 : 0);
if (sides >= 2) p.roomWanted = now;
boolean active = now - p.roomWanted < linger;
room.setActive(active);
if (active && ts > 0) p.teamspeakWanted = now;
boolean tsConnect = active && now - p.teamspeakWanted < linger;
if (teamspeak != null && (tsConnect || p.teamspeakConnected)) {
teamspeak.setRoomConnected(room, tsConnect);
}
p.teamspeakConnected = tsConnect;
if (active && dc > 0) p.discordWanted = now;
boolean dcConnect = active && now - p.discordWanted < linger;
if (bot != null && dcConnect != p.discordConnected) bot.setConnected(dcConnect);
p.discordConnected = dcConnect;
room.dropIdleSources();
}
roomInfos = buildInfos();
}
private List<Message.RoomInfo> buildInfos() {
List<Message.RoomInfo> infos = new ArrayList<>(rooms.size());
for (Room room : rooms.values()) {
DiscordRoomBot bot = discordBots.get(room.key());
infos.add(new Message.RoomInfo(room.key(), room.name(), room.groupId(),
teamspeak != null ? teamspeak.humansIn(room) : 0,
bot != null ? bot.humans() : 0,
room.active()));
}
return infos;
}
public void stop() {
ticker.shutdownNow();
plugin.close();
if (teamspeak != null) teamspeak.stop();
discordBots.values().forEach(DiscordRoomBot::stop);
}
}
@@ -0,0 +1,98 @@
package eu.mhsl.voicebridge.bridge;
import org.yaml.snakeyaml.Yaml;
import java.io.IOException;
import java.io.Reader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public record BridgeConfig(
String pluginBind,
int pluginPort,
Path dataDir,
long lingerMillis,
Teamspeak teamspeak,
Discord discord,
List<RoomConfig> rooms
) {
public record Teamspeak(String host, int port, String password, int parentChannel, String nickname,
String roomNickname, int securityLevel, ClientVersion clientVersion) {
public boolean enabled() { return !host.isBlank(); }
}
public record ClientVersion(String platform, String version, String sign) {}
public record Discord(String guild, String category, String nickname) {}
public record RoomConfig(String key, String name, String discordToken, String discordChannel,
int teamspeakChannel) {
public boolean discordEnabled() { return !discordToken.isBlank(); }
}
public static BridgeConfig load(Path file) throws IOException {
Map<String, Object> root;
try (Reader reader = Files.newBufferedReader(file)) {
root = new Yaml().load(reader);
}
if (root == null) throw new IOException(file + " ist leer");
Map<String, Object> plugin = section(root, "plugin");
Map<String, Object> ts = section(root, "teamspeak");
Map<String, Object> dc = section(root, "discord");
ClientVersion version = null;
if (ts.get("clientVersion") instanceof Map<?, ?> v) {
@SuppressWarnings("unchecked") Map<String, Object> cv = (Map<String, Object>) v;
version = new ClientVersion(string(cv, "platform", ""), string(cv, "version", ""), string(cv, "sign", ""));
}
List<RoomConfig> rooms = new ArrayList<>();
Set<String> keys = new HashSet<>();
if (root.get("rooms") instanceof List<?> list) {
for (Object entry : list) {
if (!(entry instanceof Map<?, ?> raw)) continue;
@SuppressWarnings("unchecked") Map<String, Object> room = (Map<String, Object>) raw;
String key = string(room, "key", "");
if (key.isBlank()) throw new IOException("Raum ohne key in " + file);
if (!keys.add(key)) throw new IOException("Raum-key doppelt: " + key);
rooms.add(new RoomConfig(key, string(room, "name", key), string(room, "discordToken", ""),
string(room, "discordChannel", ""), integer(room, "teamspeakChannel", 0)));
}
}
return new BridgeConfig(
string(plugin, "bind", "127.0.0.1"),
integer(plugin, "port", 24470),
Path.of(string(root, "dataDir", "data")),
integer(root, "lingerSeconds", 15) * 1000L,
new Teamspeak(string(ts, "host", ""), integer(ts, "port", 9987), string(ts, "password", ""),
integer(ts, "parentChannel", 0), string(ts, "nickname", "Crosstalk"),
string(ts, "roomNickname", "%room% (Minecraft/Discord)"), integer(ts, "securityLevel", 8), version),
new Discord(string(dc, "guild", ""), string(dc, "category", ""),
string(dc, "nickname", "%room% (Minecraft/TS)")),
List.copyOf(rooms));
}
@SuppressWarnings("unchecked")
private static Map<String, Object> section(Map<String, Object> map, String key) {
return map.get(key) instanceof Map<?, ?> m ? (Map<String, Object>) m : Map.of();
}
private static String string(Map<String, Object> map, String key, String def) {
Object value = map.get(key);
return value == null ? def : value.toString().trim();
}
private static int integer(Map<String, Object> map, String key, int def) {
Object value = map.get(key);
if (value == null) return def;
if (value instanceof Number n) return n.intValue();
return Integer.parseInt(value.toString().trim());
}
}
@@ -0,0 +1,25 @@
package eu.mhsl.voicebridge.bridge;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.file.Files;
import java.nio.file.Path;
public final class Main {
private static final Logger LOG = LoggerFactory.getLogger(Main.class);
private Main() {}
public static void main(String[] args) throws Exception {
Path configFile = Path.of(args.length > 0 ? args[0] : "config.yml");
if (!Files.exists(configFile)) {
LOG.error("{} fehlt. Vorlage: bridge/config.example.yml", configFile.toAbsolutePath());
System.exit(1);
}
Bridge bridge = new Bridge(BridgeConfig.load(configFile));
Runtime.getRuntime().addShutdownHook(new Thread(bridge::stop, "shutdown"));
bridge.start();
}
}
@@ -0,0 +1,114 @@
package eu.mhsl.voicebridge.bridge;
import eu.mhsl.voicebridge.protocol.Message;
import eu.mhsl.voicebridge.protocol.Protocol;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
/** UDP-Gegenstelle zum Paper-Plugin. Das Plugin meldet sich jede Sekunde mit einem Hello. */
final class PluginLink {
private static final Logger LOG = LoggerFactory.getLogger(PluginLink.class);
private static final long ALIVE_MILLIS = 5000;
private final DatagramSocket socket;
private final Map<String, Room> rooms;
private final Supplier<List<Message.RoomInfo>> roomInfos;
private final Map<String, Integer> minecraftMembers = new ConcurrentHashMap<>();
private volatile SocketAddress plugin;
private volatile long lastHello;
private boolean warnedVersion;
PluginLink(String bind, int port, Map<String, Room> rooms, Supplier<List<Message.RoomInfo>> roomInfos)
throws IOException {
this.socket = new DatagramSocket(new InetSocketAddress(bind, port));
this.rooms = rooms;
this.roomInfos = roomInfos;
}
void start() {
Thread.ofPlatform().daemon().name("plugin-link").start(this::receiveLoop);
LOG.info("Warte auf das Paper-Plugin auf {}", socket.getLocalSocketAddress());
}
boolean alive() {
return System.currentTimeMillis() - lastHello < ALIVE_MILLIS;
}
int minecraftMembers(String room) {
return alive() ? minecraftMembers.getOrDefault(room, 0) : 0;
}
void sendAudio(String room, UUID source, byte[] opus) {
SocketAddress target = plugin;
if (target == null || !alive()) return;
send(new Message.Audio(room, source, opus), target);
}
private void send(Message message, SocketAddress target) {
byte[] data = Protocol.encode(message);
try {
socket.send(new DatagramPacket(data, data.length, target));
} catch (IOException e) {
if (!socket.isClosed()) LOG.debug("Senden ans Plugin fehlgeschlagen: {}", e.getMessage());
}
}
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);
handle(Protocol.decode(packet.getData(), packet.getOffset(), packet.getLength()),
packet.getSocketAddress());
} catch (IOException e) {
if (!socket.isClosed()) LOG.warn("Ungültiges Paket vom Plugin: {}", e.getMessage());
} catch (RuntimeException e) {
LOG.warn("Fehler beim Verarbeiten eines Plugin-Pakets", e);
}
}
}
private void handle(Message message, SocketAddress from) {
switch (message) {
case Message.Hello hello -> {
if (hello.version() != Protocol.VERSION) {
if (!warnedVersion) LOG.error("Plugin spricht Protokoll {}, Bridge {}: bitte beide aktualisieren",
hello.version(), Protocol.VERSION);
warnedVersion = true;
return;
}
if (!alive() || !from.equals(plugin)) LOG.info("Paper-Plugin verbunden von {}", from);
plugin = from;
lastHello = System.currentTimeMillis();
minecraftMembers.clear();
minecraftMembers.putAll(hello.members());
send(new Message.Rooms(new ArrayList<>(roomInfos.get())), from);
}
case Message.Audio audio -> {
if (!from.equals(plugin)) return;
Room room = rooms.get(audio.room());
if (room != null) room.fromMinecraft(audio.source(), audio.opus());
}
case Message.Rooms ignored -> {}
}
}
void close() {
socket.close();
}
}
@@ -0,0 +1,131 @@
package eu.mhsl.voicebridge.bridge;
import eu.mhsl.voicebridge.bridge.audio.FrameSplitter;
import eu.mhsl.voicebridge.bridge.audio.Mixer;
import eu.mhsl.voicebridge.bridge.audio.Opus;
import io.github.jaredmdobson.concentus.OpusDecoder;
import io.github.jaredmdobson.concentus.OpusEncoder;
import io.github.jaredmdobson.concentus.OpusException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Ein Crosstalk-Raum aus drei Seiten. Jede Seite bekommt das Audio der beiden anderen:
* Discord und TeamSpeak je einen gemischten Stream, Minecraft jeden Sprecher einzeln.
*/
public final class Room {
private static final Logger LOG = LoggerFactory.getLogger(Room.class);
private static final long SOURCE_IDLE_MILLIS = 5000;
public enum Side { MINECRAFT, TEAMSPEAK, DISCORD }
private final BridgeConfig.RoomConfig config;
private final UUID groupId;
private final Mixer toTeamspeak = new Mixer();
private final Mixer toDiscord = new Mixer();
private final Map<String, Source> sources = new ConcurrentHashMap<>();
/** Wohin Frames für Minecraft gehen (Plugin-Verbindung). */
private volatile MinecraftSink minecraft = (room, source, opus) -> {};
private volatile boolean active;
public interface MinecraftSink {
void send(String room, UUID source, byte[] opus);
}
/** Ein Sprecher: eigener Decoder (Opus ist zustandsbehaftet) und für Minecraft ein eigener Encoder. */
private final class Source {
final Side side;
final String key;
final UUID uuid;
final OpusDecoder decoder = Opus.decoder();
final FrameSplitter splitter = new FrameSplitter();
OpusEncoder minecraftEncoder;
volatile long lastSeen;
Source(Side side, String key) {
this.side = side;
this.key = key;
this.uuid = UUID.nameUUIDFromBytes(("voicebridge:" + config.key() + ":" + key).getBytes(StandardCharsets.UTF_8));
}
}
public Room(BridgeConfig.RoomConfig config) {
this.config = config;
this.groupId = UUID.nameUUIDFromBytes(("voicebridge-group:" + config.key()).getBytes(StandardCharsets.UTF_8));
}
public BridgeConfig.RoomConfig config() { return config; }
public String key() { return config.key(); }
public String name() { return config.name(); }
public UUID groupId() { return groupId; }
public boolean active() { return active; }
public Mixer toTeamspeak() { return toTeamspeak; }
public Mixer toDiscord() { return toDiscord; }
public void setMinecraftSink(MinecraftSink sink) { this.minecraft = sink; }
void setActive(boolean active) {
if (this.active == active) return;
this.active = active;
if (!active) {
toTeamspeak.clear();
toDiscord.clear();
sources.clear();
}
LOG.info("{}: {}", name(), active ? "aktiv" : "inaktiv");
}
public void fromMinecraft(UUID player, byte[] opus) {
receive(Side.MINECRAFT, "mc:" + player, opus);
}
public void fromTeamspeak(int clientId, byte[] opus) {
receive(Side.TEAMSPEAK, "ts:" + clientId, opus);
}
public void fromDiscord(long userId, byte[] opus) {
receive(Side.DISCORD, "dc:" + userId, opus);
}
private void receive(Side side, String key, byte[] opus) {
if (!active || opus.length == 0) return;
Source source = sources.computeIfAbsent(key, k -> new Source(side, k));
source.lastSeen = System.currentTimeMillis();
List<short[]> frames;
try {
frames = source.splitter.push(Opus.decode(source.decoder, opus));
} catch (OpusException e) {
LOG.debug("{}: Opus-Paket von {} nicht dekodierbar: {}", name(), key, e.getMessage());
return;
}
for (short[] frame : frames) {
if (side != Side.TEAMSPEAK) toTeamspeak.push(key, frame);
if (side != Side.DISCORD) toDiscord.push(key, frame);
if (side != Side.MINECRAFT) sendToMinecraft(source, frame);
}
}
private void sendToMinecraft(Source source, short[] frame) {
try {
if (source.minecraftEncoder == null) source.minecraftEncoder = Opus.encoder(1, 48000);
minecraft.send(key(), source.uuid, Opus.encodeMono(source.minecraftEncoder, frame));
} catch (OpusException e) {
LOG.debug("{}: Kodieren für Minecraft fehlgeschlagen: {}", name(), e.getMessage());
}
}
void dropIdleSources() {
long now = System.currentTimeMillis();
sources.values().removeIf(source -> now - source.lastSeen > SOURCE_IDLE_MILLIS);
}
}
@@ -0,0 +1,26 @@
package eu.mhsl.voicebridge.bridge.audio;
import java.util.ArrayList;
import java.util.List;
/** Zerlegt dekodiertes PCM beliebiger Länge in Frames zu genau 20 ms. */
public final class FrameSplitter {
private final short[] pending = new short[Opus.FRAME_SAMPLES];
private int filled;
public List<short[]> push(short[] pcm) {
List<short[]> frames = new ArrayList<>(pcm.length / Opus.FRAME_SAMPLES + 1);
int offset = 0;
while (offset < pcm.length) {
int take = Math.min(Opus.FRAME_SAMPLES - filled, pcm.length - offset);
System.arraycopy(pcm, offset, pending, filled, take);
filled += take;
offset += take;
if (filled == Opus.FRAME_SAMPLES) {
frames.add(pending.clone());
filled = 0;
}
}
return frames;
}
}
@@ -0,0 +1,72 @@
package eu.mhsl.voicebridge.bridge.audio;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
/**
* Mischt die Sprecher für eine Ausgabe (Discord- oder TS-Bot), die alle 20 ms einen Frame abholt.
* Jeder Sprecher hat einen kleinen Jitter-Puffer: abgespielt wird erst ab {@link #PRIME} Frames,
* über {@link #MAX} Frames werden die ältesten verworfen, damit die Verzögerung nicht wächst.
*/
public final class Mixer {
static final int PRIME = 2;
static final int MAX = 8;
private static final long IDLE_MILLIS = 2000;
private static final class Queue {
final ArrayDeque<short[]> frames = new ArrayDeque<>();
boolean playing;
long lastPush;
}
private final Map<String, Queue> queues = new HashMap<>();
public synchronized void push(String source, short[] frame) {
Queue queue = queues.computeIfAbsent(source, k -> new Queue());
queue.frames.addLast(frame);
while (queue.frames.size() > MAX) queue.frames.removeFirst();
queue.lastPush = System.currentTimeMillis();
}
public synchronized boolean ready() {
for (Queue queue : queues.values()) {
if (queue.playing ? !queue.frames.isEmpty() : queue.frames.size() >= PRIME) return true;
}
return false;
}
/** Nächster gemischter Frame, oder {@code null}, wenn gerade niemand spricht. */
public synchronized short[] pull() {
int[] sum = null;
long now = System.currentTimeMillis();
Iterator<Queue> it = queues.values().iterator();
while (it.hasNext()) {
Queue queue = it.next();
if (!queue.playing && queue.frames.size() >= PRIME) queue.playing = true;
if (!queue.playing) {
if (queue.frames.isEmpty() && now - queue.lastPush > IDLE_MILLIS) it.remove();
continue;
}
short[] frame = queue.frames.pollFirst();
if (frame == null) {
queue.playing = false;
continue;
}
if (sum == null) sum = new int[Opus.FRAME_SAMPLES];
for (int i = 0; i < Opus.FRAME_SAMPLES; i++) sum[i] += frame[i];
}
if (sum == null) return null;
short[] out = new short[Opus.FRAME_SAMPLES];
for (int i = 0; i < Opus.FRAME_SAMPLES; i++) {
out[i] = (short) Math.max(Short.MIN_VALUE, Math.min(Short.MAX_VALUE, sum[i]));
}
return out;
}
public synchronized void clear() {
queues.clear();
}
}
@@ -0,0 +1,67 @@
package eu.mhsl.voicebridge.bridge.audio;
import io.github.jaredmdobson.concentus.OpusApplication;
import io.github.jaredmdobson.concentus.OpusDecoder;
import io.github.jaredmdobson.concentus.OpusEncoder;
import io.github.jaredmdobson.concentus.OpusException;
import io.github.jaredmdobson.concentus.OpusSignal;
import java.util.Arrays;
/** Alles läuft intern mit 48 kHz mono in Frames zu 20 ms. */
public final class Opus {
public static final int SAMPLE_RATE = 48000;
public static final int FRAME_SAMPLES = 960;
/** Längstes Opus-Paket: 120 ms. */
private static final int MAX_PACKET_SAMPLES = 5760;
private static final int MAX_PACKET_BYTES = 1275;
private Opus() {}
public static OpusDecoder decoder() {
try {
return new OpusDecoder(SAMPLE_RATE, 1);
} catch (OpusException e) {
throw new IllegalStateException(e);
}
}
/** Encoder für Sprache, mono oder stereo. */
public static OpusEncoder encoder(int channels, int bitrate) {
try {
OpusEncoder encoder = new OpusEncoder(SAMPLE_RATE, channels, OpusApplication.OPUS_APPLICATION_VOIP);
encoder.setBitrate(bitrate);
encoder.setSignalType(OpusSignal.OPUS_SIGNAL_VOICE);
encoder.setComplexity(5);
return encoder;
} catch (OpusException e) {
throw new IllegalStateException(e);
}
}
/** Dekodiert ein Paket zu PCM (mono); leere Pakete ergeben ein leeres Array. */
public static short[] decode(OpusDecoder decoder, byte[] packet) throws OpusException {
if (packet.length == 0) return new short[0];
short[] pcm = new short[MAX_PACKET_SAMPLES];
int samples = decoder.decode(packet, 0, packet.length, pcm, 0, MAX_PACKET_SAMPLES, false);
return Arrays.copyOf(pcm, samples);
}
public static byte[] encodeMono(OpusEncoder encoder, short[] frame) throws OpusException {
byte[] out = new byte[MAX_PACKET_BYTES];
int length = encoder.encode(frame, 0, FRAME_SAMPLES, out, 0, out.length);
return Arrays.copyOf(out, length);
}
/** Für Discord: mono auf beide Kanäle verteilen. */
public static byte[] encodeStereo(OpusEncoder encoder, short[] frame) throws OpusException {
short[] stereo = new short[FRAME_SAMPLES * 2];
for (int i = 0; i < FRAME_SAMPLES; i++) {
stereo[2 * i] = frame[i];
stereo[2 * i + 1] = frame[i];
}
byte[] out = new byte[MAX_PACKET_BYTES];
int length = encoder.encode(stereo, 0, FRAME_SAMPLES, out, 0, out.length);
return Arrays.copyOf(out, length);
}
}
@@ -0,0 +1,190 @@
package eu.mhsl.voicebridge.bridge.discord;
import club.minnced.discord.jdave.interop.JDaveSessionFactory;
import eu.mhsl.voicebridge.bridge.BridgeConfig;
import eu.mhsl.voicebridge.bridge.Room;
import eu.mhsl.voicebridge.bridge.audio.Opus;
import io.github.jaredmdobson.concentus.OpusEncoder;
import io.github.jaredmdobson.concentus.OpusException;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
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.OpusPacket;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.concrete.Category;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import net.dv8tion.jda.api.managers.AudioManager;
import net.dv8tion.jda.api.requests.GatewayIntent;
import net.dv8tion.jda.api.utils.MemberCachePolicy;
import net.dv8tion.jda.api.utils.cache.CacheFlag;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Ein Discord-Bot pro Raum (ein Bot kann pro Server nur in einem Voice-Channel sein).
* Der Bot ist dauerhaft am Gateway, sieht so wer im Channel ist, und tritt nur bei Bedarf bei.
*/
public final class DiscordRoomBot implements AudioSendHandler, AudioReceiveHandler {
private static final Logger LOG = LoggerFactory.getLogger(DiscordRoomBot.class);
private final BridgeConfig.Discord config;
private final Room room;
private final OpusEncoder encoder = Opus.encoder(2, 64000);
private final ExecutorService executor;
private volatile JDA jda;
private volatile Guild guild;
private volatile long channelId;
private volatile boolean joined;
public DiscordRoomBot(BridgeConfig.Discord config, Room room) {
this.config = config;
this.room = room;
this.executor = Executors.newSingleThreadExecutor(
Thread.ofPlatform().daemon().name("discord-" + room.key()).factory());
}
public void start() {
CompletableFuture.runAsync(this::login, executor).exceptionally(e -> {
LOG.error("{}: Discord-Bot konnte nicht starten: {}", room.name(), e.toString());
return null;
});
}
private void login() {
try {
jda = JDABuilder.createLight(room.config().discordToken(), GatewayIntent.GUILD_VOICE_STATES)
.enableCache(CacheFlag.VOICE_STATE)
.setMemberCachePolicy(MemberCachePolicy.VOICE)
.setAudioModuleConfig(new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))
.build()
.awaitReady();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
guild = jda.getGuildById(config.guild());
if (guild == null) {
LOG.error("{}: Bot {} ist nicht auf dem Discord-Server {}", room.name(), jda.getSelfUser().getName(), config.guild());
return;
}
VoiceChannel channel = resolveChannel(guild);
channelId = channel.getIdLong();
LOG.info("{}: Discord-Bot {} bereit, Channel #{}", room.name(), jda.getSelfUser().getName(), channel.getName());
String nickname = config.nickname().replace("%room%", room.name());
if (!nickname.equals(guild.getSelfMember().getNickname())) {
guild.getSelfMember().modifyNickname(nickname).queue(null,
e -> LOG.debug("{}: Nickname nicht gesetzt: {}", room.name(), e.getMessage()));
}
}
private VoiceChannel resolveChannel(Guild guild) {
String configured = room.config().discordChannel();
if (!configured.isBlank()) {
VoiceChannel channel = guild.getVoiceChannelById(configured);
if (channel == null) throw new IllegalStateException("Voice-Channel " + configured + " gibt es nicht");
return channel;
}
Category category = config.category().isBlank() ? null : guild.getCategoryById(config.category());
if (!config.category().isBlank() && category == null) {
throw new IllegalStateException("Kategorie " + config.category() + " gibt es nicht");
}
for (VoiceChannel channel : category != null ? category.getVoiceChannels() : guild.getVoiceChannels()) {
if (channel.getName().equals(room.name()) && (category != null || channel.getParentCategory() == null)) {
return channel;
}
}
LOG.info("{}: lege Discord-Voice-Channel an", room.name());
return category != null
? category.createVoiceChannel(room.name()).complete()
: guild.createVoiceChannel(room.name()).complete();
}
/** Menschen im Raum-Channel (Bots zählen nicht). */
public int humans() {
Guild guild = this.guild;
if (guild == null || channelId == 0) return 0;
VoiceChannel channel = guild.getVoiceChannelById(channelId);
if (channel == null) return 0;
int count = 0;
for (Member member : channel.getMembers()) if (!member.getUser().isBot()) count++;
return count;
}
public void setConnected(boolean connected) {
Guild guild = this.guild;
if (guild == null || connected == joined) return;
joined = connected;
AudioManager audio = guild.getAudioManager();
if (connected) {
VoiceChannel channel = guild.getVoiceChannelById(channelId);
if (channel == null) return;
audio.setSendingHandler(this);
audio.setReceivingHandler(this);
audio.setSelfDeafened(false);
audio.setSelfMuted(false);
audio.openAudioConnection(channel);
LOG.info("{}: Discord-Bot betritt #{}", room.name(), channel.getName());
} else {
audio.closeAudioConnection();
LOG.info("{}: Discord-Bot verlässt den Channel", room.name());
}
}
public void stop() {
JDA jda = this.jda;
if (jda != null) jda.shutdown();
executor.shutdownNow();
}
// --- Senden: JDA fragt alle 20 ms ---
@Override
public boolean canProvide() {
return room.toDiscord().ready();
}
@Override
public ByteBuffer provide20MsAudio() {
short[] frame = room.toDiscord().pull();
if (frame == null) return null;
try {
return ByteBuffer.wrap(Opus.encodeStereo(encoder, frame));
} catch (OpusException e) {
return null;
}
}
@Override
public boolean isOpus() {
return true;
}
// --- Empfangen: Opus pro User, bereits entschlüsselt ---
@Override
public boolean canReceiveEncoded() {
return true;
}
@Override
public void handleEncodedAudio(@NotNull OpusPacket packet) {
Guild guild = this.guild;
if (guild == null) return;
Member member = guild.getMemberById(packet.getUserId());
if (member != null && member.getUser().isBot()) return;
room.fromDiscord(packet.getUserId(), packet.getOpusAudio());
}
}
@@ -0,0 +1,112 @@
package eu.mhsl.voicebridge.bridge.teamspeak;
import com.github.manevolent.ts3j.audio.Microphone;
import com.github.manevolent.ts3j.enums.CodecType;
import com.github.manevolent.ts3j.event.DisconnectedEvent;
import com.github.manevolent.ts3j.event.TS3Listener;
import com.github.manevolent.ts3j.identity.LocalIdentity;
import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice;
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket;
import eu.mhsl.voicebridge.bridge.BridgeConfig;
import eu.mhsl.voicebridge.bridge.Room;
import eu.mhsl.voicebridge.bridge.audio.Opus;
import io.github.jaredmdobson.concentus.OpusEncoder;
import io.github.jaredmdobson.concentus.OpusException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.InetSocketAddress;
import java.util.function.Consumer;
/** Ein TS-Client im Raum-Channel: spielt die Mischung aus Minecraft und Discord ab und hört zu. */
final class TeamspeakRoomClient implements Microphone, TS3Listener {
private static final Logger LOG = LoggerFactory.getLogger(TeamspeakRoomClient.class);
private final BridgeConfig.Teamspeak config;
private final Room room;
private final int channel;
private final LocalTeamspeakClientSocket socket;
private final Consumer<TeamspeakRoomClient> onLost;
private final OpusEncoder encoder = Opus.encoder(1, 48000);
private volatile boolean closing;
TeamspeakRoomClient(BridgeConfig.Teamspeak config, Room room, int channel, LocalIdentity identity,
Consumer<TeamspeakRoomClient> onLost) {
this.config = config;
this.room = room;
this.channel = channel;
this.onLost = onLost;
this.socket = newSocket(config, identity, config.roomNickname().replace("%room%", room.name()));
socket.addListener(this);
socket.setVoiceHandler(this::onVoice);
socket.setWhisperHandler(packet -> {});
socket.setMicrophone(this);
}
static LocalTeamspeakClientSocket newSocket(BridgeConfig.Teamspeak config, LocalIdentity identity, String nickname) {
LocalTeamspeakClientSocket socket = new LocalTeamspeakClientSocket();
socket.setIdentity(identity);
socket.setNickname(nickname);
BridgeConfig.ClientVersion version = config.clientVersion();
if (version != null) socket.setClientVersion(version.platform(), version.version(), version.sign());
socket.setExceptionHandler(e -> LOG.debug("ts3j: {}", e.toString()));
return socket;
}
void connect() throws Exception {
socket.connect(new InetSocketAddress(config.host(), config.port()),
TeamspeakSide.emptyToNull(config.password()), 15000L);
socket.joinChannel(channel, null);
}
int clientId() {
return socket.getClientId();
}
void disconnect() {
closing = true;
try {
socket.disconnect();
} catch (Exception ignored) {
// Verbindung ist ohnehin weg
}
}
private void onVoice(PacketBody0Voice packet) {
CodecType codec = packet.getCodecType();
if (codec != CodecType.OPUS_VOICE && codec != CodecType.OPUS_MUSIC) return;
byte[] data = packet.getCodecData();
if (data == null || data.length == 0) return;
room.fromTeamspeak(packet.getClientId(), data);
}
@Override
public void onDisconnected(DisconnectedEvent e) {
if (closing) return;
LOG.warn("{}: TeamSpeak-Client vom Server getrennt", room.name());
onLost.accept(this);
}
// --- Microphone: ts3j fragt alle 20 ms ---
@Override
public boolean isReady() {
return room.toTeamspeak().ready();
}
@Override
public CodecType getCodec() {
return CodecType.OPUS_VOICE;
}
@Override
public byte[] provide() {
short[] frame = room.toTeamspeak().pull();
if (frame == null) return new byte[0];
try {
return Opus.encodeMono(encoder, frame);
} catch (OpusException e) {
return new byte[0];
}
}
}
@@ -0,0 +1,289 @@
package eu.mhsl.voicebridge.bridge.teamspeak;
import com.github.manevolent.ts3j.command.SingleCommand;
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
import com.github.manevolent.ts3j.event.ChannelCreateEvent;
import com.github.manevolent.ts3j.event.ChannelDeletedEvent;
import com.github.manevolent.ts3j.event.ChannelEditedEvent;
import com.github.manevolent.ts3j.event.ChannelListEvent;
import com.github.manevolent.ts3j.event.ChannelMovedEvent;
import com.github.manevolent.ts3j.event.ClientJoinEvent;
import com.github.manevolent.ts3j.event.ClientLeaveEvent;
import com.github.manevolent.ts3j.event.ClientMovedEvent;
import com.github.manevolent.ts3j.event.DisconnectedEvent;
import com.github.manevolent.ts3j.event.TS3Listener;
import com.github.manevolent.ts3j.identity.LocalIdentity;
import com.github.manevolent.ts3j.protocol.ProtocolRole;
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket;
import eu.mhsl.voicebridge.bridge.BridgeConfig;
import eu.mhsl.voicebridge.bridge.Room;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* TeamSpeak-Anbindung. Ein Beobachter-Client bleibt dauerhaft verbunden, sieht so alle Clients und
* Channels und legt fehlende Raum-Channels an. Pro aktivem Raum verbindet sich zusätzlich ein
* {@link TeamspeakRoomClient}, der im Raum-Channel spricht und zuhört.
*/
public final class TeamspeakSide implements TS3Listener {
private static final Logger LOG = LoggerFactory.getLogger(TeamspeakSide.class);
private static final long RECONNECT_SECONDS = 30;
/** client_type 0 = normaler Client, 1 = ServerQuery. */
private static final int CLIENT_TYPE_VOICE = 0;
private record TsClient(int channel, int type) {}
private record TsChannel(int parent, String name) {}
private final BridgeConfig.Teamspeak config;
private final Path dataDir;
private final List<Room> rooms;
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(
Thread.ofPlatform().daemon().name("teamspeak").factory());
private final Map<Integer, TsClient> clients = new ConcurrentHashMap<>();
private final Map<Integer, TsChannel> channels = new ConcurrentHashMap<>();
private final Map<String, Integer> roomChannels = new ConcurrentHashMap<>();
private final Map<String, TeamspeakRoomClient> roomClients = new ConcurrentHashMap<>();
private volatile LocalTeamspeakClientSocket observer;
private volatile boolean ready;
public TeamspeakSide(BridgeConfig.Teamspeak config, Path dataDir, List<Room> rooms) {
this.config = config;
this.dataDir = dataDir;
this.rooms = rooms;
}
public void start() {
executor.execute(this::connectObserver);
}
public boolean ready() {
return ready;
}
/** Menschen im Raum-Channel, ohne unsere eigenen Clients und ohne ServerQuery. */
public int humansIn(Room room) {
Integer channel = roomChannels.get(room.key());
if (!ready || channel == null) return 0;
Set<Integer> own = ownClientIds();
int count = 0;
for (Map.Entry<Integer, TsClient> e : clients.entrySet()) {
TsClient client = e.getValue();
if (client.channel() == channel && client.type() == CLIENT_TYPE_VOICE && !own.contains(e.getKey())) count++;
}
return count;
}
/** Raum-Client verbinden oder trennen. Läuft asynchron, mehrfaches Aufrufen schadet nicht. */
public void setRoomConnected(Room room, boolean connected) {
executor.execute(() -> {
TeamspeakRoomClient client = roomClients.get(room.key());
if (connected && client == null && ready) {
Integer channel = roomChannels.get(room.key());
if (channel == null) return;
try {
client = new TeamspeakRoomClient(config, room, channel,
loadIdentity("ts-" + room.key() + ".ini"), this::onRoomClientLost);
roomClients.put(room.key(), client);
client.connect();
LOG.info("{}: TeamSpeak-Client im Channel {}", room.name(), channel);
} catch (Exception e) {
LOG.warn("{}: TeamSpeak-Client konnte nicht verbinden: {}", room.name(), e.toString());
roomClients.remove(room.key());
if (client != null) client.disconnect();
}
} else if (!connected && client != null) {
roomClients.remove(room.key());
client.disconnect();
LOG.info("{}: TeamSpeak-Client getrennt", room.name());
}
});
}
private void onRoomClientLost(TeamspeakRoomClient client) {
executor.execute(() -> roomClients.values().remove(client));
}
private Set<Integer> ownClientIds() {
Set<Integer> own = new HashSet<>();
LocalTeamspeakClientSocket socket = observer;
if (socket != null) own.add(socket.getClientId());
for (TeamspeakRoomClient client : roomClients.values()) own.add(client.clientId());
return own;
}
private void connectObserver() {
try {
LocalTeamspeakClientSocket socket = TeamspeakRoomClient.newSocket(config, loadIdentity("ts-observer.ini"),
config.nickname());
socket.addListener(this);
observer = socket;
socket.connect(new InetSocketAddress(config.host(), config.port()), emptyToNull(config.password()), 15000L);
LOG.info("TeamSpeak verbunden mit {}:{} ({} Channels, {} Clients sichtbar)",
config.host(), config.port(), channels.size(), clients.size());
resolveChannels(socket);
if (config.parentChannel() > 0) socket.joinChannel(config.parentChannel(), null);
ready = true;
} catch (Exception e) {
LOG.warn("TeamSpeak-Verbindung fehlgeschlagen ({}), neuer Versuch in {} s", e.toString(), RECONNECT_SECONDS);
disconnectObserver();
executor.schedule(this::connectObserver, RECONNECT_SECONDS, TimeUnit.SECONDS);
}
}
private void resolveChannels(LocalTeamspeakClientSocket socket) throws Exception {
for (Room room : rooms) {
int configured = room.config().teamspeakChannel();
if (configured > 0) {
roomChannels.put(room.key(), configured);
continue;
}
Integer found = findChannel(room.name());
if (found == null) {
socket.executeCommand(new SingleCommand("channelcreate", ProtocolRole.CLIENT,
new CommandSingleParameter("channel_name", room.name()),
new CommandSingleParameter("cpid", Integer.toString(config.parentChannel())),
new CommandSingleParameter("channel_flag_permanent", "1"),
new CommandSingleParameter("channel_codec", "4"))).get();
// Die Antwort enthält die cid nicht, sie kommt per notifychannelcreated.
for (int i = 0; i < 50 && found == null; i++) {
Thread.sleep(100);
found = findChannel(room.name());
}
if (found == null) throw new IOException("Channel " + room.name() + " wurde nicht angelegt");
LOG.info("TeamSpeak-Channel angelegt: {} ({})", room.name(), found);
}
roomChannels.put(room.key(), found);
}
}
private Integer findChannel(String name) {
for (Map.Entry<Integer, TsChannel> e : channels.entrySet()) {
if (e.getValue().parent() == config.parentChannel() && e.getValue().name().equals(name)) return e.getKey();
}
return null;
}
private void disconnectObserver() {
ready = false;
LocalTeamspeakClientSocket socket = observer;
observer = null;
if (socket != null) {
try {
socket.disconnect();
} catch (Exception ignored) {
// Verbindung ist ohnehin weg
}
}
for (TeamspeakRoomClient client : roomClients.values()) client.disconnect();
roomClients.clear();
clients.clear();
channels.clear();
}
LocalIdentity loadIdentity(String file) throws IOException, GeneralSecurityException {
Path path = dataDir.resolve(file);
if (Files.exists(path)) return LocalIdentity.read(path.toFile());
Files.createDirectories(dataDir);
LOG.info("Erzeuge TeamSpeak-Identität {} (Security-Level {})", file, config.securityLevel());
LocalIdentity identity = LocalIdentity.generateNew(config.securityLevel());
identity.save(path.toFile());
return identity;
}
static String emptyToNull(String value) {
return value == null || value.isEmpty() ? null : value;
}
public void stop() {
executor.shutdownNow();
disconnectObserver();
}
// --- Ereignisse des Beobachters ---
@Override
public void onClientJoin(ClientJoinEvent e) {
clients.put(e.getClientId(), new TsClient(e.getClientTargetId(), e.getClientType()));
}
@Override
public void onClientMoved(ClientMovedEvent e) {
clients.compute(e.getClientId(), (id, old) ->
new TsClient(e.getTargetChannelId(), old == null ? CLIENT_TYPE_VOICE : old.type()));
}
@Override
public void onClientLeave(ClientLeaveEvent e) {
clients.remove(e.getClientId());
}
@Override
public void onChannelList(ChannelListEvent e) {
putChannel(e.getMap());
}
@Override
public void onChannelCreate(ChannelCreateEvent e) {
putChannel(e.getMap());
}
@Override
public void onChannelEdit(ChannelEditedEvent e) {
Map<String, String> map = e.getMap();
String name = map.get("channel_name");
int cid = parse(map.get("cid"), -1);
if (name != null && cid >= 0) channels.computeIfPresent(cid, (id, old) -> new TsChannel(old.parent(), name));
}
@Override
public void onChannelMoved(ChannelMovedEvent e) {
Map<String, String> map = e.getMap();
int cid = parse(map.get("cid"), -1);
int parent = parse(map.get("cpid"), 0);
channels.computeIfPresent(cid, (id, old) -> new TsChannel(parent, old.name()));
}
@Override
public void onChannelDeleted(ChannelDeletedEvent e) {
channels.remove(e.getChannelId());
roomChannels.values().removeIf(cid -> cid == e.getChannelId());
}
@Override
public void onDisconnected(DisconnectedEvent e) {
LOG.warn("TeamSpeak-Beobachter getrennt, neuer Versuch in {} s", RECONNECT_SECONDS);
executor.execute(this::disconnectObserver);
executor.schedule(this::connectObserver, RECONNECT_SECONDS, TimeUnit.SECONDS);
}
private void putChannel(Map<String, String> map) {
int cid = parse(map.get("cid"), -1);
if (cid < 0) return;
channels.put(cid, new TsChannel(parse(map.get("cpid"), 0), map.getOrDefault("channel_name", "")));
}
private static int parse(String value, int def) {
if (value == null) return def;
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
return def;
}
}
}
@@ -0,0 +1,70 @@
package eu.mhsl.voicebridge.bridge.audio;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class MixerTest {
private static short[] frame(int value) {
short[] frame = new short[Opus.FRAME_SAMPLES];
Arrays.fill(frame, (short) value);
return frame;
}
@Test
void waitsForJitterBufferThenMixes() {
Mixer mixer = new Mixer();
mixer.push("a", frame(100));
assertFalse(mixer.ready());
assertNull(mixer.pull());
mixer.push("a", frame(100));
mixer.push("b", frame(50));
mixer.push("b", frame(50));
assertTrue(mixer.ready());
assertEquals(150, mixer.pull()[0]);
assertEquals(150, mixer.pull()[0]);
assertNull(mixer.pull());
}
@Test
void clipsInsteadOfOverflowing() {
Mixer mixer = new Mixer();
for (String source : List.of("a", "b")) {
mixer.push(source, frame(30000));
mixer.push(source, frame(30000));
}
assertEquals(Short.MAX_VALUE, mixer.pull()[0]);
}
@Test
void dropsOldestFramesWhenBehind() {
Mixer mixer = new Mixer();
for (int i = 0; i < Mixer.MAX + 5; i++) mixer.push("a", frame(i));
assertEquals(5, mixer.pull()[0]);
}
@Test
void splitterProducesExactFrames() {
FrameSplitter splitter = new FrameSplitter();
assertEquals(0, splitter.push(new short[500]).size());
assertEquals(1, splitter.push(new short[500]).size());
assertEquals(2, splitter.push(new short[1920]).size());
}
@Test
void opusRoundTrip() throws Exception {
short[] tone = new short[Opus.FRAME_SAMPLES];
for (int i = 0; i < tone.length; i++) tone[i] = (short) (8000 * Math.sin(i * 2 * Math.PI * 440 / Opus.SAMPLE_RATE));
byte[] packet = Opus.encodeMono(Opus.encoder(1, 48000), tone);
assertTrue(packet.length > 0);
assertEquals(Opus.FRAME_SAMPLES, Opus.decode(Opus.decoder(), packet).length);
byte[] stereo = Opus.encodeStereo(Opus.encoder(2, 64000), tone);
assertEquals(Opus.FRAME_SAMPLES, Opus.decode(Opus.decoder(), stereo).length);
}
}
+23
View File
@@ -0,0 +1,23 @@
plugins {
id 'com.gradleup.shadow' version '9.6.1' apply false
}
subprojects {
apply plugin: 'java-library'
group = 'eu.mhsl.voicebridge'
version = '0.1.0'
repositories {
mavenCentral()
}
java {
toolchain.languageVersion = JavaLanguageVersion.of(25)
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.release.set(25)
}
}
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+249
View File
@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+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
+9
View File
@@ -0,0 +1,9 @@
dependencies {
testImplementation platform('org.junit:junit-bom:5.13.4')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
@@ -0,0 +1,25 @@
package eu.mhsl.voicebridge.protocol;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Nachrichten zwischen Paper-Plugin und Bridge-Dienst (UDP, ein Datagramm pro Nachricht).
*/
public sealed interface Message {
/** Plugin → Bridge, jede Sekunde: Herzschlag und verbundene Spieler pro Raum. */
record Hello(int version, Map<String, Integer> members) implements Message {}
/** Bridge → Plugin, Antwort auf jedes Hello: die Räume und wer auf den anderen Seiten ist. */
record Rooms(List<RoomInfo> rooms) implements Message {}
record RoomInfo(String key, String name, UUID groupId, int teamspeak, int discord, boolean active) {}
/**
* Ein Opus-Frame (20 ms, 48 kHz mono) in beide Richtungen. {@code source} ist beim Plugin → Bridge
* die Spieler-UUID, beim Bridge → Plugin eine feste UUID pro externem Sprecher.
*/
record Audio(String room, UUID source, byte[] opus) implements Message {}
}
@@ -0,0 +1,98 @@
package eu.mhsl.voicebridge.protocol;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public final class Protocol {
/** Bei inkompatiblen Änderungen am Format hochzählen. */
public static final int VERSION = 1;
public static final int DEFAULT_PORT = 24470;
public static final int MAX_DATAGRAM = 2048;
private static final byte HELLO = 1;
private static final byte ROOMS = 2;
private static final byte AUDIO = 3;
private Protocol() {}
public static byte[] encode(Message message) {
ByteArrayOutputStream bytes = new ByteArrayOutputStream(256);
try (DataOutputStream out = new DataOutputStream(bytes)) {
switch (message) {
case Message.Hello hello -> {
out.writeByte(HELLO);
out.writeInt(hello.version());
out.writeShort(hello.members().size());
for (Map.Entry<String, Integer> e : hello.members().entrySet()) {
out.writeUTF(e.getKey());
out.writeShort(e.getValue());
}
}
case Message.Rooms rooms -> {
out.writeByte(ROOMS);
out.writeShort(rooms.rooms().size());
for (Message.RoomInfo room : rooms.rooms()) {
out.writeUTF(room.key());
out.writeUTF(room.name());
writeUuid(out, room.groupId());
out.writeShort(room.teamspeak());
out.writeShort(room.discord());
out.writeBoolean(room.active());
}
}
case Message.Audio audio -> {
out.writeByte(AUDIO);
out.writeUTF(audio.room());
writeUuid(out, audio.source());
out.write(audio.opus());
}
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return bytes.toByteArray();
}
public static Message decode(byte[] data, int offset, int length) throws IOException {
DataInputStream in = new DataInputStream(new ByteArrayInputStream(data, offset, length));
byte type = in.readByte();
return switch (type) {
case HELLO -> {
int version = in.readInt();
int count = in.readUnsignedShort();
Map<String, Integer> members = new LinkedHashMap<>();
for (int i = 0; i < count; i++) members.put(in.readUTF(), in.readUnsignedShort());
yield new Message.Hello(version, members);
}
case ROOMS -> {
int count = in.readUnsignedShort();
List<Message.RoomInfo> rooms = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
rooms.add(new Message.RoomInfo(in.readUTF(), in.readUTF(), readUuid(in),
in.readUnsignedShort(), in.readUnsignedShort(), in.readBoolean()));
}
yield new Message.Rooms(rooms);
}
case AUDIO -> new Message.Audio(in.readUTF(), readUuid(in), in.readAllBytes());
default -> throw new IOException("Unbekannter Nachrichtentyp " + type);
};
}
private static void writeUuid(DataOutputStream out, UUID uuid) throws IOException {
out.writeLong(uuid.getMostSignificantBits());
out.writeLong(uuid.getLeastSignificantBits());
}
private static UUID readUuid(DataInputStream in) throws IOException {
return new UUID(in.readLong(), in.readLong());
}
}
@@ -0,0 +1,46 @@
package eu.mhsl.voicebridge.protocol;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class ProtocolTest {
private static Message roundTrip(Message message) throws IOException {
byte[] data = Protocol.encode(message);
return Protocol.decode(data, 0, data.length);
}
@Test
void hello() throws IOException {
Message.Hello hello = new Message.Hello(Protocol.VERSION, Map.of("crosstalk-1", 3));
assertEquals(hello, roundTrip(hello));
}
@Test
void rooms() throws IOException {
Message.Rooms rooms = new Message.Rooms(List.of(
new Message.RoomInfo("crosstalk-1", "Crosstalk 1", UUID.randomUUID(), 2, 0, true)));
assertEquals(rooms, roundTrip(rooms));
}
@Test
void audio() throws IOException {
byte[] opus = {1, 2, 3, (byte) 0xF8, (byte) 0xFF};
UUID source = UUID.randomUUID();
Message.Audio audio = (Message.Audio) roundTrip(new Message.Audio("crosstalk-2", source, opus));
assertEquals("crosstalk-2", audio.room());
assertEquals(source, audio.source());
assertArrayEquals(opus, audio.opus());
}
@Test
void unknownType() {
assertThrows(IOException.class, () -> Protocol.decode(new byte[]{42}, 0, 1));
}
}
+3
View File
@@ -0,0 +1,3 @@
rootProject.name = 'voice-bridge'
include 'protocol', 'plugin', 'bridge', 'ts3j'
Vendored Submodule
+1
Submodule third_party/ts3j added at db57d60c98.
+19
View File
@@ -0,0 +1,19 @@
// ts3j (TeamSpeak-Client-Protokoll) aus dem Submodul third_party/ts3j bauen.
// JitPack hat keinen Build des aktuellen Stands (TS5/TS6-Lizenz-Fix vom 2025-07).
sourceSets {
main {
java.srcDirs = ['../third_party/ts3j/src/main/java']
}
}
dependencies {
api 'org.bouncycastle:bcprov-jdk15on:1.67'
api 'commons-lang:commons-lang:2.6'
api 'dnsjava:dnsjava:2.1.8'
api 'org.ini4j:ini4j:0.5.1'
}
tasks.withType(JavaCompile).configureEach {
options.compilerArgs += ['-nowarn', '-Xlint:none']
}