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,8 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
run/
|
||||||
|
config.yml
|
||||||
|
!plugin/src/main/resources/config.yml
|
||||||
|
data/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "third_party/ts3j"]
|
||||||
|
path = third_party/ts3j
|
||||||
|
url = https://github.com/Manevolent/ts3j.git
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
rootProject.name = 'voice-bridge'
|
||||||
|
|
||||||
|
include 'protocol', 'plugin', 'bridge', 'ts3j'
|
||||||
+1
Submodule third_party/ts3j added at db57d60c98.
@@ -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']
|
||||||
|
}
|
||||||
Reference in new issue
Block a user