Skip to content

Commit

Permalink
experimental(bukkit): real-time voice transcription
Browse files Browse the repository at this point in the history
  • Loading branch information
HaHaWTH committed Oct 9, 2024
1 parent 1155309 commit 8bf8ed8
Show file tree
Hide file tree
Showing 19 changed files with 342 additions and 29 deletions.
12 changes: 10 additions & 2 deletions bukkit/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<parent>
<groupId>io.wdsj</groupId>
<artifactId>AdvancedSensitiveWords</artifactId>
<version>1.2</version>
<version>1.3</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down Expand Up @@ -399,11 +399,19 @@
<scope>compile</scope>
</dependency>

<!-- Whisper JNI -->
<dependency>
<groupId>io.github.givimad</groupId>
<artifactId>whisper-jni</artifactId>
<version>1.6.1</version>
<scope>provided</scope>
</dependency>

<!-- Common module -->
<dependency>
<groupId>io.wdsj</groupId>
<artifactId>AdvancedSensitiveWords-common</artifactId>
<version>1.2</version>
<version>1.3</version>
<scope>compile</scope>
</dependency>
</dependencies>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ public void onEnable() {
}
if (Bukkit.getPluginManager().isPluginEnabled("voicechat") &&
settingsManager.getProperty(PluginSettings.HOOK_SIMPLE_VOICE_CHAT)) {
if (settingsManager.getProperty(PluginSettings.VOICE_REALTIME_TRANSCRIBING)) {
libraryService.loadWhisperJniOptional();
}
voiceChatHookService = new VoiceChatHookService(this);
voiceChatHookService.register();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,30 @@

import de.maxhenkel.voicechat.api.VoicechatApi;
import de.maxhenkel.voicechat.api.VoicechatPlugin;
import de.maxhenkel.voicechat.api.audio.AudioConverter;
import de.maxhenkel.voicechat.api.events.EventRegistration;
import de.maxhenkel.voicechat.api.events.MicrophonePacketEvent;
import de.maxhenkel.voicechat.api.events.PlayerConnectedEvent;
import de.maxhenkel.voicechat.api.events.PlayerDisconnectedEvent;
import de.maxhenkel.voicechat.api.opus.OpusDecoder;
import io.wdsj.asw.bukkit.manage.punish.PlayerShadowController;
import io.wdsj.asw.bukkit.permission.PermissionsEnum;
import io.wdsj.asw.bukkit.permission.cache.CachingPermTool;
import io.wdsj.asw.bukkit.setting.PluginSettings;
import org.bukkit.entity.Player;

import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

import static io.wdsj.asw.bukkit.AdvancedSensitiveWords.settingsManager;

public class VoiceChatExtension implements VoicechatPlugin {
public final Map<UUID, float[]> connectedPlayers;


public VoiceChatExtension() {
connectedPlayers = new ConcurrentHashMap<>();
}

/**
Expand Down Expand Up @@ -39,14 +54,16 @@ public void initialize(VoicechatApi api) {
@Override
public void registerEvents(EventRegistration registration) {
registration.registerEvent(MicrophonePacketEvent.class, this::onMicrophone);
registration.registerEvent(PlayerConnectedEvent.class, this::onConnect);
registration.registerEvent(PlayerDisconnectedEvent.class, this::onDisconnect);
}

/**
* This method is called whenever a player sends audio to the server via the voice chat.
*
* @param event the microphone packet event
*/
private void onMicrophone(MicrophonePacketEvent event) { // TODO: incomplete version, plans to add real-time voice transcribe
private void onMicrophone(MicrophonePacketEvent event) {
if (event.getSenderConnection() == null) {
return;
}
Expand All @@ -55,8 +72,48 @@ private void onMicrophone(MicrophonePacketEvent event) { // TODO: incomplete ver
}

Player player = (Player) event.getSenderConnection().getPlayer().getPlayer();
if (PlayerShadowController.isShadowed(player)) {
if (PlayerShadowController.isShadowed(player) && settingsManager.getProperty(PluginSettings.VOICE_SYNC_SHADOW)) {
event.cancel();
return;
}

if (!settingsManager.getProperty(PluginSettings.VOICE_REALTIME_TRANSCRIBING)) return;
if (event.getPacket() == null || CachingPermTool.hasPermission(PermissionsEnum.BYPASS, player)) return;
OpusDecoder decoder = event.getVoicechat().createDecoder();
AudioConverter converter = event.getVoicechat().getAudioConverter();
float[] newData = converter.shortsToFloats(decoder.decode(event.getPacket().getOpusEncodedData()));
if (connectedPlayers.get(player.getUniqueId()) != null) {
float[] oldData = connectedPlayers.get(player.getUniqueId());
float[] result = new float[oldData.length + newData.length];
System.arraycopy(oldData, 0, result, 0, oldData.length);
System.arraycopy(newData, 0, result, oldData.length, newData.length);
connectedPlayers.put(player.getUniqueId(), result);
} else {
connectedPlayers.put(player.getUniqueId(), newData);
}
decoder.close();
}

private void onConnect(PlayerConnectedEvent event) {
if (!settingsManager.getProperty(PluginSettings.VOICE_REALTIME_TRANSCRIBING)) return;
if (event.getConnection() == null) {
return;
}
if (!(event.getConnection().getPlayer().getPlayer() instanceof Player)) {
return;
}
Player player = (Player) event.getConnection().getPlayer().getPlayer();
connectedPlayers.put(player.getUniqueId(), new float[]{});
}

private void onDisconnect(PlayerDisconnectedEvent event) {
if (!settingsManager.getProperty(PluginSettings.VOICE_REALTIME_TRANSCRIBING)) {
if (!connectedPlayers.isEmpty()) connectedPlayers.clear();
return;
}
if (event.getPlayerUuid() == null) {
return;
}
connectedPlayers.remove(event.getPlayerUuid());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package io.wdsj.asw.bukkit.integration.voicechat;

import com.google.common.util.concurrent.ThreadFactoryBuilder;
import io.github.givimad.whisperjni.WhisperContext;
import io.github.givimad.whisperjni.WhisperFullParams;
import io.github.givimad.whisperjni.WhisperJNI;
import io.wdsj.asw.bukkit.AdvancedSensitiveWords;
import io.wdsj.asw.bukkit.setting.PluginSettings;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.concurrent.*;

import static io.wdsj.asw.bukkit.AdvancedSensitiveWords.LOGGER;
import static io.wdsj.asw.bukkit.AdvancedSensitiveWords.settingsManager;

public class WhisperVoiceTranscribeTool {
private final WhisperJNI whisper;
private final WhisperContext whisperCtx;
private final ThreadPoolExecutor threadPool;

public WhisperVoiceTranscribeTool() {
try {
WhisperJNI.loadLibrary();
if (settingsManager.getProperty(PluginSettings.VOICE_DEBUG)) {
WhisperJNI.LibraryLogger logger = log -> LOGGER.info("[WhisperJNI] " + log);
WhisperJNI.setLibraryLogger(logger);
LOGGER.info("WhisperJNI debug logger enabled");
} else {
WhisperJNI.setLibraryLogger(null);
}
whisper = new WhisperJNI();
File dataFolder = Paths.get(AdvancedSensitiveWords.getInstance().getDataFolder().getPath(), "whisper", "model").toFile();
if (Files.notExists(dataFolder.toPath())) {
Files.createDirectories(dataFolder.toPath());
}
int coreCount = Runtime.getRuntime().availableProcessors();
int maxThread = settingsManager.getProperty(PluginSettings.VOICE_REALTIME_TRANSCRIBING_MAX_THREAD);
if (maxThread <= -1) {
maxThread = coreCount;
} else if (maxThread == 0) {
maxThread = coreCount * 2;
}
LOGGER.info("Using " + maxThread + " thread(s) for voice transcription");
whisperCtx = whisper.init(Paths.get(dataFolder.getPath(), settingsManager.getProperty(PluginSettings.VOICE_MODEL_NAME)));
RejectedExecutionHandler handler = (r, executor) -> LOGGER.info("Rejected execution of transcription task, thread pool is full");
threadPool = new ThreadPoolExecutor(Math.min(coreCount, maxThread),
maxThread,
settingsManager.getProperty(PluginSettings.VOICE_REALTIME_TRANSCRIBING_THREAD_KEEP_ALIVE),
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
new ThreadFactoryBuilder()
.setNameFormat("ASW Whisper Transcribe Thread-%d")
.setDaemon(true)
.build(),
handler);
threadPool.allowCoreThreadTimeOut(true);
threadPool.prestartAllCoreThreads();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public CompletableFuture<String> transcribe(float[] data) {
return CompletableFuture.supplyAsync(() -> {
WhisperFullParams params = new WhisperFullParams();
int result = whisper.full(whisperCtx, params, data, data.length);
if (result != 0) {
throw new RuntimeException("Transcription failed with code " + result);
}
whisper.fullNSegments(whisperCtx);
return whisper.fullGetSegmentText(whisperCtx,0);
}, threadPool);
}

public void shutdown() {
threadPool.shutdownNow();
whisperCtx.close();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,12 @@ public BukkitLibraryService(AdvancedSensitiveWords plugin) {
public void loadRequired() {
libraryManager.loadLibraries(openai4j, caffeine, ollama4j);
}

public void loadWhisperJniOptional() {
libraryManager.loadLibraries(Library.builder()
.groupId("io{}github{}givimad")
.artifactId("whisper-jni")
.version("1.6.1")
.build());
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
package io.wdsj.asw.bukkit.service.hook;

import com.github.Anon8281.universalScheduler.scheduling.tasks.MyScheduledTask;
import de.maxhenkel.voicechat.api.BukkitVoicechatService;
import io.wdsj.asw.bukkit.AdvancedSensitiveWords;
import io.wdsj.asw.bukkit.integration.voicechat.VoiceChatExtension;
import io.wdsj.asw.bukkit.integration.voicechat.WhisperVoiceTranscribeTool;
import io.wdsj.asw.bukkit.setting.PluginSettings;
import io.wdsj.asw.bukkit.task.voicechat.VoiceChatTranscribeTask;
import io.wdsj.asw.bukkit.util.SchedulingUtils;

import static io.wdsj.asw.bukkit.AdvancedSensitiveWords.LOGGER;
import static io.wdsj.asw.bukkit.AdvancedSensitiveWords.settingsManager;

public class VoiceChatHookService {
private final AdvancedSensitiveWords plugin;
private VoiceChatExtension voiceChatExtension;
private MyScheduledTask transcribeTask;
private WhisperVoiceTranscribeTool transcribeTool;
public VoiceChatHookService(AdvancedSensitiveWords plugin) {
this.plugin = plugin;
}
Expand All @@ -20,6 +28,11 @@ public void register() {
voiceChatExtension = new VoiceChatExtension();
service.registerPlugin(voiceChatExtension);
LOGGER.info("Successfully hooked into voicechat.");
if (settingsManager.getProperty(PluginSettings.VOICE_REALTIME_TRANSCRIBING)) {
transcribeTool = new WhisperVoiceTranscribeTool();
long interval = settingsManager.getProperty(PluginSettings.VOICE_CHECK_INTERVAL);
transcribeTask = new VoiceChatTranscribeTask(voiceChatExtension, transcribeTool).runTaskTimerAsynchronously(plugin, interval * 20L, interval * 20L);
}
} catch (Exception e) {
LOGGER.warning("Failed to register voicechat listener." +
" This should not happen, please report to the author: " + e.getMessage());
Expand All @@ -33,5 +46,9 @@ public void unregister() {
if (voiceChatExtension != null) {
plugin.getServer().getServicesManager().unregister(voiceChatExtension);
}
SchedulingUtils.cancelTaskSafely(transcribeTask);
if (transcribeTool != null) {
transcribeTool.shutdown();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public class PluginMessages implements SettingsHolder {
public static final Property<String> MESSAGE_ON_NAME = newProperty("Name.messageOnName", "&c您的用户名包含敏感词,请修改您的用户名或联系管理员.");
@Comment("玩家物品包含敏感词时的消息")
public static final Property<String> MESSAGE_ON_ITEM = newProperty("Item.messageOnItem", "&c您的物品包含敏感词.");
@Comment("玩家发送违规语音消息时的提示")
public static final Property<String> MESSAGE_ON_VOICE = newProperty("Voice.messageOnVoice", "&c请勿发送违规语音.");
@Comment("插件重载消息")
public static final Property<String> MESSAGE_ON_COMMAND_RELOAD = newProperty("Plugin.messageOnCommandReload", "&aAdvancedSensitiveWords 已重新加载.");
@Comment("违规次数重置消息")
Expand Down Expand Up @@ -74,6 +76,7 @@ public void registerComments(CommentsConfiguration conf) {
conf.setComment("Sign", "告示牌检测消息");
conf.setComment("Anvil", "铁砧重命名检测消息");
conf.setComment("Name", "玩家名检测消息");
conf.setComment("Voice", "玩家语音检测消息");
}

private PluginMessages() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,32 @@ public class PluginSettings implements SettingsHolder {
"Whether to enable punishment"})
public static final Property<Boolean> ITEM_PUNISH = newProperty("Item.punish", true);

@Comment({"*是否启用实时语音转录检测(Java 11+)",
"*Whether to enable real-time voice transcribing(Java 11+)"})
public static final Property<Boolean> VOICE_REALTIME_TRANSCRIBING = newProperty("Voice.realtimeTranscribing", false);
@Comment({"*实时语音转录检测最大线程数",
"*Maximum threads for real-time voice transcribing"})
public static final Property<Integer> VOICE_REALTIME_TRANSCRIBING_MAX_THREAD = newProperty("Voice.realtimeTranscribingMaxThread", -1);
public static final Property<Long> VOICE_REALTIME_TRANSCRIBING_THREAD_KEEP_ALIVE = newProperty("Voice.realtimeTranscribingThreadKeepAlive", 60L);
@Comment({"*模型文件名",
"*Model file name"})
public static final Property<String> VOICE_MODEL_NAME = newProperty("Voice.modelName", "ggml-tiny.bin");
@Comment({"*是否启用调试日志",
"*Whether to enable debug logging"})
public static final Property<Boolean> VOICE_DEBUG = newProperty("Voice.debug", false);
@Comment({"*检测间隔(单位: 秒)",
"*Check interval(in seconds)"})
public static final Property<Long> VOICE_CHECK_INTERVAL = newProperty("Voice.checkInterval", 40L);
@Comment({"*是否在违规时通知玩家",
"*Whether to notify the player when violated"})
public static final Property<Boolean> VOICE_SEND_MESSAGE = newProperty("Voice.sendMessage", true);
@Comment({"是否启用惩罚",
"Whether to enable the punish"})
public static final Property<Boolean> VOICE_PUNISH = newProperty("Voice.punish", true);
@Comment({"是否在Shadow惩罚时同步至语音",
"Whether to sync Shadow punishment to voice chat"})
public static final Property<Boolean> VOICE_SYNC_SHADOW = newProperty("Voice.syncShadow", true);


@Override
public void registerComments(CommentsConfiguration conf) {
Expand All @@ -426,6 +452,7 @@ public void registerComments(CommentsConfiguration conf) {
conf.setComment("Anvil", "Anvil rename detection");
conf.setComment("Name", "Player name detection");
conf.setComment("Item", "Item detection");
conf.setComment("Voice", "Voice detection (Requires hookVoiceChat enabled)");
}

// Do not instantiate.
Expand Down
Loading

0 comments on commit 8bf8ed8

Please sign in to comment.