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:
@@ -45,6 +45,12 @@ export function downloadLessonPlan(data) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getLessonPlanWords(planId) {
|
||||
return axios.post('plan/word/voice', {
|
||||
planId: planId
|
||||
})
|
||||
}
|
||||
|
||||
const resolveBlob = (res, fileName) => {
|
||||
// 创建 Blob 对象,可以指定 type,也可以让浏览器自动推断
|
||||
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 Admid from '@/pages/admid/admid.vue'
|
||||
import Student from '@/pages/student.vue'
|
||||
import PlanTTS from '@/pages/PlanTTS.vue'
|
||||
|
||||
// 统一在这里声明所有路由
|
||||
const routes = [
|
||||
@@ -41,6 +42,13 @@ const routes = [
|
||||
meta: { // meta 信息
|
||||
title: '管理员页面' // 页面标题
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/plan/tts',
|
||||
component: PlanTTS,
|
||||
meta: {
|
||||
title: 'TTS生成'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user