03-技术讲解
# TTS性能优化技术讲解
# 什么问题
TTS,英语全称 Text-to-Speech,中文就是“文字转语音”。
- 阅读器应用
- 导航系统
- 在线教育平台
- ....
TTS的实现直接使用 WebAPI 就能够实现。
- 不同的操作系统转换出来的音效不同
- 无法定制化语音效果
- 有一定的兼容性问题
因为上面罗列出来的种种原因,这里不考虑 WebAPI.
这里考虑使用第三方平台,整体架构如下:
这种架构下,仍然存在一些待解决的问题:
- 大文本的转换非常耗时
- 大文本转换出来的音频也很大,也存在传输耗时的问题
- 如何给文本添加情绪
# 解决思路
- 将大文本进行分割,拆解成小文本。
- 添加情绪需要用到一些自然语言处理库
- 对转换过了的文本进行缓存
# 解决细节
1. 文本断句
文本断句的时候需要考虑到文本切割的一个粒度。
以标点符号来断句(推荐使用)
function splitTextByPunctuation(text) { // 使用正则表达式匹配句子结束的标点符号 return text.match(/[^。!?]*[。!?]/g) || []; } // 示例文本 const longText = "这是第一句话。这里是第二句话,包含更多的内容。还有一些问句吗?当然有!这是最后一句。"; // 使用标点符号进行断句 const sentences = splitTextByPunctuation(longText); console.log(sentences);
[ "这是第一句话。", "这里是第二句话,包含更多的内容。", "还有一些问句吗?", "当然有!", "这是最后一句。" ]
固定长度来切割
2. 情绪标记
张三:“你在干嘛呀?我真的要被你气爆炸了”
const dialogues = [
{ speaker: '张三', text: '你在干嘛呀?我真的要被你气爆炸了', emotion: 'angry' },
// 更多对话...
];
这里需要借助一些自然语言处理库:
- NLTK (Natural Language Toolkit):这是一个强大的 Python 库,用于各种自然语言处理任务。NLTK有许多预训练的情绪分析模型,可以用于情绪分类。
- TextBlob:这是一个简单易用的 Python 库,基于 NLTK 和 Pattern。它提供了情绪分析功能,可以快速对文本进行情绪分类。
- Google Cloud Natural Language API:Google 的自然语言处理 API 提供了强大的情绪分析功能,可以处理大规模文本并返回情绪分类结果。
- ....
这里我们可以在后端自己启用一个 python 服务,架构如下:
整体流程如下:
客户端发送请求:客户端将文本数据发送到前端服务器。
前端服务器处理请求
接收客户端请求,将文本数据通过 WebSocket 转发给后端服务器。
前端服务器无需关心后端的具体处理过程,只需等待返回结果。
后端服务器进行情绪分析
使用自然语言处理库(如NLTK、TextBlob)对文本进行情绪分析和标记。
标记后的文本包含情绪信息,可以用于不同的语音合成。
调用第三方 TTS 平台
后端服务器通过 WebSocket 与第三方 TTS 平台建立连接。
将标记后的文本发送到第三方平台,进行语音合成。
接收第三方平台返回的音频字节数据。
返回处理结果
将音频字节数据通过 WebSocket 传回前端服务器。
前端服务器将音频字节数据转换为 base64 格式,返回给客户端。
3. 并发的控制
为什么需要并发?因为大文本被拆成了小文本,文本内容减少了,但是文本的量增多了。
但是并发也需要对一次性并发的量进行控制,防止服务器过载。
限制并发的数量
const maxConcurrency = 5; // 最大并发请求数 async function fetchTTS(segment) { // 模拟一个TTS请求 return new Promise(resolve => { setTimeout(() => { resolve(`TTS result for: ${segment}`); }, Math.random() * 2000); // 随机延迟模拟请求时间 }); } async function processSegments(textSegments) { const results = []; for (let i = 0; i < textSegments.length; i += maxConcurrency) { const segmentBatch = textSegments.slice(i, i + maxConcurrency); // 分批处理 const segmentResults = await Promise.all(segmentBatch.map(segment => fetchTTS(segment))); results.push(...segmentResults); // 合并结果 } return results; } // 示例文本分段 const textSegments = ["Segment 1", "Segment 2", "Segment 3", "Segment 4", "Segment 5", "Segment 6", "Segment 7"]; // 处理文本分段 processSegments(textSegments).then(results => { console.log(results); // 输出处理结果 });
使用队列来管理请求
const maxConcurrency = 5; // 最大并发请求数 let activeRequests = 0; // 当前活动请求数 const queue = []; // 请求队列 function enqueueRequest(request) { return new Promise((resolve, reject) => { queue.push({ request, resolve, reject }); // 将请求加入队列 processQueue(); // 处理队列中的请求 }); } function processQueue() { if (activeRequests >= maxConcurrency || queue.length === 0) { return; // 如果达到最大并发请求数或队列为空,则返回 } const { request, resolve, reject } = queue.shift(); // 从队列中取出一个请求 activeRequests++; // 增加活动请求数 request() .then(resolve) .catch(reject) .finally(() => { activeRequests--; // 请求完成后减少活动请求数 processQueue(); // 继续处理队列中的下一个请求 }); } async function fetchTTS(segment) { // 模拟一个TTS请求 return new Promise(resolve => { setTimeout(() => { resolve(`TTS result for: ${segment}`); }, Math.random() * 2000); // 随机延迟模拟请求时间 }); } async function processSegments(textSegments) { const results = []; for (let i = 0; i < textSegments.length; i += maxConcurrency) { const segmentBatch = textSegments.slice(i, i + maxConcurrency); // 分批处理 const segmentResults = await Promise.all( segmentBatch.map(segment => enqueueRequest(() => fetchTTS(segment))) ); } results.push(...segmentResults); // 合并结果 return results; } // 示例文本分段 const textSegments = ["Segment 1", "Segment 2", "Segment 3", "Segment 4", "Segment 5", "Segment 6", "Segment 7"]; // 处理文本分段 processSegments(textSegments).then(results => { console.log(results); // 输出处理结果 });
动态的去调整并发的数量
let maxConcurrency = 5; // 初始最大并发请求数 async function adjustConcurrencyBasedOnLoad() { const serverLoad = await getServerLoad(); // 获取服务器负载 if (serverLoad > 80) { maxConcurrency = Math.max(1, maxConcurrency - 1); // 如果负载超过80%,减少并发请求数 } else if (serverLoad < 50) { maxConcurrency++; // 如果负载低于50%,增加并发请求数 } setTimeout(adjustConcurrencyBasedOnLoad, 10000); // 每10秒调整一次并发请求数 } function getServerLoad() { // 模拟获取服务器负载 return new Promise(resolve => { setTimeout(() => { resolve(Math.random() * 100); // 返回0到100之间的随机负载 }, 1000); }); } adjustConcurrencyBasedOnLoad(); // 启动并发调整 // 继续使用上面的队列管理示例代码
4. 请求的取消
用户快速hover了第一段和第二段,那么第一段的响应变得毫无用处,因此这里涉及到一个请求取消的操作。
AbortController,这是浏览器提供的一个 API.
结合之前的并发控制,示例代码如下:
const maxConcurrency = 5; // 最大并发请求数
let activeRequests = 0; // 当前活动请求数
const queue = []; // 请求队列
let currentController = null; // 当前的 AbortController
function enqueueRequest(request, controller) {
return new Promise((resolve, reject) => {
queue.push({ request, resolve, reject, controller }); // 将请求加入队列
processQueue(); // 处理队列中的请求
});
}
function processQueue() {
if (activeRequests >= maxConcurrency || queue.length === 0) {
return; // 如果达到最大并发请求数或队列为空,则返回
}
const { request, resolve, reject, controller } = queue.shift(); // 从队列中取出一个请求
activeRequests++; // 增加活动请求数
request(controller)
.then(resolve)
.catch(reject)
.finally(() => {
activeRequests--; // 请求完成后减少活动请求数
processQueue(); // 继续处理队列中的下一个请求
});
}
async function fetchTTS(segment, controller) {
// 模拟一个TTS请求
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
resolve(`TTS result for: ${segment}`);
}, Math.random() * 2000); // 随机延迟模拟请求时间
// 监听取消信号
controller.signal.addEventListener('abort', () => {
clearTimeout(timeout);
reject(new Error('Request was aborted'));
});
});
}
async function processSegments(textSegments) {
const results = await Promise.all(
textSegments.map(segment => {
const controller = new AbortController();
return enqueueRequest(() => fetchTTS(segment, controller), controller);
})
);
return results;
}
// 示例文本分段
const textSegments = [
"Segment 1",
"Segment 2",
"Segment 3",
"Segment 4",
"Segment 5",
"Segment 6",
"Segment 7",
];
// 处理文本分段
processSegments(textSegments).then(results => {
console.log(results); // 输出处理结果
});
5. 转换base64
前端服务器拿到 bytes 数据后,可以进行一层转换,将其转换为 base64 格式的数据,这样客户端的页面上使用的时候就会更加方便,可以给生成的 audio 元素的 src 属性直接赋值这个 base64
示例代码如下:
// 前端服务器
const express = require('express');
const axios = require('axios');
const app = express();
const port = 3000;
// 示例后端服务器URL
const backendUrl = 'http://backend-server-url/get-tts-audio';
// 获取后端音频数据并转换为Base64
app.get('/get-audio', async (req, res) => {
try {
// 从后端服务器获取音频字节数据
const response = await axios.get(backendUrl, { responseType: 'arraybuffer' });
const audioBytes = response.data;
// 将音频字节数据转换为Base64
const base64Audio = Buffer.from(audioBytes).toString('base64');
// 将Base64音频数据发送给客户端
res.send(base64Audio);
} catch (error) {
console.error('Error fetching audio from backend:', error);
res.status(500).send('Error fetching audio from backend');
}
});
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audio Example</title>
</head>
<body>
<audio id="audioPlayer" controls></audio>
<script>
// 获取 base64 音频数据
fetch('http://localhost:3000/get-audio')
.then(response => response.text())
.then(base64Audio => {
const audioPlayer = document.getElementById('audioPlayer');
// 将 base64 数据设置为 audio 元素的 src 属性
audioPlayer.src = `data:audio/wav;base64,${base64Audio}`;
audioPlayer.play();
})
.catch(error => console.error('Error fetching audio:', error));
</script>
</body>
</html>
6. 进行缓存
- 缓存在客户端
- 之前你转换过的文本能够再次复用
- 速度相比缓存在服务器会更快一些
- 缓存在服务器端
- 别人转换过的音频也能得到复用
如果要缓存在服务器端,那么就需要加一个数据库
整体的流程:
- 如果缓存中存在该文本的转换结果,前端服务器直接返回 Base64 格式的音频数据给客户端。
- 如果缓存中不存在该文本的转换结果,前端服务器将请求转发给后端服务器,之后前端服务器将转换结果存入缓存数据库中,以便下次请求时可以直接使用。
-EOF-