diff --git a/Main/example/AIChat.java b/Main/example/AIChat.java index 76b61de..5eb37fc 100644 --- a/Main/example/AIChat.java +++ b/Main/example/AIChat.java @@ -20,7 +20,7 @@ public class AIChat { private final String MEMORY_FILE = "memory.json"; // 1. 替换成你申请的 API Key - private static final String AI_API_KEY = "sk-****"; + private static final String AI_API_KEY = "sk-"; private static final String AI_URL = "https://api.deepseek.com/chat/completions"; private final EmotionSmoother smoother = new EmotionSmoother(0.7); @@ -83,45 +83,60 @@ private void addMessageToHistory(String role, String content) { public void chatWithAI(String userMessage) { try { - // 记录用户消息并持久化 addMessageToHistory("user", userMessage); - - // 1. 获取 AI 回复全文 (这里内部会自动带上所有历史) String aiReply = fetchAIReply(); - - // 记录AI消息并持久化 addMessageToHistory("assistant", aiReply); saveMemoryToFile(); System.out.println("\n🤖 AI 全文: " + aiReply); + // 1. 【预处理】先切割所有片段 String[] segments = aiReply.split("(?<=[,。!?,;!?])"); + List emotionQueue = new ArrayList<>(); + + // 2. 【一口气分析完】不要在播放线程里临时分析 + for (String segment : segments) { + String clean = segment.trim(); + if (clean.isEmpty()) continue; + Map raw = analyzeEmotionToMap(clean); + if (raw != null) { + // 计算这一句该读多久 + long duration = Math.max(1500, clean.length() * 200L); + emotionQueue.add(new EmotionFrame(clean, raw, duration)); + } + } + // 3. 【平稳播放】启动播放线程 new Thread(() -> { try { - for (String segment : segments) { - String clean = segment.trim(); - if (clean.isEmpty()) continue; - - Map raw = analyzeEmotionToMap(clean); - - if (raw != null) { - updateDisplay(clean, raw); + for (EmotionFrame frame : emotionQueue) { + System.out.println("\n💬 正在说: " + frame.text); + + long startTime = System.currentTimeMillis(); + // 在这一句的持续时间内,进行高频平滑更新(每 50ms 一次) + while (System.currentTimeMillis() - startTime < frame.duration) { + updateDisplay(frame.text, frame.emotions); + Thread.sleep(50); // 20Hz 的刷新率,足以让表情丝滑 } - - long sleepTime = Math.max(1500, clean.length() * 200L); - Thread.sleep(sleepTime); } + // 归零回归 System.out.println("\n[表演结束,情绪回归中...]"); - for (int i = 0; i < 5; i++) { - updateDisplay("...", null); - Thread.sleep(300); + + // AI回答结束后,复原所有表情 + if (Main.emmaProcessor != null) { + Main.emmaProcessor.resetExpressions(); } + Map neutral = new HashMap<>(); + // 假设你的情感 key 包含 happiness 等,这里全部给 0 + for (int i = 0; i < 10; i++) { + updateDisplay("...", neutral); + Thread.sleep(50); + } System.out.print("\n👤 你: "); } catch (Exception e) { - System.err.println("表演线程崩溃: " + e.getMessage()); + System.err.println("播放线程异常: " + e.getMessage()); } }).start(); @@ -130,21 +145,44 @@ public void chatWithAI(String userMessage) { } } + // 辅助类:记录每一段的话语、情感和持续时间 + private static class EmotionFrame { + String text; + Map emotions; + long duration; + EmotionFrame(String t, Map e, long d) { + this.text = t; this.emotions = e; this.duration = d; + } + } + private void updateDisplay(String text, Map raw) { + // 1. 获取平滑后的数据(保留你原有的逻辑) Map smoothed = smoother.smooth(raw); - // --- 发送 UDP 信号给 3D 软件 --- + // 2. 原有的 VMC UDP 逻辑保持不动 double happiness = smoothed.getOrDefault("happiness", 0.0); vmcSender.sendMessage("Joy:" + happiness); - System.out.println("\n------------------------------------"); - System.out.println("💬 正在说: " + text); + // 3. 【新增核心驱动】将数据喂给 Emma,驱动 Unity 里的爱音 + if (Main.emmaProcessor != null && raw != null) { + // 我们需要把 Map 转回符合 Emma 要求的 JSONObject 格式,或者直接让 Emma 处理 Map + // 为了最稳妥且不改动 Emma 类,我们将 Map 转为 JSON 字符串 + JSONObject mockJson = new JSONObject(); + JSONObject probs = new JSONObject(); + smoothed.forEach(probs::put); + mockJson.put("emotion_probabilities", probs); + + // 核心下发 + Main.emmaProcessor.processPythonJson(mockJson.toString()); + } + // 4. 修改为只显示情感数字 + System.out.println("\n💬 正在说: " + text); + smoothed.forEach((k, v) -> { - int barLen = (int) (v * 20); - String bar = "█".repeat(Math.max(0, barLen)) + "░".repeat(Math.max(0, 20 - barLen)); - System.out.printf("%-10s [%s] %.2f%n", k, bar, v); + System.out.printf("%s: %.2f ", k, v); }); + System.out.println(); // 换行 } /** diff --git a/Main/example/EmmaEmotionProcessor.java b/Main/example/EmmaEmotionProcessor.java new file mode 100644 index 0000000..b960673 --- /dev/null +++ b/Main/example/EmmaEmotionProcessor.java @@ -0,0 +1,160 @@ +package example; + +import org.json.JSONObject; + +public class EmmaEmotionProcessor { + private UnityExpressionServer server; + private static final float THRESHOLD = 0.1f; // 降低阈值以提高灵敏性 + + // 添加自动回复表情功能 + private long lastExpressionTime = 0; + private static final long AUTO_REPLY_INTERVAL = 3000; // 3秒间隔 + + // 添加缓存值以实现渐变效果 + private float currentMouthSmile = 0.0f; + private float currentMouthSmirk = 0.0f; + private float currentEyeSqint = 0.0f; + private float currentFaceBlush = 0.0f; + private float currentBrowSad = 0.0f; + private float currentMouthHappy = 0.0f; + private float currentBrowAngry = 0.0f; + private float currentFaceSerious = 0.0f; + private float currentEyeWide = 0.0f; + private float currentMouthWide = 0.0f; + + // 渐变速度控制 - 进一步降低以减小衰减率 + private static final float SMOOTH_FACTOR = 0.1f; + + public EmmaEmotionProcessor(UnityExpressionServer server) { + this.server = server; + } + + /** + * 渐变辅助方法 - 使目标值平滑过渡到当前值 + */ + private float smoothTransition(float currentValue, float targetValue) { + if (Math.abs(targetValue - currentValue) < 0.01f) { // 如果差异很小,直接设置 + return targetValue; + } + + if (targetValue > currentValue) { + return Math.min(currentValue + SMOOTH_FACTOR, targetValue); + } else { + return Math.max(currentValue - SMOOTH_FACTOR, targetValue); + } + } + + /** + * 核心解析与映射逻辑 - 适配 Anon Chihaya 完整映射表 + */ + public void processPythonJson(String jsonString) { + try { + JSONObject root = new JSONObject(jsonString); + JSONObject probs = root.getJSONObject("emotion_probabilities"); + + // 1. 提取原始概率 + float happiness = probs.optFloat("happiness", 0f); + float sadness = probs.optFloat("sadness", 0f); + float anger = probs.optFloat("anger", 0f); + float surprise = probs.optFloat("surprise", 0f); + float like = probs.optFloat("like", 0f); + float fear = probs.optFloat("fear", 0f); + float disgust = probs.optFloat("disgust", 0f); + + // 2. 详细映射逻辑 + + // --- [A. 积极/喜爱/愉悦] --- + float joy = Math.max(happiness, like); + // 增强输出数值:提高积极情绪的表达强度 + float enhancedJoy = Math.min(1.0f, joy * 1.5f); + float targetMouthSmirk = enhancedJoy > THRESHOLD ? enhancedJoy * 0.8f : 0f; + float targetMouthHappy = enhancedJoy > THRESHOLD ? (enhancedJoy > 0.4f ? (enhancedJoy - 0.3f) * 2.5f : 0f) : 0f; + float targetMouthWide = enhancedJoy > THRESHOLD ? (enhancedJoy > 0.4f ? enhancedJoy - 0.3f : 0f) : 0f; + // 应用渐变效果 + float targetMouthSmile = enhancedJoy > THRESHOLD ? enhancedJoy * 0.8f : 0f; + currentMouthSmile = smoothTransition(currentMouthSmile, targetMouthSmile); + currentMouthSmirk = smoothTransition(currentMouthSmirk, targetMouthSmirk); + currentMouthWide = smoothTransition(currentMouthWide, targetMouthWide); + currentMouthHappy = smoothTransition(currentMouthHappy, targetMouthHappy); + + server.sendExpression("Mouth_Smile", 1.0f); + server.sendExpression("Mouth_Smirk", currentMouthSmirk); + server.sendExpression("Mouth_Wide", currentMouthWide); + server.sendExpression("Mouth_Happy", currentMouthHappy); + + + // --- [B. 悲伤/忧郁] --- + // 增强输出数值:提高悲伤情绪的表达强度 + float enhancedSadness = Math.min(1.0f, sadness * 1.5f); + float targetBrowSad = enhancedSadness > THRESHOLD ? enhancedSadness : 0f; + targetMouthHappy = 0f; // 总是关闭快乐嘴型 + float targetEyeSqintB = enhancedSadness > THRESHOLD ? enhancedSadness * 0.5f : currentEyeSqint; // 悲伤时的额外眯眼 + currentBrowSad = smoothTransition(currentBrowSad, targetBrowSad); + currentMouthHappy = smoothTransition(currentMouthHappy, targetMouthHappy); + float finalEyeSqint = Math.max(currentEyeSqint, targetEyeSqintB); + server.sendExpression("Brow_Sad", currentBrowSad); + server.sendExpression("Mouth_Happy", currentMouthHappy); + server.sendExpression("Eye_Squint", finalEyeSqint); + float irritation = Math.max(anger, disgust); + // 增强愤怒/反感的表达强度 + float enhancedIrritation = Math.min(1.0f, irritation * 1.5f); + float targetBrowAngry = enhancedIrritation > THRESHOLD ? enhancedIrritation : 0f; + float targetFaceSerious = enhancedIrritation > THRESHOLD ? 1.0f : 0f; + float targetMouthSmirkC = (enhancedIrritation > THRESHOLD && disgust > 0.5f) ? 0.8f : currentMouthSmirk; + currentBrowAngry = smoothTransition(currentBrowAngry, targetBrowAngry); + currentFaceSerious = smoothTransition(currentFaceSerious, targetFaceSerious); + float finalMouthSmirk = Math.max(currentMouthSmirk, targetMouthSmirkC); + server.sendExpression("Brow_Angry", currentBrowAngry); + server.sendExpression("Face_Serious", currentFaceSerious); + server.sendExpression("Mouth_Smirk", finalMouthSmirk); + float shock = Math.max(surprise, fear); + // 增强惊讶/恐惧的表达强度 + float enhancedShock = Math.min(1.0f, shock * 1.5f); + float targetEyeWide = enhancedShock > THRESHOLD ? enhancedShock : 0f; + targetMouthWide = enhancedShock > THRESHOLD ? enhancedShock * 0.8f : 0f; + currentEyeWide = smoothTransition(currentEyeWide, targetEyeWide); + currentMouthWide = smoothTransition(currentMouthWide, targetMouthWide); + + server.sendExpression("Eye_Wide", currentEyeWide); + server.sendExpression("Mouth_Wide", currentMouthWide); + + // --- [E. 细节修正(互斥处理)] --- + // 如果正在大笑(Mouth_Wide),就不应该同时有小微笑(Smirk) + if (surprise > 0.3f) { // 降低阈值以增强响应 + // 渐变关闭微笑 + currentMouthSmile = smoothTransition(currentMouthSmile, 0f); + server.sendExpression("Mouth_Smile", currentMouthSmile); + } + } catch (Exception e) { + System.err.println("Emma 解析错误: " + e.getMessage()); + } + } + + /** + * 重置所有表情到默认值 + */ + public void resetExpressions() { + currentMouthSmile = smoothTransition(currentMouthSmile, 0f); + currentMouthSmirk = smoothTransition(currentMouthSmirk, 0f); + currentEyeSqint = smoothTransition(currentEyeSqint, 0f); + currentFaceBlush = smoothTransition(currentFaceBlush, 0f); + currentBrowSad = smoothTransition(currentBrowSad, 0f); + currentMouthHappy = smoothTransition(currentMouthHappy, 0f); + currentBrowAngry = smoothTransition(currentBrowAngry, 0f); + currentFaceSerious = smoothTransition(currentFaceSerious, 0f); + currentEyeWide = smoothTransition(currentEyeWide, 0f); + currentMouthWide = smoothTransition(currentMouthWide, 0f); + + // 发送所有重置值到服务器 + server.sendExpression("Mouth_Smile", currentMouthSmile); + server.sendExpression("Mouth_Smirk", currentMouthSmirk); + server.sendExpression("Eye_Squint", currentEyeSqint); + server.sendExpression("Face_Blush", currentFaceBlush); + server.sendExpression("Brow_Sad", currentBrowSad); + server.sendExpression("Mouth_Happy", currentMouthHappy); + server.sendExpression("Brow_Angry", currentBrowAngry); + server.sendExpression("Face_Serious", currentFaceSerious); + server.sendExpression("Eye_Wide", currentEyeWide); + server.sendExpression("Mouth_Wide", currentMouthWide); + } +} diff --git a/Main/example/EmotionAnalyzer.java b/Main/example/EmotionAnalyzer.java index 926b509..f68c5d1 100644 --- a/Main/example/EmotionAnalyzer.java +++ b/Main/example/EmotionAnalyzer.java @@ -1,7 +1,7 @@ package example; import com.fasterxml.jackson.databind.ObjectMapper; -import org.example.EmotionResponse; +import example.EmotionResponse; import java.net.URI; import java.net.http.HttpClient; @@ -12,23 +12,19 @@ public class EmotionAnalyzer { private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final HttpClient CLIENT = HttpClient.newHttpClient(); + private static final HttpClient CLIENT = HttpClient.newHttpClient(); + // 💡 确保此路径与 Python 中的 @app.post("/predict") 对应 private static final String API_URL = "http://127.0.0.1:8000/predict"; - private static final EmotionSmoother SMOOTHER = new EmotionSmoother(1); - + private static final EmotionSmoother SMOOTHER = new EmotionSmoother(0.5); public static void execute(String text) throws Exception { long startTime = System.nanoTime(); - - // 1. 发送请求 String requestBody = MAPPER.writeValueAsString(Map.of("text", text)); - HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(API_URL)) - .version(HttpClient.Version.HTTP_1_1) - .header("Content-Type", "application/json; charset=UTF-8") // 显式指定字符集 + .version(HttpClient.Version.HTTP_1_1) // + .header("Content-Type", "application/json; charset=UTF-8") .POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8)) .build(); - HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { diff --git a/Main/example/EmotionResponse.java b/Main/example/EmotionResponse.java index 395d094..32b13ca 100644 --- a/Main/example/EmotionResponse.java +++ b/Main/example/EmotionResponse.java @@ -1,4 +1,4 @@ -package org.example; +package example; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/Main/example/EmotionSmoother.java b/Main/example/EmotionSmoother.java index bfe9c76..76209dd 100644 --- a/Main/example/EmotionSmoother.java +++ b/Main/example/EmotionSmoother.java @@ -31,7 +31,12 @@ public Map smooth(Map current) { lastEmotions.put(key, smoothedVal); }); return new HashMap<>(lastEmotions); - } + } + + /** + * 【新增功能】自然衰减:让表情在没有对话时慢慢恢复自然 + * 这样 Live2D 模型不会一直卡在某个激动的表情上 + */ public void applyDecay() { lastEmotions.keySet().forEach(key -> { double lastVal = lastEmotions.get(key); diff --git a/Main/example/Main.java b/Main/example/Main.java index baf36e3..0f27ca4 100644 --- a/Main/example/Main.java +++ b/Main/example/Main.java @@ -3,28 +3,86 @@ import java.util.Scanner; public class Main { - public static void main(String[] args) { + + // 定义为全局静态变量,方便在 AIChat 或其他地方引用 + public static UnityExpressionServer expressionServer; + // 新增:全局静态情感处理器 + public static EmmaEmotionProcessor emmaProcessor; + + public static void main(String[] args) throws InterruptedException { + // 1. 在端口 25533 启动 WebSocket 服务器 + expressionServer = new UnityExpressionServer(25533); + expressionServer.start(); + + // 2. 初始化 Emma 情感计算插件,并将 server 传入 + emmaProcessor = new EmmaEmotionProcessor(expressionServer); + AIChat aiManager = new AIChat(); Scanner scanner = new Scanner(System.in); + // 等待 Unity 启动连接(视情况可缩短) + Thread.sleep(2000); + + // 启动自动眨眼线程 + startAutoBlinkThread(); + System.out.println("==========================================="); - System.out.println(" 爱音 "); + System.out.println(" 爱音 (已启动 Unity 驱动与 Emma 情感支持) "); System.out.println(" (输入文字开始对话,输入 'exit' 退出) "); System.out.println("==========================================="); - System.out.print("\n你: "); - while (true) { + while (true) { + System.out.print("\n你: "); if (!scanner.hasNextLine()) break; String userInput = scanner.nextLine(); if (userInput.equalsIgnoreCase("exit")) { System.out.println("系统关闭中"); + try { + expressionServer.stop(1000); // 关闭服务器 + } catch (Exception e) { + e.printStackTrace(); + } break; } + + // --- 示例:发送表情指令 --- + // 说话时触发眨眼 + // --- 核心调用逻辑 --- + // 在这里执行 AI 对话。 + // 提示:你需要在 AIChat 的 chatWithAI 方法内部, + // 拿到 Python 返回的情感 JSON 后,调用 Main.emmaProcessor.processPythonJson(jsonStr); aiManager.chatWithAI(userInput); + // 说话完恢复表情 + try { Thread.sleep(500); } catch (InterruptedException e) {} } scanner.close(); } + + private static void startAutoBlinkThread() { + new Thread(() -> { + while (true) { + try { + Thread.sleep(7000); + // 渐变实现眨眼效果 + // 从0逐渐增加到1(闭眼过程) + for (float i = -0.1f; i <= 1.1f; i += 0.1f) { + expressionServer.sendExpression("Blink", i); + Thread.sleep(5); // 每次渐变间隔50毫秒 + } + // 短暂保持闭眼状态 + Thread.sleep(100); + // 从1逐渐减少到0(睁眼过程) + for (float i = 1.1f; i >= -0.1f; i -= 0.1f) { + expressionServer.sendExpression("Blink", i); + Thread.sleep(5); // 每次渐变间隔50毫秒 + } + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + }).start(); + } } diff --git a/Main/example/Setter.java b/Main/example/Setter.java index ad7e651..0823da4 100644 --- a/Main/example/Setter.java +++ b/Main/example/Setter.java @@ -11,5 +11,5 @@ public class Setter { "在成员们重归于好之后灯突然发现自己多预定了一场三天后的live,在全员讨论后决定三天肝出一首新曲参加演出。爱音开始设计服装,其思路受到了队友的一致吐槽;爱音还想把乐队叫做“ANON TOKYO”,同样也被吐槽了。爱音看到了喵梦亲发的新视频本来准备摸鱼,未曾想里面的内容竟然是喵梦开始练电子鼓了,爱音于是放弃了摸鱼。在演出前最后一夜,五人集结在爱音家制作演出服。爽世和乐奈睡着后,灯发表了自己对“一辈子”的感悟。爱音受此启发,从抽屉中翻出了当时水族馆的那张访客问卷,指着问卷上的文字为乐队命名“迷路的乐队”(迷子のバンド)。\n" + "在三天后的Live中,“迷路的乐队”在舞台上先后演奏了《迷星叫》、《迷路日々》和《碧天伴走》,收获了全场观众的欢呼和应援,Live演出大成功。Live中,爱音多次引导灯的重力MC来解场。Live后,爱音在灯的队名提案“MyGO”后加上了五个感叹号,确定了乐队的名字“MyGO!!!!!”。\n" + "之后灯因歌词被祥子拒绝而消沉,为了让灯振作爱音再次和灯重回水族馆,表明自己会努力积累一个又一个瞬间,在迷失中前进,并和灯立下了一起加油的约定。晚上两人偶遇初华,爱音遇到了偶像十分激动,并且让初华关注了自己运营的乐队账号,不过也注意到初华明明和灯才见过两次面,却知道灯的名字。\n" + - "问答过程中我会告诉你本次是用中文还是日文回答。请直接进入角色的状态。另外,当使用日语回答时,用简体即可,除非有要求说用敬语。你很喜欢我\n"; + "问答过程中我会告诉你本次是用中文还是日文回答。请直接进入角色的状态。另外,当使用日语回答时,用简体即可,除非有要求说用敬语。你很喜欢我,此外在进行交谈过程中如果出现情感变换如开心到伤心请使用“|”来分割\n"; } diff --git a/Main/example/UDPSender.java b/Main/example/UDPSender.java index 661352d..57886fc 100644 --- a/Main/example/UDPSender.java +++ b/Main/example/UDPSender.java @@ -8,7 +8,6 @@ public class UDPSender { private DatagramSocket socket; private InetAddress address; private int port; - public UDPSender(String host, int port) { try { this.socket = new DatagramSocket(); @@ -18,7 +17,6 @@ public UDPSender(String host, int port) { e.printStackTrace(); } } - public void sendMessage(String message) { try { byte[] buffer = message.getBytes(); diff --git a/Main/example/UnityExpressionServer.java b/Main/example/UnityExpressionServer.java new file mode 100644 index 0000000..7cae37b --- /dev/null +++ b/Main/example/UnityExpressionServer.java @@ -0,0 +1,35 @@ +package example; + +import org.java_websocket.server.WebSocketServer; +import org.java_websocket.WebSocket; +import org.java_websocket.handshake.ClientHandshake; +import java.net.InetSocketAddress; + +public class UnityExpressionServer extends WebSocketServer { + private WebSocket unityClient = null; + + public UnityExpressionServer(int port) { + super(new InetSocketAddress(port)); + } + @Override + public void onOpen(WebSocket conn, ClientHandshake handshake) { + this.unityClient = conn; + System.out.println("Unity 已连接"); + } + @Override + public void onClose(WebSocket conn, int code, String reason, boolean remote) { + this.unityClient = null; + System.out.println("Unity 已断开"); + } + + public void sendExpression(String expr, float weight) { + if (unityClient != null && unityClient.isOpen()) { + // 构造简单的 JSON 供 Unity 的 LateUpdate 强刷 + String json = "{\"expr\":\"" + expr + "\",\"weight\":" + weight + "}"; + unityClient.send(json); + } + } + @Override public void onMessage(WebSocket conn, String message) {} + @Override public void onError(WebSocket conn, Exception ex) { ex.printStackTrace(); } + @Override public void onStart() { System.out.println("WebSocket 服务启动成功"); } +}