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:
BIN
debug_roi.jpg
BIN
debug_roi.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
@@ -153,6 +153,25 @@
|
|||||||
<artifactId>java-jwt</artifactId>
|
<artifactId>java-jwt</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-openai</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.zxing</groupId>
|
||||||
|
<artifactId>core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.zxing</groupId>
|
||||||
|
<artifactId>javase</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.yinlihupo.enlish.service.controller;
|
package com.yinlihupo.enlish.service.controller;
|
||||||
|
|
||||||
import com.yinlihupo.enlish.service.domain.dataobject.LessonPlansDO;
|
import com.yinlihupo.enlish.service.domain.dataobject.LessonPlansDO;
|
||||||
import com.yinlihupo.enlish.service.model.vo.plan.AddLessonPlanReqVO;
|
import com.yinlihupo.enlish.service.domain.dataobject.VocabularyBankDO;
|
||||||
import com.yinlihupo.enlish.service.model.vo.plan.DownLoadLessonPlanReqVO;
|
import com.yinlihupo.enlish.service.model.vo.plan.*;
|
||||||
import com.yinlihupo.enlish.service.service.LessonPlansService;
|
import com.yinlihupo.enlish.service.service.LessonPlansService;
|
||||||
|
import com.yinlihupo.enlish.service.utils.TTSUtil;
|
||||||
import com.yinlihupo.enlish.service.utils.WordExportUtil;
|
import com.yinlihupo.enlish.service.utils.WordExportUtil;
|
||||||
import com.yinlihupo.framework.biz.operationlog.aspect.ApiOperationLog;
|
import com.yinlihupo.framework.biz.operationlog.aspect.ApiOperationLog;
|
||||||
import com.yinlihupo.framework.common.response.Response;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
@@ -30,6 +32,8 @@ public class LessonPlanController {
|
|||||||
|
|
||||||
@Resource(name = "taskExecutor")
|
@Resource(name = "taskExecutor")
|
||||||
private Executor taskExecutor;
|
private Executor taskExecutor;
|
||||||
|
@Resource
|
||||||
|
private TTSUtil ttsUtil;
|
||||||
|
|
||||||
@Value("${templates.plan.weekday}")
|
@Value("${templates.plan.weekday}")
|
||||||
private String planWeekday;
|
private String planWeekday;
|
||||||
@@ -60,13 +64,36 @@ public class LessonPlanController {
|
|||||||
try {
|
try {
|
||||||
Map<String, Object> map = JsonUtils.parseMap(lessonPlanById.getContentDetails(), String.class, Object.class);
|
Map<String, Object> map = JsonUtils.parseMap(lessonPlanById.getContentDetails(), String.class, Object.class);
|
||||||
if (!lessonPlanById.getTitle().contains("复习")) {
|
if (!lessonPlanById.getTitle().contains("复习")) {
|
||||||
WordExportUtil.generateLessonPlanDocx(map, lessonPlanById.getTitle(), response, planWeekday, true);
|
WordExportUtil.generateLessonPlanDocx(map, lessonPlanById, response, planWeekday, true);
|
||||||
} else {
|
} else {
|
||||||
WordExportUtil.generateLessonPlanDocx(map, lessonPlanById.getTitle(), response, planWeekend, false);
|
WordExportUtil.generateLessonPlanDocx(map, lessonPlanById, response, planWeekend, false);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
// 忽略这里的错误,因为响应可能已经提交
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,25 @@ package com.yinlihupo.enlish.service.utils;
|
|||||||
|
|
||||||
import com.deepoove.poi.XWPFTemplate;
|
import com.deepoove.poi.XWPFTemplate;
|
||||||
import com.deepoove.poi.config.Configure;
|
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.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.ExamWordsDO;
|
||||||
|
import com.yinlihupo.enlish.service.domain.dataobject.LessonPlansDO;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.zip.ZipEntry;
|
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 {
|
public static void generateLessonPlanDocx(Map<String, Object> map, LessonPlansDO lessonPlan, HttpServletResponse response, String templateWordPath, boolean isWeekday) throws IOException {
|
||||||
fileName = URLEncoder.encode(fileName + ".docx", StandardCharsets.UTF_8).replaceAll("\\+", "%20");
|
String fileName = URLEncoder.encode(lessonPlan.getTitle() + ".docx", StandardCharsets.UTF_8).replaceAll("\\+", "%20");
|
||||||
|
|
||||||
// 3. 设置响应头
|
// 3. 设置响应头
|
||||||
response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||||
@@ -82,6 +94,8 @@ public class WordExportUtil {
|
|||||||
} else {
|
} else {
|
||||||
template = XWPFTemplate.compile(inputStream, configLessonPlanWeekend);
|
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();
|
OutputStream out = response.getOutputStream();
|
||||||
template.render(map);
|
template.render(map);
|
||||||
template.write(out);
|
template.write(out);
|
||||||
@@ -161,4 +175,38 @@ public class WordExportUtil {
|
|||||||
out.flush();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ spring:
|
|||||||
max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||||
min-idle: 0 # 连接池中的最小空闲连接
|
min-idle: 0 # 连接池中的最小空闲连接
|
||||||
max-idle: 10 # 连接池中的最大空闲连接
|
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:
|
templates:
|
||||||
@@ -26,7 +35,7 @@ templates:
|
|||||||
count: 100
|
count: 100
|
||||||
data: C:\project\tess
|
data: C:\project\tess
|
||||||
plan:
|
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
|
weekend: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\study_plan_review_v1.docx
|
||||||
plan_day: 7
|
plan_day: 7
|
||||||
tmp:
|
tmp:
|
||||||
@@ -38,4 +47,4 @@ ai:
|
|||||||
|
|
||||||
aliyun:
|
aliyun:
|
||||||
accessKeyId:
|
accessKeyId:
|
||||||
accessKeySecret:
|
accessKeySecret:
|
||||||
|
|||||||
Binary file not shown.
@@ -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() {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,12 @@ export function downloadLessonPlan(data) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLessonPlanWords(planId) {
|
||||||
|
return axios.post('plan/word/voice', {
|
||||||
|
planId: planId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const resolveBlob = (res, fileName) => {
|
const resolveBlob = (res, fileName) => {
|
||||||
// 创建 Blob 对象,可以指定 type,也可以让浏览器自动推断
|
// 创建 Blob 对象,可以指定 type,也可以让浏览器自动推断
|
||||||
const blob = new Blob([res], { type: 'application/octet-stream' });
|
const blob = new Blob([res], { type: 'application/octet-stream' });
|
||||||
|
|||||||
12
enlish-vue/src/api/tts.js
Normal file
12
enlish-vue/src/api/tts.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import axios from "@/axios";
|
||||||
|
|
||||||
|
export function synthesizeOpenAITTS(text, voice = 'alloy', format = 'mp3') {
|
||||||
|
return axios.post('/plan/word/voice/tts', {
|
||||||
|
text,
|
||||||
|
voice,
|
||||||
|
format
|
||||||
|
}, {
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
167
enlish-vue/src/pages/PlanTTS.vue
Normal file
167
enlish-vue/src/pages/PlanTTS.vue
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<template>
|
||||||
|
<div class="common-layout">
|
||||||
|
<el-container>
|
||||||
|
<el-header>
|
||||||
|
<Header></Header>
|
||||||
|
</el-header>
|
||||||
|
<el-main class="p-4">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<div class="text-lg font-semibold mb-4">TTS</div>
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<el-input v-model="planIdInput" placeholder="planId" style="max-width: 220px" />
|
||||||
|
<el-button type="primary" :loading="loadingWords" @click="onLoadWords">加载词汇</el-button>
|
||||||
|
<el-select v-model="voice" placeholder="选择声线" style="max-width: 160px">
|
||||||
|
<el-option label="alloy" value="alloy" />
|
||||||
|
<el-option label="verse" value="verse" />
|
||||||
|
<el-option label="nova" value="nova" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="format" placeholder="格式" style="max-width: 120px">
|
||||||
|
<el-option label="mp3" value="mp3" />
|
||||||
|
<el-option label="wav" value="wav" />
|
||||||
|
<el-option label="ogg" value="ogg" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="success" :disabled="words.length === 0" :loading="generatingAll"
|
||||||
|
@click="onGenerateAll">生成全部音频</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="tableData" border class="w-full" v-loading="loadingWords">
|
||||||
|
<el-table-column prop="word" label="词汇/短语" min-width="260" />
|
||||||
|
<el-table-column label="状态" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.audioUrl ? 'success' : 'info'" effect="plain">
|
||||||
|
{{ row.audioUrl ? '已生成' : '未生成' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="360" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="primary" :loading="row.loading"
|
||||||
|
@click="onGenerateOne(row)">生成音频</el-button>
|
||||||
|
<el-button size="small" class="ml-2" :disabled="!row.audioUrl"
|
||||||
|
@click="onPlay(row)">播放</el-button>
|
||||||
|
<el-button size="small" class="ml-2" :disabled="!row.audioUrl"
|
||||||
|
@click="onDownload(row)">下载</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="mt-3 text-sm text-gray-500">
|
||||||
|
共 {{ words.length }} 条
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Header from '@/layouts/components/Header.vue'
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { getLessonPlanWords } from '@/api/plan'
|
||||||
|
import { synthesizeOpenAITTS } from '@/api/tts'
|
||||||
|
import { showMessage } from '@/composables/util'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const planIdInput = ref(route.query.planId ? String(route.query.planId) : '')
|
||||||
|
const words = ref([])
|
||||||
|
const loadingWords = ref(false)
|
||||||
|
const generatingAll = ref(false)
|
||||||
|
const voice = ref('alloy')
|
||||||
|
const format = ref('mp3')
|
||||||
|
|
||||||
|
const tableData = computed(() => {
|
||||||
|
return words.value.map(w => ({
|
||||||
|
word: w,
|
||||||
|
audioUrl: audioMap.value.get(w) || '',
|
||||||
|
loading: loadingSet.value.has(w)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const audioMap = ref(new Map())
|
||||||
|
const loadingSet = ref(new Set())
|
||||||
|
|
||||||
|
async function onLoadWords() {
|
||||||
|
if (!planIdInput.value) {
|
||||||
|
showMessage('请输入 planId', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadingWords.value = true
|
||||||
|
try {
|
||||||
|
const res = await getLessonPlanWords(Number(planIdInput.value))
|
||||||
|
const d = res.data
|
||||||
|
const arr = d?.data?.words
|
||||||
|
words.value = Array.isArray(arr) ? arr : []
|
||||||
|
if (words.value.length === 0) {
|
||||||
|
showMessage('未获取到词汇', 'warning')
|
||||||
|
} else {
|
||||||
|
showMessage('已加载词汇', 'success')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingWords.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onGenerateOne(row) {
|
||||||
|
const text = row?.word
|
||||||
|
if (!text) return
|
||||||
|
loadingSet.value.add(text)
|
||||||
|
try {
|
||||||
|
const res = await synthesizeOpenAITTS(text, voice.value, format.value)
|
||||||
|
const blob = res.data
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
audioMap.value.set(text, url)
|
||||||
|
showMessage('生成成功', 'success')
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('生成失败', 'error')
|
||||||
|
} finally {
|
||||||
|
loadingSet.value.delete(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onGenerateAll() {
|
||||||
|
if (words.value.length === 0) return
|
||||||
|
generatingAll.value = true
|
||||||
|
try {
|
||||||
|
for (const w of words.value) {
|
||||||
|
loadingSet.value.add(w)
|
||||||
|
try {
|
||||||
|
const res = await synthesizeOpenAITTS(w, voice.value, format.value)
|
||||||
|
const url = URL.createObjectURL(res.data)
|
||||||
|
audioMap.value.set(w, url)
|
||||||
|
} catch (e) {
|
||||||
|
} finally {
|
||||||
|
loadingSet.value.delete(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showMessage('全部生成完成', 'success')
|
||||||
|
} finally {
|
||||||
|
generatingAll.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPlay(row) {
|
||||||
|
const url = audioMap.value.get(row.word)
|
||||||
|
if (!url) return
|
||||||
|
const audio = new Audio(url)
|
||||||
|
audio.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDownload(row) {
|
||||||
|
const url = audioMap.value.get(row.word)
|
||||||
|
if (!url) return
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
const ext = format.value || 'mp3'
|
||||||
|
a.download = `${row.word.replace(/\s+/g, '_')}.${ext}`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (planIdInput.value) {
|
||||||
|
onLoadWords()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -4,6 +4,7 @@ import Class from '@/pages/class.vue'
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import Admid from '@/pages/admid/admid.vue'
|
import Admid from '@/pages/admid/admid.vue'
|
||||||
import Student from '@/pages/student.vue'
|
import Student from '@/pages/student.vue'
|
||||||
|
import PlanTTS from '@/pages/PlanTTS.vue'
|
||||||
|
|
||||||
// 统一在这里声明所有路由
|
// 统一在这里声明所有路由
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -41,6 +42,13 @@ const routes = [
|
|||||||
meta: { // meta 信息
|
meta: { // meta 信息
|
||||||
title: '管理员页面' // 页面标题
|
title: '管理员页面' // 页面标题
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/plan/tts',
|
||||||
|
component: PlanTTS,
|
||||||
|
meta: {
|
||||||
|
title: 'TTS生成'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
21
pom.xml
21
pom.xml
@@ -281,6 +281,27 @@
|
|||||||
<version>${jaxb-runtime.version}</version>
|
<version>${jaxb-runtime.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-bom</artifactId>
|
||||||
|
<version>1.1.0</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.zxing</groupId>
|
||||||
|
<artifactId>core</artifactId>
|
||||||
|
<version>3.5.2</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.zxing</groupId>
|
||||||
|
<artifactId>javase</artifactId>
|
||||||
|
<version>3.5.2</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user