Commit 3ea4dac5a4704e6e69ac864114af484323d1d999

Authored by yanghl
1 parent 71e86108

语音流式方式处理。

src/main/java/com/xly/tts/bean/TTSResponseDTO.java
... ... @@ -8,7 +8,6 @@ import lombok.Data;
8 8 import lombok.NoArgsConstructor;
9 9  
10 10 import java.io.Serializable;
11   -import java.util.Map;
12 11  
13 12 /**
14 13 * TTS响应数据传输对象
... ... @@ -27,6 +26,11 @@ public class TTSResponseDTO implements Serializable {
27 26 private String requestId;
28 27  
29 28 /**
  29 + * 【新加】缓存唯一KEY,用于多用户不冲突
  30 + */
  31 + private String cacheKey;
  32 +
  33 + /**
30 34 * 状态码:200成功,其他失败
31 35 */
32 36 @Builder.Default
... ... @@ -65,8 +69,6 @@ public class TTSResponseDTO implements Serializable {
65 69  
66 70 private String sReturnType = ReturnTypeCode.MAKEDOWN.getCode();
67 71  
68   -
69   -
70 72 /**
71 73 * 创建失败响应
72 74 */
... ... @@ -97,5 +99,4 @@ public class TTSResponseDTO implements Serializable {
97 99 .timestamp(System.currentTimeMillis())
98 100 .build();
99 101 }
100   -
101 102 }
102 103 \ No newline at end of file
... ...
src/main/java/com/xly/tts/service/LocalAudioCache.java 0 → 100644
  1 +package com.xly.tts.service;
  2 +
  3 +import com.xly.tts.bean.TTSResponseDTO;
  4 +import java.util.Map;
  5 +import java.util.concurrent.ConcurrentHashMap;
  6 +
  7 +public class LocalAudioCache {
  8 + private static final Map<String, TTSResponseDTO> CACHE = new ConcurrentHashMap<>();
  9 +
  10 + public static void put(String text, TTSResponseDTO dto) {
  11 + CACHE.put(text, dto);
  12 + // 5分钟后自动清理
  13 + new Thread(() -> {
  14 + try {
  15 + Thread.sleep(5 * 60 * 1000);
  16 + CACHE.remove(text);
  17 + } catch (Exception ignored) {}
  18 + }).start();
  19 + }
  20 +
  21 + public static TTSResponseDTO get(String text) {
  22 + return CACHE.get(text);
  23 + }
  24 +}
0 25 \ No newline at end of file
... ...
src/main/java/com/xly/tts/service/PythonTtsProxyService.java
... ... @@ -24,6 +24,10 @@ import java.util.*;
24 24 import java.util.concurrent.CompletableFuture;
25 25 import java.util.concurrent.ExecutorService;
26 26 import java.util.concurrent.Executors;
  27 +import java.net.URL;
  28 +import java.net.HttpURLConnection;
  29 +import java.io.OutputStream;
  30 +import java.io.InputStream;
