feat(tts): 集成OpenAI语音合成功能并支持计划单词语音生成

- 新增TTS工具类TTSUtil,实现文本到语音的转换并通过HTTP响应返回音频流
- 在LessonPlanController添加获取计划单词列表及单词语音生成接口
- 前端新增PlanTTS页面,实现计划单词TTS的加载、生成、播放及下载功能
- 路由新增PlanTTS路由,支持访问TTS生成功能页面
- 配置文件application-dev.yml新增OpenAI TTS相关配置
- WordExportUtil生成计划文档时嵌入对应页面二维码图片
- 引入spring-ai-openai相关依赖支持OpenAI模型调用
- 新增单词语音相关请求与响应VO类,方便接口数据传输
- 新增计划单词获取接口plan/word/voice对应前端api
- 新增计划单词语音合成接口plan/word/voice/tts对应前端api
- 添加二维码生成逻辑,用于生成计划文档中的二维码图片链接
- 添加单元测试模版VoiceTest,预留TTS工具类测试接口
This commit is contained in:
lbw
2025-12-29 12:44:16 +08:00
parent 494ab77486
commit 2d76ed507e
16 changed files with 458 additions and 8 deletions

View File

@@ -1,9 +1,10 @@
package com.yinlihupo.enlish.service.controller;
import com.yinlihupo.enlish.service.domain.dataobject.LessonPlansDO;
import com.yinlihupo.enlish.service.model.vo.plan.AddLessonPlanReqVO;
import com.yinlihupo.enlish.service.model.vo.plan.DownLoadLessonPlanReqVO;
import com.yinlihupo.enlish.service.domain.dataobject.VocabularyBankDO;
import com.yinlihupo.enlish.service.model.vo.plan.*;
import com.yinlihupo.enlish.service.service.LessonPlansService;
import com.yinlihupo.enlish.service.utils.TTSUtil;
import com.yinlihupo.enlish.service.utils.WordExportUtil;
import com.yinlihupo.framework.biz.operationlog.aspect.ApiOperationLog;
import com.yinlihupo.framework.common.response.Response;
@@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
@@ -30,6 +32,8 @@ public class LessonPlanController {
@Resource(name = "taskExecutor")
private Executor taskExecutor;
@Resource
private TTSUtil ttsUtil;
@Value("${templates.plan.weekday}")
private String planWeekday;
@@ -60,13 +64,36 @@ public class LessonPlanController {
try {
Map<String, Object> map = JsonUtils.parseMap(lessonPlanById.getContentDetails(), String.class, Object.class);
if (!lessonPlanById.getTitle().contains("复习")) {
WordExportUtil.generateLessonPlanDocx(map, lessonPlanById.getTitle(), response, planWeekday, true);
WordExportUtil.generateLessonPlanDocx(map, lessonPlanById, response, planWeekday, true);
} else {
WordExportUtil.generateLessonPlanDocx(map, lessonPlanById.getTitle(), response, planWeekend, false);
WordExportUtil.generateLessonPlanDocx(map, lessonPlanById, response, planWeekend, false);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@PostMapping("word/voice")
@ApiOperationLog(description = "获取单词")
public Response<FindWordVoiceRspVO> findPlanWordVoice(@RequestBody FindWordVoiceReqVO findWordVoiceReqVO) {
Integer id = findWordVoiceReqVO.getPlanId();
LessonPlansDO lessonPlanById = lessonPlanService.findLessonPlanById(id);
try {
Map<String, Object> map = JsonUtils.parseMap(lessonPlanById.getContentDetails(), String.class, Object.class);
Object syncVocabList = map.get("syncVocabList");
List<VocabularyBankDO> list = JsonUtils.parseList(JsonUtils.toJsonString(syncVocabList), VocabularyBankDO.class);
List<String> words = list.stream().map(VocabularyBankDO::getWord).toList();
return Response.success(FindWordVoiceRspVO.builder().words(words).build());
} catch (Exception e) {
log.error(e.getMessage());
return Response.fail("获取单词失败");
}
}
@PostMapping("word/voice/tts")
public void findPlanWordVoiceTTS(@RequestBody FindWordTTSVoiceReqVO findWordVoiceReqVO, HttpServletResponse response) {
ttsUtil.generateWordVoice(findWordVoiceReqVO.getText(), response);
}
}

View File

@@ -0,0 +1,17 @@
package com.yinlihupo.enlish.service.model.vo.plan;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class FindWordTTSVoiceReqVO {
String text;
String voice;
String format;
}

View File

@@ -0,0 +1,15 @@
package com.yinlihupo.enlish.service.model.vo.plan;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class FindWordVoiceReqVO {
private Integer planId;
}

View File

@@ -0,0 +1,17 @@
package com.yinlihupo.enlish.service.model.vo.plan;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class FindWordVoiceRspVO {
private List<String> words;
}

View File

@@ -0,0 +1,66 @@
package com.yinlihupo.enlish.service.utils;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.openai.OpenAiAudioSpeechModel;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
@Component
@Slf4j
public class TTSUtil {
private final OpenAiAudioSpeechModel speechModel;
public TTSUtil(OpenAiAudioSpeechModel speechModel) {
this.speechModel = speechModel;
}
/**
* 生成语音并将二进制流写入 HTTP 响应
*
* @param text 需要转换的文本
* @param response HTTP 响应对象
*/
public void generateWordVoice(String text, HttpServletResponse response) {
// 1. 参数校验
if (!StringUtils.hasText(text)) {
sendError(response, HttpServletResponse.SC_BAD_REQUEST, "Input text cannot be empty");
return;
}
try {
// 3. 调用 OpenAI 接口获取音频数据
byte[] audioBytes = speechModel.call(text);
// 4. 设置 HTTP 响应头
response.setContentType("audio/mpeg");
response.setContentLength(audioBytes.length);
// 可选:如果不希望浏览器自动播放,而是强制下载,请取消下面这行的注释
// response.setHeader("Content-Disposition", "attachment; filename=\"speech.mp3\"");
// 5. 将音频数据写入响应流
try (ServletOutputStream outputStream = response.getOutputStream()) {
outputStream.write(audioBytes);
outputStream.flush();
}
} catch (Exception e) {
log.error("TTS Generation failed: {}", e.getMessage());
sendError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "TTS Generation failed: " + e.getMessage());
}
}
// 辅助方法:发送错误响应
private void sendError(HttpServletResponse response, int status, String message) {
try {
response.sendError(status, message);
} catch (IOException e) {
// 忽略这里的错误,因为响应可能已经提交
}
}
}

View File

@@ -2,13 +2,25 @@ package com.yinlihupo.enlish.service.utils;
import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.config.Configure;
import com.deepoove.poi.data.PictureType;
import com.deepoove.poi.data.Pictures;
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.MultiFormatWriter;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import com.yinlihupo.enlish.service.domain.dataobject.ExamWordsDO;
import com.yinlihupo.enlish.service.domain.dataobject.LessonPlansDO;
import jakarta.servlet.http.HttpServletResponse;
import java.awt.image.BufferedImage;
import java.io.*;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
@@ -68,8 +80,8 @@ public class WordExportUtil {
}
}
public static void generateLessonPlanDocx(Map<String, Object> map, String fileName, HttpServletResponse response, String templateWordPath, boolean isWeekday) throws IOException {
fileName = URLEncoder.encode(fileName + ".docx", StandardCharsets.UTF_8).replaceAll("\\+", "%20");
public static void generateLessonPlanDocx(Map<String, Object> map, LessonPlansDO lessonPlan, HttpServletResponse response, String templateWordPath, boolean isWeekday) throws IOException {
String fileName = URLEncoder.encode(lessonPlan.getTitle() + ".docx", StandardCharsets.UTF_8).replaceAll("\\+", "%20");
// 3. 设置响应头
response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
@@ -82,6 +94,8 @@ public class WordExportUtil {
} else {
template = XWPFTemplate.compile(inputStream, configLessonPlanWeekend);
}
String url = "http://localhost:5173/#/plan/tts?planId=" + lessonPlan.getId();
map.put("img", Pictures.ofBytes(generateQR(url), PictureType.PNG).create());
OutputStream out = response.getOutputStream();
template.render(map);
template.write(out);
@@ -161,4 +175,38 @@ public class WordExportUtil {
out.flush();
}
}
private static byte[] generateQR(String content) {
int width = 300;
int height = 300;
String format = "png";
Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
hints.put(EncodeHintType.MARGIN, 1);
try {
// 1. 生成比特矩阵
BitMatrix bitMatrix = new MultiFormatWriter().encode(
content,
BarcodeFormat.QR_CODE,
width,
height,
hints
);
// 2. 将 BitMatrix 转为字节数组
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
// MatrixToImageWriter 是 Zxing 提供的工具类
MatrixToImageWriter.writeToStream(bitMatrix, format, outputStream);
return outputStream.toByteArray();
} catch (WriterException | IOException e) {
// 建议增加对 IOException 的捕获,因为涉及流操作
throw new RuntimeException("生成二维码失败", e);
}
}
}

View File

@@ -19,6 +19,15 @@ spring:
max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制)
min-idle: 0 # 连接池中的最小空闲连接
max-idle: 10 # 连接池中的最大空闲连接
ai:
openai:
api-key: your_api_key_here
base-url: http://124.220.58.5:2233
audio:
speech:
options:
model: tts-1
voice: alloy
templates:
@@ -26,7 +35,7 @@ templates:
count: 100
data: C:\project\tess
plan:
weekday: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\tem_study_plan_v2.docx
weekday: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\tem_study_plan_v3.docx
weekend: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\study_plan_review_v1.docx
plan_day: 7
tmp:
@@ -38,4 +47,4 @@ ai:
aliyun:
accessKeyId:
accessKeySecret:
accessKeySecret:

View File

@@ -0,0 +1,18 @@
package com.yinlihupo.enlish.service.voice;
import com.yinlihupo.enlish.service.utils.TTSUtil;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class VoiceTest {
@Resource
private TTSUtil ttsUtil;
@Test
public void test() {
}
}