diff --git a/src/main/java/com/xly/tts/bean/TTSResponseDTO.java b/src/main/java/com/xly/tts/bean/TTSResponseDTO.java index 02f980f..1fc8f65 100644 --- a/src/main/java/com/xly/tts/bean/TTSResponseDTO.java +++ b/src/main/java/com/xly/tts/bean/TTSResponseDTO.java @@ -54,6 +54,7 @@ public class TTSResponseDTO implements Serializable { private String audioBase64; private Integer audioSize; private String audioFormat; + private String audioText; // 或者只返回音频URL private String audioUrl; diff --git a/src/main/java/com/xly/tts/service/LocalAudioCache.java b/src/main/java/com/xly/tts/service/LocalAudioCache.java index 417dbfa..d670d95 100644 --- a/src/main/java/com/xly/tts/service/LocalAudioCache.java +++ b/src/main/java/com/xly/tts/service/LocalAudioCache.java @@ -1,24 +1,22 @@ package com.xly.tts.service; import com.xly.tts.bean.TTSResponseDTO; -import java.util.Map; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; public class LocalAudioCache { - private static final Map CACHE = new ConcurrentHashMap<>(); - public static void put(String text, TTSResponseDTO dto) { - CACHE.put(text, dto); - // 5分钟后自动清理 - new Thread(() -> { - try { - Thread.sleep(5 * 60 * 1000); - CACHE.remove(text); - } catch (Exception ignored) {} - }).start(); + // 内部存储结构:cacheKey_index -> { "text":"...", "audio":"base64" } + private static final Map> CACHE = new ConcurrentHashMap<>(); + + // 存储:一段文字 + 一段音频 + public static void addPiece(String cacheKey, int index, String text, String audioBase64) { + String key = cacheKey + "_" + index; + CACHE.put(key, Map.of("text", text, "audio", audioBase64)); } - public static TTSResponseDTO get(String text) { - return CACHE.get(text); + // 获取:一段文字 + 音频 + public static Map getPiece(String cacheKey, int index) { + return CACHE.get(cacheKey + "_" + index); } } \ No newline at end of file diff --git a/src/main/java/com/xly/tts/service/PythonTtsProxyService.java b/src/main/java/com/xly/tts/service/PythonTtsProxyService.java index 05b1209..187c78b 100644 --- a/src/main/java/com/xly/tts/service/PythonTtsProxyService.java +++ b/src/main/java/com/xly/tts/service/PythonTtsProxyService.java @@ -18,15 +18,13 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import javax.annotation.PostConstruct; -import java.io.ByteArrayInputStream; -import java.io.InputStream; +import java.io.*; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.net.URL; import java.net.HttpURLConnection; -import java.io.OutputStream; import java.io.InputStream; @Slf4j @@ -135,11 +133,18 @@ public class PythonTtsProxyService { } String voiceTextNew = AdvancedSymbolRemover.removePunctuationHtml(aiText); + // ============================ + // 【绝对唯一】不会重复、不会覆盖 + // ============================ + String cacheKey = request.getUserid() + "_" + System.nanoTime(); + TTSResponseDTO dto = TTSResponseDTO.builder() .code(200) .message("success") + .cacheKey(cacheKey) // 前端靠这个取自己的分段 .originalText(request.getText()) .processedText(aiText) + .audioText(voiceTextNew) .systemText(systemText) .voice(request.getVoice()) .sSceneName(aiResponseDTO.getSSceneName()) @@ -155,52 +160,101 @@ public class PythonTtsProxyService { return ResponseEntity.ok(dto); } - // ============================================== - // 👇 【关键】生成 全局唯一的 key(多用户不冲突) - // ============================================== - String cacheKey = request.getUserid() + "_" + System.currentTimeMillis() + "_" + request.getText(); - + // 平均分割文字 + List textParts = splitTextSmart(voiceTextNew, 30); + dto.setAudioSize(textParts.size()); + // 异步分段合成 CompletableFuture.runAsync(() -> { - try { - Map params = new HashMap<>(); - params.put("text", voiceTextNew); - params.put("voice", request.getVoice()); - params.put("rate", request.getRate() != null ? request.getRate() : "+10%"); - params.put("volume", request.getVolume() != null ? request.getVolume() : "+0%"); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setAccept(Collections.singletonList(MediaType.APPLICATION_OCTET_STREAM)); - HttpEntity> entity = new HttpEntity<>(params, headers); - - ResponseEntity response = restTemplate.exchange( - pythonServiceUrl + "/stream-synthesize", - HttpMethod.POST, entity, byte[].class - ); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - dto.setAudioBase64(Base64.getEncoder().encodeToString(response.getBody())); - dto.setAudioSize(response.getBody().length); - dto.setAudioFormat("audio/mpeg"); - - // ============================================== - // 👇 用唯一key存(不覆盖别人) - // ============================================== - LocalAudioCache.put(cacheKey, dto); + for (int i = 0; i < textParts.size(); i++) { + String part = textParts.get(i); + if (ObjectUtil.isEmpty(part)) continue; + + try { + Map params = new HashMap<>(); + params.put("text", part); + params.put("voice", request.getVoice()); + params.put("rate", request.getRate() != null ? request.getRate() : "+10%"); + params.put("volume", request.getVolume() != null ? request.getVolume() : "+0%"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_OCTET_STREAM)); + HttpEntity> entity = new HttpEntity<>(params, headers); + + ResponseEntity response = restTemplate.exchange( + pythonServiceUrl + "/stream-synthesize", + HttpMethod.POST, entity, byte[].class + ); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + String base64 = Base64.getEncoder().encodeToString(response.getBody()); + + // ============================ + // 【关键】带序号存储!前端靠序号知道顺序! + // ============================ + LocalAudioCache.addPiece(cacheKey, i, part, base64); + } + } catch (Exception e) { + log.warn("分段合成失败: {}", e.getMessage()); } - } catch (Exception e) { - log.warn("语音合成忽略:{}", e.getMessage()); } }, executorService); - // ============================================== - // 👇 把 cacheKey 返回给前端(前端靠它取音频) - // ============================================== - dto.setCacheKey(cacheKey); - return ResponseEntity.ok(dto); } + // ============================================== +// 智能分段:优先按 。!?; , 空格 断开 +// 不会把一句话生硬切断,更自然 +// ============================================== + private List splitTextSmart(String text, int maxLength) { + List parts = new ArrayList<>(); + if (text == null || text.isEmpty()) return parts; + + int len = text.length(); + int start = 0; + + while (start < len) { + int end = Math.min(start + maxLength, len); + + // 如果不是最后一段,寻找最近的断句点 + if (end < len) { + // 优先按 。!?; 断句 + int splitPos = lastIndexOfAny(text, start, end, '。', '!', '?', ';'); + if (splitPos == -1) { + // 其次按 , 逗号 + splitPos = lastIndexOfAny(text, start, end, ','); + } + if (splitPos == -1) { + // 最后按空格 + splitPos = lastIndexOfAny(text, start, end, ' '); + } + if (splitPos != -1) { + end = splitPos + 1; + } + } + + String part = text.substring(start, end).trim(); + if (!part.isEmpty()) { + parts.add(part); + } + start = end; + } + return parts; + } + + // 工具:查找最后出现的符号 + private int lastIndexOfAny(String text, int start, int end, char... chars) { + for (int i = end - 1; i >= start; i--) { + for (char c : chars) { + if (text.charAt(i) == c) { + return i; + } + } + } + return -1; + } + public ResponseEntity getVoiceResult(TTSRequestDTO request) { try { String voiceText = AdvancedSymbolRemover.removePunctuationHtml(request.getText()); diff --git a/src/main/java/com/xly/util/AdvancedSymbolRemover.java b/src/main/java/com/xly/util/AdvancedSymbolRemover.java index 3d00538..39de572 100644 --- a/src/main/java/com/xly/util/AdvancedSymbolRemover.java +++ b/src/main/java/com/xly/util/AdvancedSymbolRemover.java @@ -15,26 +15,36 @@ public class AdvancedSymbolRemover { /** - * 移除所有标点符号(保留字母、数字、中文) + * 移除所有符号(保留字母、数字、中文、标点) */ public static String removePunctuationHtml(String text) { try{ if (text == null || text.isEmpty()) return ""; text = HtmlCleaner.cleanHtml(text); - text = text.replaceAll("br", ""); text = text.replaceAll("
", ""); text = text.replaceAll("", ""); text = text.replaceAll("
", ""); text = text.replaceAll(" ", ""); - // 👇 【安全正则】只删除 数字后面的 .0 或 .00 + + // 去掉数字末尾无用的 .0 .00 text = text.replaceAll("(?<=\\d)\\.0+(?!\\d)", ""); - // 移除中文和英文标点 - text = text.replaceAll("[\\pP\\p{Punct}]", ""); - // 可选:只保留字母、数字、汉字、空格 - text = text.replaceAll("[^\\p{L}\\p{N}\\p{Zs}]", ""); + // 去掉无用文字 + text = text.replaceAll("换一换", ""); + + // 去掉 -,但保留负数 + text = text.replaceAll("(? getAudio(String cacheKey) { - if (ObjectUtil.isEmpty(cacheKey)) { - return ResponseEntity.ok(TTSResponseDTO.builder().code(204).build()); - } - TTSResponseDTO dto = LocalAudioCache.get(cacheKey); - if (dto == null) { - return ResponseEntity.ok(TTSResponseDTO.builder().code(204).build()); - } - return ResponseEntity.ok(dto); + @GetMapping("/audio/piece") + public ResponseEntity> getPiece( + @RequestParam String cacheKey, + @RequestParam int index) { + return ResponseEntity.ok(LocalAudioCache.getPiece(cacheKey, index)); } /** diff --git a/src/main/resources/templates/chat.html b/src/main/resources/templates/chat.html index 35f2051..1f464d1 100644 --- a/src/main/resources/templates/chat.html +++ b/src/main/resources/templates/chat.html @@ -479,8 +479,7 @@ }; let chatHistory = []; - let audioQueue = []; - let isPlaying = false; + let currentModel = 'general'; const md = window.markdownit({ html: true, @@ -556,9 +555,45 @@ doMessage(input, message, button); } - // ====================== - // 🔥 已修复:完整 fetch 流式交互 - // ====================== + // ============================ + // 核心:按序号 0,1,2... 顺序获取 + 播放 + // =========================== + async function playByIndex(cacheKey, currentIndex, totalSize) { + if (currentIndex >= totalSize) return; + + async function checkPiece() { + try { + // 你原来的 fetch 写法 100% 保留 + const res = await fetch(`${CONFIG.backendUrl}/api/tts/audio/piece?cacheKey=${cacheKey}&index=${currentIndex}`); + const piece = await res.json(); + + if (piece && piece.audio) { + // 你原来的 base64 播放方式 + const blob = base64ToBlob(piece.audio); + const audio = new Audio(URL.createObjectURL(blob)); + + audio.onended = () => { + // 自动播放下一段 + playByIndex(cacheKey, currentIndex + 1, totalSize); + }; + + audio.play().catch(err => { + console.log('播放异常,自动下一段', err); + playByIndex(cacheKey, currentIndex + 1, totalSize); + }); + + } else { + // 没获取到,等待再取(你原来的 800ms) + setTimeout(checkPiece, 800); + } + } catch (e) { + setTimeout(checkPiece, 800); + } + } + + checkPiece(); + } + async function doMessage(input, message, button) { addMessage(message, 'user'); showTypingIndicator(); @@ -586,36 +621,11 @@ const replyText = (data.processedText || "") + (data.systemText || ""); addMessage(replyText, 'ai'); - // ============================================== - // 👇 【关键】用 cacheKey 取音频(绝对不串音) - // ============================================== const cacheKey = data.cacheKey; - if (!cacheKey) return; - - let retry = 0; - const checkAudio = async () => { - retry++; - if (retry > 20) return; - - try { - // ============================================== - // 👇 用 cacheKey 获取自己的音频(别人拿不到) - // ============================================== - const res = await fetch(`${CONFIG.backendUrl}/api/tts/audio?cacheKey=${encodeURIComponent(cacheKey)}`); - const audioData = await res.json(); - - if (audioData.audioBase64) { - const blob = base64ToBlob(audioData.audioBase64); - const audio = new Audio(URL.createObjectURL(blob)); - audio.play().catch(err => console.log('播放异常', err)); - } else { - setTimeout(checkAudio, 800); - } - } catch (e) { - setTimeout(checkAudio, 800); - } - }; - setTimeout(checkAudio, 1200); + const audioSize = data.audioSize; // 总分几段 + + + playByIndex(cacheKey, 0, audioSize); } catch (error) { console.error('错误:', error); @@ -629,28 +639,6 @@ } } - // ============================== - // 👇 语音排队播放函数(保证顺序) - // ============================== - function playNextAudio() { - if (isPlaying || audioQueue.length === 0) return; - - isPlaying = true; - const base64 = audioQueue.shift(); - const blob = base64ToBlob(base64); - const audio = new Audio(URL.createObjectURL(blob)); - - audio.onended = () => { - isPlaying = false; - playNextAudio(); // 播放下一条 - }; - - audio.play().catch(err => { - isPlaying = false; - playNextAudio(); - }); - } - function base64ToBlob(base64) { const byteCharacters = atob(base64); const byteNumbers = new Array(byteCharacters.length);