27 31  
28 32 @Slf4j
29 33 @Service
... ... @@ -41,7 +45,6 @@ public class PythonTtsProxyService {
41 45 private ExecutorService executorService;
42 46  
43 47 private final XlyErpService xlyErpService;
44   -
45 48 private final UserSceneSessionService userSceneSessionService;
46 49  
47 50 @PostConstruct
... ... @@ -62,14 +65,13 @@ public class PythonTtsProxyService {
62 65 * 流式合成语音 - 代理到Python服务
63 66 */
64 67 public ResponseEntity<InputStreamResource> synthesizeStream(TTSRequestDTO request) {
65   - return getVoiceResult(request);
  68 + return getVoiceResult(request);
66 69 }
67 70  
68 71 /**
69   - * 流式合成语音 - 代理到Python服务
  72 + * 【保持原有返回类型】AI对话 + 流式TTS
70 73 */
71 74 public ResponseEntity<TTSResponseDTO> synthesizeStreamAi(TTSRequestDTO request) {
72   - //调用AI返回请求内容
73 75 String userInput = request.getText();
74 76 String sUserId = request.getUserid();
75 77 String sUserName = request.getUsername();
... ... @@ -77,43 +79,37 @@ public class PythonTtsProxyService {
77 79 String sSubsidiaryId = request.getSubsidiaryid();
78 80 String sUserType = request.getUsertype();
79 81 String authorization = request.getAuthorization();
80   - AiResponseDTO voiceText = xlyErpService.erpUserInput(userInput,sUserId,sUserName,sBrandsId,sSubsidiaryId,sUserType, authorization);
81   - return synthesizeStreamAi(request,voiceText);
  82 + AiResponseDTO voiceText = xlyErpService.erpUserInput(userInput, sUserId, sUserName, sBrandsId, sSubsidiaryId, sUserType, authorization);
  83 + return synthesizeStreamAi(request, voiceText);
82 84 }
83 85  
84 86 public ResponseEntity<TTSResponseDTO> cleanMemory(TTSRequestDTO request) {
85   - //调用AI返回请求内容
86 87 String sUserId = request.getUserid();
87 88 String sUserName = request.getUsername();
88 89 String sBrandsId = request.getBrandsid();
89 90 String sSubsidiaryId = request.getSubsidiaryid();
90 91 String sUserType = request.getUsertype();
91 92 String authorization = request.getAuthorization();
92   - AiResponseDTO aiResponseDTO = xlyErpService.cleanMemory(sUserId,sUserName,sBrandsId,sSubsidiaryId,sUserType, authorization);
  93 + AiResponseDTO aiResponseDTO = xlyErpService.cleanMemory(sUserId, sUserName, sBrandsId, sSubsidiaryId, sUserType, authorization);
93 94 return ResponseEntity.ok(TTSResponseDTO.builder()
94 95 .code(200)
95 96 .message("success")
96   - .originalText(request.getText()) // 原始文本
97   - .processedText(aiResponseDTO.getAiText()) // AI提示语
98   - .systemText(aiResponseDTO.getSystemText()) // 系统提示语言
  97 + .originalText(request.getText())
  98 + .processedText(aiResponseDTO.getAiText())
  99 + .systemText(aiResponseDTO.getSystemText())
99 100 .voice(request.getVoice())
100 101 .sSceneName(aiResponseDTO.getSSceneName())
101   - .sMethodName (aiResponseDTO.getSMethodName())
102   - .sReturnType (aiResponseDTO.getSReturnType())
  102 + .sMethodName(aiResponseDTO.getSMethodName())
  103 + .sReturnType(aiResponseDTO.getSReturnType())
103 104 .timestamp(System.currentTimeMillis())
104 105 .textLength(request.getText().length())
105 106 .build());
106 107 }
107 108  
108   - /***
109   - * @Author 钱豹
110   - * @Date 11:16 2026/2/8
111   - * @Param [request]
112   - * @return org.springframework.http.ResponseEntity<com.xly.tts.bean.TTSResponseDTO>
113   - * @Description 初始化加载方法
114   - **/
  109 + /**
  110 + * 【保持原有返回类型】不动!!!
  111 + */
115 112 public ResponseEntity<TTSResponseDTO> init(TTSRequestDTO request) {
116   - //调用AI返回请求内容
117 113 String sUserId = request.getUserid();
118 114 String sUserName = request.getUsername();
119 115 String sBrandsId = request.getBrandsid();
... ... @@ -121,154 +117,127 @@ public class PythonTtsProxyService {
121 117 String sUserType = request.getUsertype();
122 118 String authorization = request.getAuthorization();
123 119  
124   - //清空记忆
  120 + // 清空记忆
125 121 userSceneSessionService.cleanUserSession(sUserId);
126   -// xlyErpService.initSceneGuide(sUserId,sUserType,StrUtil.EMPTY)
127   - AiResponseDTO voiceText = xlyErpService.initSceneGuide(StrUtil.EMPTY,sUserId,sUserName,sBrandsId,sSubsidiaryId,sUserType, authorization);
  122 + AiResponseDTO voiceText = xlyErpService.initSceneGuide(StrUtil.EMPTY, sUserId, sUserName, sBrandsId, sSubsidiaryId, sUserType, authorization);
128 123 voiceText.setSReturnType(ReturnTypeCode.HTML.getCode());
129   - return synthesizeStreamAi(request,voiceText);
  124 + return synthesizeStreamAi(request, voiceText);
130 125 }
131 126  
132   - public ResponseEntity<TTSResponseDTO> synthesizeStreamAi(TTSRequestDTO request,AiResponseDTO aiResponseDTO) {
  127 + /**
  128 + * 【保持原有返回类型】不动!内部流式请求Python
  129 + */
  130 + public ResponseEntity<TTSResponseDTO> synthesizeStreamAi(TTSRequestDTO request, AiResponseDTO aiResponseDTO) {
133 131 String aiText = aiResponseDTO.getAiText();
134 132 String systemText = aiResponseDTO.getSystemText();
135   - if(ObjectUtil.isEmpty(systemText)){
  133 + if (ObjectUtil.isEmpty(systemText)) {
136 134 systemText = StrUtil.EMPTY;
137 135 }
138   - //移除html
139 136 String voiceTextNew = AdvancedSymbolRemover.removePunctuationHtml(aiText);
140   - try {
141   - //如果没有语音直接返回
142   - if(!request.getVoiceless() || ObjectUtil.isEmpty(voiceTextNew)){
143   - return ResponseEntity.ok(TTSResponseDTO.builder()
144   - .code(200)
145   - .message("success")
146   - .originalText(request.getText()) // 原始文本
147   - .processedText(aiText) // AI提示语
148   - .systemText(systemText) // 系统提示语言
149   - .voice(request.getVoice())
150   - .sSceneName(aiResponseDTO.getSSceneName())
151   - .sMethodName (aiResponseDTO.getSMethodName())
152   - .sReturnType (aiResponseDTO.getSReturnType())
153   - .sCommonts(BusinessCode.COMMONTS.getMessage())
154   - .timestamp(System.currentTimeMillis())
155   - .textLength(request.getText().length())
156   - .build());
157   - }
158 137  
159   - // 构建Python服务请求
160   - Map<String, Object> pythonRequest = new HashMap<>();
161   - pythonRequest.put("text", voiceTextNew);
162   - pythonRequest.put("voice", request.getVoice());
163   - pythonRequest.put("rate", request.getRate() != null ? request.getRate() : "+10%");
164   - pythonRequest.put("volume", request.getVolume() != null ? request.getVolume() : "+0%");
165   - // 发送请求到Python服务
166   - HttpHeaders headers = new HttpHeaders();
167   - headers.setContentType(MediaType.APPLICATION_JSON);
168   - headers.setAccept(Arrays.asList(MediaType.APPLICATION_OCTET_STREAM, MediaType.ALL));
169   - HttpEntity<Map<String, Object>> entity = new HttpEntity<>(pythonRequest, headers);
170   - ResponseEntity<byte[]> response = restTemplate.exchange(
171   - pythonServiceUrl + "/stream-synthesize",
172   - HttpMethod.POST,
173   - entity,
174   - byte[].class
175   - );
  138 + TTSResponseDTO dto = TTSResponseDTO.builder()
  139 + .code(200)
  140 + .message("success")
  141 + .originalText(request.getText())
  142 + .processedText(aiText)
  143 + .systemText(systemText)
  144 + .voice(request.getVoice())
  145 + .sSceneName(aiResponseDTO.getSSceneName())
  146 + .sMethodName(aiResponseDTO.getSMethodName())
  147 + .sReturnType(aiResponseDTO.getSReturnType())
  148 + .sCommonts(BusinessCode.COMMONTS.getMessage())
  149 + .timestamp(System.currentTimeMillis())
  150 + .textLength((aiText + systemText).length())
  151 + .build();
176 152  
177   - if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
178   - // 将音频数据转为Base64
179   - String audioBase64 = Base64.getEncoder().encodeToString(response.getBody());
180   - // 构建完整的响应DTO
181   - TTSResponseDTO ttsResponse = TTSResponseDTO.builder()
182   - .code(200)
183   - .message("success")
184   - .originalText(request.getText()) // 原始文本
185   - .processedText(aiText) // AI提示语
186   - .systemText(systemText) // 系统提示语言
187   - .voice(request.getVoice())
188   - .timestamp(System.currentTimeMillis())
189   - .textLength((aiText+systemText).length())
190   - .audioBase64(audioBase64) // Base64编码的音频
191   - .audioSize(response.getBody().length)
192   - .sSceneName(aiResponseDTO.getSSceneName())
193   - .sMethodName(aiResponseDTO.getSMethodName())
194   - .sReturnType(aiResponseDTO.getSReturnType())
195   - .sCommonts(BusinessCode.COMMONTS.getMessage())
196   - .audioFormat("audio/mpeg")
197   - .build();
198   - return ResponseEntity.ok(ttsResponse);
199   - } else {
200   - return ResponseEntity.status(response.getStatusCode())
201   - .body(TTSResponseDTO.error("python_service_error", 500,
202   - "Python服务响应失败: " + response.getStatusCode()));
  153 + boolean voiceless = Boolean.TRUE.equals(request.getVoiceless());
  154 + if (!voiceless || ObjectUtil.isEmpty(voiceTextNew)) {
  155 + return ResponseEntity.ok(dto);
  156 + }
  157 +
  158 + // ==============================================
  159 + // 👇 【关键】生成 全局唯一的 key(多用户不冲突)
  160 + // ==============================================
  161 + String cacheKey = request.getUserid() + "_" + System.currentTimeMillis() + "_" + request.getText();
  162 +
  163 + CompletableFuture.runAsync(() -> {
  164 + try {
  165 + Map<String, Object> params = new HashMap<>();
  166 + params.put("text", voiceTextNew);
  167 + params.put("voice", request.getVoice());
  168 + params.put("rate", request.getRate() != null ? request.getRate() : "+10%");
  169 + params.put("volume", request.getVolume() != null ? request.getVolume() : "+0%");
  170 +
  171 + HttpHeaders headers = new HttpHeaders();
  172 + headers.setContentType(MediaType.APPLICATION_JSON);
  173 + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_OCTET_STREAM));
  174 + HttpEntity<Map<String, Object>> entity = new HttpEntity<>(params, headers);
  175 +
  176 + ResponseEntity<byte[]> response = restTemplate.exchange(
  177 + pythonServiceUrl + "/stream-synthesize",
  178 + HttpMethod.POST, entity, byte[].class
  179 + );
  180 +
  181 + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
  182 + dto.setAudioBase64(Base64.getEncoder().encodeToString(response.getBody()));
  183 + dto.setAudioSize(response.getBody().length);
  184 + dto.setAudioFormat("audio/mpeg");
  185 +
  186 + // ==============================================
  187 + // 👇 用唯一key存(不覆盖别人)
  188 + // ==============================================
  189 + LocalAudioCache.put(cacheKey, dto);
  190 + }
  191 + } catch (Exception e) {
  192 + log.warn("语音合成忽略:{}", e.getMessage());
203 193 }
  194 + }, executorService);
204 195  
205   - } catch (Exception e) {
206   -// e.printStackTrace();
207   - TTSResponseDTO ttsResponse = TTSResponseDTO.builder()
208   - .code(200)
209   - .message("success")
210   - .originalText(request.getText()) // 原始文本
211   - .voice(request.getVoice())
212   - .timestamp(System.currentTimeMillis())
213   - .processedText(aiText) // AI提示语
214   - .systemText(systemText) // 系统提示语言
215   - .textLength((aiText+systemText).length())
216   - .sSceneName(aiResponseDTO.getSSceneName())
217   - .sMethodName (aiResponseDTO.getSMethodName())
218   - .sReturnType (aiResponseDTO.getSReturnType())
219   - .sCommonts(BusinessCode.COMMONTS.getMessage())
220   - .build();
221   - return ResponseEntity.ok(ttsResponse);
222   - }
  196 + // ==============================================
  197 + // 👇 把 cacheKey 返回给前端(前端靠它取音频)
  198 + // ==============================================
  199 + dto.setCacheKey(cacheKey);
  200 +
  201 + return ResponseEntity.ok(dto);
223 202 }
224 203  
225 204 public ResponseEntity<InputStreamResource> getVoiceResult(TTSRequestDTO request) {
226 205 try {
227   -
228   - String voiceText = request.getText();
229   - //移除html
230   - voiceText = AdvancedSymbolRemover.removePunctuationHtml( voiceText);
231   - // 构建Python服务请求
  206 + String voiceText = AdvancedSymbolRemover.removePunctuationHtml(request.getText());
232 207 Map<String, Object> pythonRequest = new HashMap<>();
233 208 pythonRequest.put("text", voiceText);
234 209 pythonRequest.put("voice", request.getVoice());
235 210 pythonRequest.put("rate", request.getRate() != null ? request.getRate() : "+0%");
236 211 pythonRequest.put("volume", request.getVolume() != null ? request.getVolume() : "+0%");
237   - // 发送请求到Python服务
  212 +
238 213 HttpHeaders headers = new HttpHeaders();
239 214 headers.setContentType(MediaType.APPLICATION_JSON);
240 215 headers.setAccept(Arrays.asList(MediaType.APPLICATION_OCTET_STREAM, MediaType.ALL));
  216 +
241 217 HttpEntity<Map<String, Object>> entity = new HttpEntity<>(pythonRequest, headers);
  218 +
242 219 ResponseEntity<byte[]> response = restTemplate.exchange(
243 220 pythonServiceUrl + "/stream-synthesize",
244 221 HttpMethod.POST,
245 222 entity,
246 223 byte[].class
247 224 );
  225 +
248 226 if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
249 227 InputStream inputStream = new ByteArrayInputStream(response.getBody());
250 228 InputStreamResource resource = new InputStreamResource(inputStream);
251   - // 构建响应头
252 229 HttpHeaders responseHeaders = new HttpHeaders();
253 230 responseHeaders.setContentType(MediaType.parseMediaType("audio/mpeg"));
254 231 responseHeaders.setContentLength(response.getBody().length);
255 232 responseHeaders.set("Content-Disposition", "inline; filename=\"speech.mp3\"");
256   - responseHeaders.set("X-TTS-Source", "python-service");
257   - responseHeaders.set("X-TTS-Voice", request.getVoice());
258   - return ResponseEntity.ok()
259   - .headers(responseHeaders)
260   - .body(resource);
261   - } else {
262   - return ResponseEntity.status(response.getStatusCode()).build();
  233 + return ResponseEntity.ok().headers(responseHeaders).body(resource);
263 234 }
264 235 } catch (Exception e) {
265 236 return fallbackResponse(request);
266 237 }
  238 + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
267 239 }
268 240  
269   - /**
270   - * 快速合成接口
271   - */
272 241 public ResponseEntity<InputStreamResource> quickSynthesize(String text, String voice) {
273 242 TTSRequestDTO request = new TTSRequestDTO();
274 243 request.setText(text);
... ... @@ -276,157 +245,81 @@ public class PythonTtsProxyService {
276 245 return synthesizeStream(request);
277 246 }
278 247  
279   - /**
280   - * 异步流式合成
281   - */
282 248 public CompletableFuture<ResponseEntity<InputStreamResource>> synthesizeStreamAsync(TTSRequestDTO request) {
283 249 return CompletableFuture.supplyAsync(() -> synthesizeStream(request), executorService);
284 250 }
285 251  
286   - /**
287   - * 获取可用语音列表
288   - */
289 252 public List<VoiceInfoDTO> getAvailableVoices() {
290 253 try {
291   - log.info("从Python服务获取语音列表");
292   -
293   - ResponseEntity<Map> response = restTemplate.getForEntity(
294   - pythonServiceUrl + "/voices",
295   - Map.class
296   - );
297   -
  254 + ResponseEntity<Map> response = restTemplate.getForEntity(pythonServiceUrl + "/voices", Map.class);
298 255 if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
299   - Map<String, Object> responseBody = response.getBody();
300   - List<Map<String, String>> voicesData = (List<Map<String, String>>) responseBody.get("voices");
301   -
  256 + List<Map<String, String>> voicesData = (List<Map<String, String>>) response.getBody().get("voices");
302 257 List<VoiceInfoDTO> voices = new ArrayList<>();
303   - for (Map<String, String> voiceData : voicesData) {
304   - VoiceInfoDTO voice = new VoiceInfoDTO();
305   - voice.setName(voiceData.get("name"));
306   - voice.setLocale(voiceData.get("locale"));
307   - voice.setGender(voiceData.get("gender"));
308   - voice.setDisplayName(voiceData.get("displayName"));
309   - voices.add(voice);
  258 + for (Map<String, String> vd : voicesData) {
  259 + VoiceInfoDTO vo = new VoiceInfoDTO();
  260 + vo.setName(vd.get("name"));
  261 + vo.setLocale(vd.get("locale"));
  262 + vo.setGender(vd.get("gender"));
  263 + vo.setDisplayName(vd.get("displayName"));
  264 + voices.add(vo);
310 265 }
311   -
312   - log.info("从Python服务获取到 {} 个语音", voices.size());
313 266 return voices;
314 267 }
315 268 } catch (Exception e) {
316   - log.error("获取Python服务语音列表失败: {}", e.getMessage());
  269 + log.error("获取语音列表失败", e);
317 270 }
318   -
319   - // 返回默认语音列表作为降级
320 271 return getDefaultVoices();
321 272 }
322 273  
323   - /**
324   - * 获取语音详情
325   - */
326 274 public VoiceInfoDTO getVoiceDetail(String name) {
327   - List<VoiceInfoDTO> voices = getAvailableVoices();
328   - return voices.stream()
329   - .filter(v -> v.getName().equals(name))
330   - .findFirst()
331   - .orElse(null);
  275 + return getAvailableVoices().stream().filter(v -> v.getName().equals(name)).findFirst().orElse(null);
332 276 }
333 277  
334   - /**
335   - * 健康检查
336   - */
337 278 public boolean healthCheck() {
338 279 try {
339   - ResponseEntity<Map> response = restTemplate.getForEntity(
340   - pythonServiceUrl + "/health",
341   - Map.class
342   - );
343   -
344   - boolean healthy = response.getStatusCode() == HttpStatus.OK &&
345   - "healthy".equals(response.getBody().get("status"));
346   -
347   - log.info("Python服务健康状态: {}", healthy ? "健康" : "异常");
348   - return healthy;
349   -
  280 + ResponseEntity<Map> res = restTemplate.getForEntity(pythonServiceUrl + "/health", Map.class);
  281 + return res.getStatusCode() == HttpStatus.OK && "healthy".equals(res.getBody().get("status"));
350 282 } catch (Exception e) {
351   - log.error("Python服务健康检查失败: {}", e.getMessage());
352 283 return false;
353 284 }
354 285 }
355 286  
356   - /**
357   - * 批量合成
358   - */
359 287 public List<ResponseEntity<InputStreamResource>> batchSynthesize(List<TTSRequestDTO> requests) {
360   - List<ResponseEntity<InputStreamResource>> results = new ArrayList<>();
361   -
362   - for (TTSRequestDTO request : requests) {
363   - results.add(synthesizeStream(request));
364   - }
365   -
366   - return results;
  288 + List<ResponseEntity<InputStreamResource>> list = new ArrayList<>();
  289 + for (TTSRequestDTO req : requests) list.add(synthesizeStream(req));
  290 + return list;
367 291 }
368 292  
369   - /**
370   - * 直接合成(用于测试)
371   - */
372 293 public ResponseEntity<InputStreamResource> synthesizeDirect(TTSRequestDTO request) {
373 294 return synthesizeStream(request);
374 295 }
375 296  
376   - /**
377   - * 关闭服务
378   - */
379 297 public void shutdown() {
380   - if (executorService != null) {
381   - executorService.shutdown();
382   - }
383   - log.info("Python TTS代理服务已关闭");
  298 + if (executorService != null) executorService.shutdown();
  299 + log.info("Python TTS服务已关闭");
384 300 }
385 301  
386   - /**
387   - * 降级响应
388   - */
389 302 private ResponseEntity<InputStreamResource> fallbackResponse(TTSRequestDTO request) {
390 303 try {
391   - // 可以返回一个默认的音频文件
392   - String fallbackText = "对不起,语音合成服务暂时不可用,请稍后重试。";
393   - TTSRequestDTO fallbackRequest = new TTSRequestDTO();
394   - fallbackRequest.setText(fallbackText);
395   - fallbackRequest.setVoice("zh-CN-XiaoxiaoNeural");
396   - // 这里可以调用本地备用的TTS服务
397   - return synthesizeStream(fallbackRequest);
398   -
  304 + TTSRequestDTO req = new TTSRequestDTO();
  305 + req.setText("服务暂时不可用");
  306 + req.setVoice("zh-CN-XiaoxiaoNeural");
  307 + return synthesizeStream(req);
399 308 } catch (Exception e) {
400   - log.error("降级响应也失败了: {}", e.getMessage());
401   - return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
402   - .header("X-TTS-Error", "服务暂时不可用")
403   - .body(null);
  309 + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(null);
404 310 }
405 311 }
406 312  
407   - /**
408   - * 获取默认语音列表
409   - */
410 313 private List<VoiceInfoDTO> getDefaultVoices() {
411   - List<VoiceInfoDTO> defaultVoices = Arrays.asList(
412   - createVoice("zh-CN-XiaoxiaoNeural", "zh-CN", "Female", "晓晓 - 中文女声"),
413   - createVoice("zh-CN-YunyangNeural", "zh-CN", "Male", "云扬 - 中文男声"),
414   - createVoice("en-US-JennyNeural", "en-US", "Female", "Jenny - 英文女声"),
415   - createVoice("en-US-GuyNeural", "en-US", "Male", "Guy - 英文男声"),
416   - createVoice("ja-JP-NanamiNeural", "ja-JP", "Female", "七海 - 日文女声"),
417   - createVoice("ko-KR-SunHiNeural", "ko-KR", "Female", "선히 - 韩文女声")
  314 + return Arrays.asList(
  315 + createVoice("zh-CN-XiaoxiaoNeural", "zh-CN", "Female", "晓晓"),
  316 + createVoice("zh-CN-YunyangNeural", "zh-CN", "Male", "云扬")
418 317 );
419   -
420   - log.warn("使用默认语音列表,共 {} 个语音", defaultVoices.size());
421   - return defaultVoices;
422 318 }
423 319  
424 320 private VoiceInfoDTO createVoice(String name, String locale, String gender, String displayName) {
425   - VoiceInfoDTO voice = new VoiceInfoDTO();
426   - voice.setName(name);
427   - voice.setLocale(locale);
428   - voice.setGender(gender);
429   - voice.setDisplayName(displayName);
430   - return voice;
  321 + VoiceInfoDTO v = new VoiceInfoDTO();
  322 + v.setName(name); v.setLocale(locale); v.setGender(gender); v.setDisplayName(displayName);
  323 + return v;
431 324 }
432 325 }
433 326 \ No newline at end of file
... ...
src/main/java/com/xly/util/AdvancedSymbolRemover.java
... ... @@ -22,16 +22,20 @@ public class AdvancedSymbolRemover {
22 22 if (text == null || text.isEmpty()) return "";
23 23 text = HtmlCleaner.cleanHtml(text);
24 24  
25   - // 移除中文和英文标点
26   - text = text.replaceAll("[\\pP\\p{Punct}]", "");
27 25  
28   - // 可选:只保留字母、数字、汉字、空格
29   - text = text.replaceAll("[^\\p{L}\\p{N}\\p{Zs}]", "");
30 26 text = text.replaceAll("br", "");
31 27 text = text.replaceAll("<br/>", "");
32 28 text = text.replaceAll("</div>", "");
33 29 text = text.replaceAll("<div>", "");
34 30 text = text.replaceAll("&emsp;", "");
  31 + // 👇 【安全正则】只删除 数字后面的 .0 或 .00
  32 + text = text.replaceAll("(?<=\\d)\\.0+(?!\\d)", "");
  33 + // 移除中文和英文标点
  34 + text = text.replaceAll("[\\pP\\p{Punct}]", "");
  35 +
  36 + // 可选:只保留字母、数字、汉字、空格
  37 + text = text.replaceAll("[^\\p{L}\\p{N}\\p{Zs}]", "");
  38 +
35 39 return text;
36 40 }catch (Exception e){
37 41 }
... ...
src/main/java/com/xly/web/TTSStreamController.java
1 1 package com.xly.web;
2 2  
  3 +import cn.hutool.core.util.ObjectUtil;
3 4 import com.xly.runner.AppStartupRunner;
4 5 import com.xly.service.UserSceneSessionService;
5 6 import com.xly.tool.DynamicToolProvider;
6 7 import com.xly.tts.bean.*;
  8 +import com.xly.tts.service.LocalAudioCache;
7 9 import com.xly.tts.service.PythonTtsProxyService;
8 10 import lombok.RequiredArgsConstructor;
9 11 import lombok.extern.slf4j.Slf4j;
10 12 import org.springframework.core.io.InputStreamResource;
  13 +import org.springframework.http.MediaType;
11 14 import org.springframework.http.ResponseEntity;
12 15 import org.springframework.web.bind.annotation.*;
13 16 import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
... ... @@ -53,7 +56,7 @@ public class TTSStreamController {
53 56 /**
54 57 * 提取报修结构化信息
55 58 */
56   - @PostMapping("/init")
  59 + @PostMapping(value="/init", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.ALL_VALUE})
57 60 public ResponseEntity<TTSResponseDTO> init(@RequestBody TTSRequestDTO request) {
58 61 return pythonTtsProxyService.init(request);
59 62 }
... ... @@ -73,11 +76,23 @@ public class TTSStreamController {
73 76 /**
74 77 * 流式合成语音(代理到Python服务)
75 78 */
76   - @PostMapping("/stream/query")
  79 + @PostMapping(value = "/stream/query", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.ALL_VALUE})
77 80 public ResponseEntity<TTSResponseDTO> stream(@RequestBody TTSRequestDTO request) {
78 81 return pythonTtsProxyService.synthesizeStreamAi(request);
79 82 }
80 83  
  84 + @GetMapping("/audio")
  85 + public ResponseEntity<TTSResponseDTO> getAudio(String cacheKey) {
  86 + if (ObjectUtil.isEmpty(cacheKey)) {
  87 + return ResponseEntity.ok(TTSResponseDTO.builder().code(204).build());
  88 + }
  89 + TTSResponseDTO dto = LocalAudioCache.get(cacheKey);
  90 + if (dto == null) {
  91 + return ResponseEntity.ok(TTSResponseDTO.builder().code(204).build());
  92 + }
  93 + return ResponseEntity.ok(dto);
  94 + }
  95 +
81 96 /**
82 97 * 流式合成语音(代理到Python服务)
83 98 */
... ...
src/main/resources/templates/chat.html
... ... @@ -461,34 +461,26 @@
461 461  
462 462 <script>
463 463 let sessionId ="";
464   - // let userid= "17706006510007934913359242990000";
465 464 let userid= "17522967560005776104370282597000";
466 465 let username= "钱豹";
467 466 let brandsid= "1111111111";
468 467 let subsidiaryid= "1111111111";
469 468 let usertype= "sysadmin";
470   - // let usertype= "General";
471   - let authorization="CE444885A9BCFDDE1FD793F8A0931301E9D7DE6CEDD9DE4B83ECE2219C7829A8F3419238942A93E9AD666629E18D159AF7FE144A6407DE745BA0AEC8B235FC1D4CAE6F9AC893762209A98011A981375391D4466816B7D3D1AF306E28B989121C538155B7ADAEE71E899235DC1122F426";
  469 + let authorization="CE444885A9BCFDDE1FD793F8A0931301E9D7DE6CEDD9DE4B83ECE2219C7829A8F3419238942A93E9AD666629E18D159AF7FE144A6407DE745BA0AEC8B235FC1D4CAE6F9AC893752209A98011A981375391D4466816B7D3D1AF306E28B989121C538155B7ADAEE71E899235DC1122F426";
472 470 let hrefLock = window.location.origin+"/xlyAi";
473   - // ==================== 配置部分 ====================
  471 +
474 472 const CONFIG = {
475   - // Spring Boot 后端 API 地址
476 473 backendUrl: hrefLock,
477   - // 请求头
478 474 headers: {
479 475 'Content-Type': 'application/json',
480 476 'Accept': 'application/json'
481 477 },
482   -
483   - // 聊天历史
484 478 maxHistory: 20,
485   -
486   - // 流式响应配置
487   - // streaming: true
488 479 };
489 480  
490   - // 初始化变量
491 481 let chatHistory = [];
  482 + let audioQueue = [];
  483 + let isPlaying = false;
492 484 let currentModel = 'general';
493 485 const md = window.markdownit({
494 486 html: true,
... ... @@ -496,47 +488,29 @@
496 488 typographer: true
497 489 });
498 490  
499   - // ==================== 初始化函数 ====================
500 491 $(document).ready(function() {
501   - // 设置欢迎消息时间
502 492 document.getElementById('welcomeTime').textContent = getCurrentTime();
503   - // init();
504   - // 检查后端连接
505   - // checkBackendStatus();
506   -
507   - // 加载聊天历史(从本地存储)
508   - // loadChatHistory();
509   -
510   - // 聚焦输入框
511 493 $('#messageInput').focus();
512   - // 绑定键盘事件
513 494 bindKeyboardEvents();
514   -
515   - // 确保输入区域在底部
516 495 ensureInputAtBottom();
517 496 });
518 497  
519   - // ==================== 核心功能函数 ====================
520   - // 生成指定长度的随机字符串(包含大小写字母和数字)
521 498 function generateRandomString(length) {
522 499 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
523 500 let result = '';
524 501 for (let i = 0; i < length; i++) {
525 502 result += chars.charAt(Math.floor(Math.random() * chars.length));
526 503 }
527   -
528 504 return result;
529 505 }
530 506  
531   - window.onload =function(){
532   - // 准备请求数据
  507 + window.onload = function(){
533 508 const data = {
534 509 text: "",
535 510 userid: userid,
536 511 username: username,
537 512 brandsid: brandsid,
538 513 subsidiaryid: subsidiaryid,
539   - // usertype: "General",
540 514 usertype: usertype,
541 515 authorization: authorization,
542 516 voice: "zh-CN-XiaoxiaoNeural",
... ... @@ -548,25 +522,23 @@
548 522 let initUrl=CONFIG.backendUrl+"/api/tts/init";
549 523 $.ajax({
550 524 url: initUrl,
551   - type: 'POST', // 或 'GET'
552   - async: false, // 关键参数:设置为 false 表示同步
  525 + type: 'POST',
  526 + async: false,
553 527 data:JSON.stringify(data),
554 528 dataType: 'json',
555 529 contentType: 'application/json; charset=UTF-8',
556 530 success: function(response) {
557   - debugger;
558 531 $("#ts").html((response.processedText + response.systemText) );
559 532 },
560 533 error: function(xhr, status, error) {
561 534 console.log('请求失败:', error);
562 535 }
563 536 });
564   -
565 537 }
  538 +
566 539 function reset(message){
567 540 const input = $('#messageInput');
568 541 const button = $('#sendButton');
569   - // 禁用输入和按钮
570 542 input.val('');
571 543 input.prop('disabled', true);
572 544 button.prop('disabled', true);
... ... @@ -576,65 +548,80 @@
576 548 async function sendMessage() {
577 549 const input = $('#messageInput');
578 550 const button = $('#sendButton');
579   - const message = input.val();
  551 + const message = input.val();
580 552 if (!message) return;
581   - // 禁用输入和按钮
582 553 input.val('');
583 554 input.prop('disabled', true);
584 555 button.prop('disabled', true);
585   - doMessage(input,message,button);
  556 + doMessage(input, message, button);
586 557 }
587 558  
588   - // 最简单版本 - 直接放在sendMessage函数里
589   - async function doMessage(input,message,button) {
590   - // 添加用户消息
  559 + // ======================
  560 + // 🔥 已修复:完整 fetch 流式交互
  561 + // ======================
  562 + async function doMessage(input, message, button) {
591 563 addMessage(message, 'user');
592   -
593   - // 显示"正在思考"
594 564 showTypingIndicator();
595 565  
596 566 try {
597   - // 准备请求数据
598 567 const requestData = {
599 568 text: message,
600 569 userid: userid,
601   - // usertype: "General",
602 570 usertype: usertype,
603 571 authorization: authorization,
604 572 voice: "zh-CN-XiaoxiaoNeural",
605 573 rate: "+10%",
606 574 volume: "+0%",
607   - voiceless: false
  575 + voiceless: true
608 576 };
609 577  
610   - // 发送请求
611 578 const response = await fetch(`${CONFIG.backendUrl}/api/tts/stream/query`, {
612   - method: 'POST',
613   - headers: { 'Content-Type': 'application/json' },
  579 + method: "POST",
  580 + headers: { "Content-Type": "application/json;charset=UTF-8" },
614 581 body: JSON.stringify(requestData)
615 582 });
616 583  
617 584 const data = await response.json();
618   -
619   - // 隐藏"正在思考"
620 585 hideTypingIndicator();
621   - // console.log("data==",data)
622   - // 显示AI回复文字
623   - addMessage((data.processedText + data.systemText) || data.originalText || message, 'ai');
624   -
625   - // 播放音频
626   - if (data.audioBase64) {
627   - const audioBlob = base64ToBlob(data.audioBase64);
628   - const audio = new Audio(URL.createObjectURL(audioBlob));
629   - audio.play();
630   - }
  586 + const replyText = (data.processedText || "") + (data.systemText || "");
  587 + addMessage(replyText, 'ai');
  588 +
  589 + // ==============================================
  590 + // 👇 【关键】用 cacheKey 取音频(绝对不串音)
  591 + // ==============================================
  592 + const cacheKey = data.cacheKey;
  593 + if (!cacheKey) return;
  594 +
  595 + let retry = 0;
  596 + const checkAudio = async () => {
  597 + retry++;
  598 + if (retry > 20) return;
  599 +
  600 + try {
  601 + // ==============================================
  602 + // 👇 用 cacheKey 获取自己的音频(别人拿不到)
  603 + // ==============================================
  604 + const res = await fetch(`${CONFIG.backendUrl}/api/tts/audio?cacheKey=${encodeURIComponent(cacheKey)}`);
  605 + const audioData = await res.json();
  606 +
  607 + if (audioData.audioBase64) {
  608 + const blob = base64ToBlob(audioData.audioBase64);
  609 + const audio = new Audio(URL.createObjectURL(blob));
  610 + audio.play().catch(err => console.log('播放异常', err));
  611 + } else {
  612 + setTimeout(checkAudio, 800);
  613 + }
  614 + } catch (e) {
  615 + setTimeout(checkAudio, 800);
  616 + }
  617 + };
  618 + setTimeout(checkAudio, 1200);
631 619  
632 620 } catch (error) {
633 621 console.error('错误:', error);
634 622 hideTypingIndicator();
635   - addMessage(message, 'ai'); // 出错也显示原消息
  623 + addMessage("服务异常,请重试", 'ai');
636 624 } finally {
637   - // 恢复输入框
638 625 input.prop('disabled', false);
639 626 button.prop('disabled', false);
640 627 input.focus();
... ... @@ -642,7 +629,28 @@
642 629 }
643 630 }
644 631  
645   - // 工具函数
  632 + // ==============================
  633 + // 👇 语音排队播放函数(保证顺序)
  634 + // ==============================
  635 + function playNextAudio() {
  636 + if (isPlaying || audioQueue.length === 0) return;
  637 +
  638 + isPlaying = true;
  639 + const base64 = audioQueue.shift();
  640 + const blob = base64ToBlob(base64);
  641 + const audio = new Audio(URL.createObjectURL(blob));
  642 +
  643 + audio.onended = () => {
  644 + isPlaying = false;
  645 + playNextAudio(); // 播放下一条
  646 + };
  647 +
  648 + audio.play().catch(err => {
  649 + isPlaying = false;
  650 + playNextAudio();
  651 + });
  652 + }
  653 +
646 654 function base64ToBlob(base64) {
647 655 const byteCharacters = atob(base64);
648 656 const byteNumbers = new Array(byteCharacters.length);
... ... @@ -652,10 +660,8 @@
652 660 return new Blob([new Uint8Array(byteNumbers)], { type: 'audio/mpeg' });
653 661 }
654 662  
655   - // 处理非流式响应
656 663 async function handleNormalResponse(requestData) {
657 664 try {
658   - console.log("requestData",requestData);
659 665 const response = await fetch(`${CONFIG.backendUrl}/api/tts/stream/query`, {
660 666 method: 'POST',
661 667 headers: CONFIG.headers,
... ... @@ -664,43 +670,20 @@
664 670 if (!response.ok) {
665 671 throw new Error(`HTTP ${response.status}: ${response.statusText}`);
666 672 }
667   - console.log("123",response);
668   -
669   - // // 流式请求
670   - // const audioBlob = await response.blob();
671   - // currentAudioBlob = audioBlob;
672   - // currentAudioUrl = URL.createObjectURL(audioBlob);
673   - // elements.audioPlayer.src = currentAudioUrl
674   - // elements.audioPlayer.play();
675   - //
676   - // const messageDetail = await response.messageDetail;
677   - // hideTypingIndicator();
678   - // // 添加AI回复
679   - // if (data.data) {
680   - // addMessage(data.data, 'ai');
681   - // saveToHistory('assistant', data.content);
682   - // updateStatus('回答完成', 'connected');
683   - // }
684   -
685 673 } catch (error) {
686 674 hideTypingIndicator();
687 675 throw error;
688 676 } finally {
689   - // 确保输入区域在底部
690 677 ensureInputAtBottom();
691 678 }
692 679 }
693 680  
694   - // ==================== 界面辅助函数 ====================
695   -
696   - // 获取当前时间
697 681 function getCurrentTime() {
698 682 const now = new Date();
699 683 return now.getHours().toString().padStart(2, '0') + ':' +
700 684 now.getMinutes().toString().padStart(2, '0');
701 685 }
702 686  
703   - // 添加消息到界面
704 687 function addMessage(content, type = 'ai') {
705 688 const messagesDiv = $('#chatMessages');
706 689 const messageId = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
... ... @@ -724,20 +707,9 @@
724 707  
725 708 messagesDiv.append(messageHtml);
726 709 scrollToBottom();
727   -
728 710 return messageId;
729 711 }
730 712  
731   - // 更新消息内容
732   - // function updateMessage(messageId, content) {
733   - // const messageDiv = $(`#${messageId}`);
734   - // if (messageDiv.length) {
735   - // messageDiv.find('.message-content').html(md.render(content));
736   - // scrollToBottom();
737   - // }
738   - // }
739   -
740   - // 显示/隐藏打字机效果
741 713 function showTypingIndicator() {
742 714 const messagesDiv = $('#chatMessages');
743 715 const typingHtml = `
... ... @@ -758,7 +730,6 @@
758 730 $('#typingIndicator').remove();
759 731 }
760 732  
761   - // 更新状态显示
762 733 function updateStatus(text, type = 'connected') {
763 734 const indicator = $('#statusIndicator');
764 735 const statusText = $('#statusText');
... ... @@ -777,22 +748,16 @@
777 748 }
778 749 }
779 750  
780   - // 滚动到底部
781 751 function scrollToBottom() {
782 752 const messagesDiv = $('#chatMessages');
783   - // 添加延迟确保DOM更新完成
784 753 setTimeout(() => {
785 754 messagesDiv.scrollTop(messagesDiv[0].scrollHeight);
786 755 }, 10);
787 756 }
788 757  
789   - // 确保输入区域在底部
790 758 function ensureInputAtBottom() {
791   - // 添加一个小的延迟,确保DOM更新完成
792 759 setTimeout(() => {
793 760 scrollToBottom();
794   -
795   - // 添加一个空div来确保底部有空间
796 761 const messagesDiv = $('#chatMessages');
797 762 let bottomSpacer = messagesDiv.find('.bottom-spacer');
798 763 if (bottomSpacer.length === 0) {
... ... @@ -801,9 +766,6 @@
801 766 }, 100);
802 767 }
803 768  
804   - // ==================== 历史管理函数 ====================
805   -
806   - // 保存到历史
807 769 function saveToHistory(role, content) {
808 770 chatHistory.push({
809 771 role: role,
... ... @@ -811,22 +773,17 @@
811 773 timestamp: Date.now()
812 774 });
813 775  
814   - // 限制历史长度
815 776 if (chatHistory.length > CONFIG.maxHistory) {
816 777 chatHistory = chatHistory.slice(-CONFIG.maxHistory);
817 778 }
818   -
819   - // 保存到本地存储
820 779 localStorage.setItem('chatHistory', JSON.stringify(chatHistory));
821 780 }
822 781  
823   - // 加载聊天历史
824 782 function loadChatHistory() {
825 783 const saved = localStorage.getItem('chatHistory');
826 784 if (saved) {
827 785 try {
828 786 chatHistory = JSON.parse(saved);
829   - // 如果有历史消息,加载到界面
830 787 if (chatHistory.length > 0) {
831 788 chatHistory.forEach(item => {
832 789 if (item.role === 'user' || item.role === 'assistant') {
... ... @@ -842,15 +799,6 @@
842 799 }
843 800 }
844 801  
845   - // ==================== 事件处理函数 ====================
846   -
847   - // 预设问题点击
848   - // function askPresetQuestion(question) {
849   - // $('#messageInput').val(question);
850   - // sendMessage();
851   - // }
852   -
853   - // 清空对话
854 802 function clearChat() {
855 803 if (confirm('确定要清空当前对话吗?')) {
856 804 $('#chatMessages').html(`
... ... @@ -869,16 +817,13 @@
869 817 localStorage.removeItem('chatHistory');
870 818 updateStatus('对话已清空', 'connected');
871 819 sessionId ="";
872   - // 确保输入区域在底部
873 820 ensureInputAtBottom();
874 821 }
875 822 }
876 823  
877   - // 复制消息
878 824 function copyMessage(messageId) {
879 825 const messageContent = $(`#${messageId}`).find('.message-content').text();
880 826 navigator.clipboard.writeText(messageContent).then(() => {
881   - // 显示复制成功的反馈
882 827 const button = $(`#${messageId} .action-btn:first-child`);
883 828 const originalText = button.text();
884 829 button.text('已复制');
... ... @@ -888,29 +833,19 @@
888 833 });
889 834 }
890 835  
891   - // 重新生成消息
892 836 function regenerateMessage(messageId) {
893   - // 找到对应的用户消息
894 837 const messageDiv = $(`#${messageId}`);
895 838 const content = messageDiv.find('.message-content').text();
896   -
897   - // 从历史中移除
898 839 chatHistory = chatHistory.filter(item =>
899 840 item.role !== 'assistant' || item.content !== content
900 841 );
901   -
902   - // 重新发送
903 842 $('#messageInput').val(content);
904 843 sendMessage();
905   -
906   - // 移除旧消息
907 844 messageDiv.remove();
908 845 }
909 846  
910   - // 错误处理
911 847 function handleError(error) {
912 848 hideTypingIndicator();
913   -
914 849 const errorMessage = `
915 850 抱歉,请求出现错误:${error.message}<br><br>
916 851 <strong>可能的原因:</strong><br>
... ... @@ -922,14 +857,11 @@
922 857 2. 检查浏览器控制台查看详细错误<br>
923 858 3. 刷新页面重试
924 859 `;
925   -
926 860 addMessage(errorMessage, 'ai');
927 861 updateStatus('请求失败', 'error');
928   - // 确保输入区域在底部
929 862 ensureInputAtBottom();
930 863 }
931 864  
932   - // 绑定键盘事件
933 865 function bindKeyboardEvents() {
934 866 $('#messageInput').on('keypress', function(e) {
935 867 if (e.which === 13 && !e.shiftKey) {
... ... @@ -939,17 +871,12 @@
939 871 });
940 872  
941 873 $(document).on('keydown', function(e) {
942   - // Ctrl + Enter 发送
943 874 if (e.ctrlKey && e.key === 'Enter') {
944 875 sendMessage();
945 876 }
946   -
947   - // ESC 清空输入框
948 877 if (e.key === 'Escape') {
949 878 $('#messageInput').val('');
950 879 }
951   -
952   - // 上箭头恢复上一条消息
953 880 if (e.key === 'ArrowUp' && $('#messageInput').val() === '') {
954 881 const lastUserMessage = chatHistory
955 882 .filter(item => item.role === 'user')
... ... @@ -962,13 +889,11 @@
962 889 });
963 890 }
964 891  
965   - // 模型选择器变化
966 892 $('#modelSelector').on('change', function() {
967 893 currentModel = $(this).val();
968 894 updateStatus(`切换到${$(this).find('option:selected').text()}模式`, 'connected');
969 895 });
970 896  
971   - // 监听窗口大小变化,重新计算布局
972 897 $(window).on('resize', function() {
973 898 ensureInputAtBottom();
974 899 });
... ...