feat: 重构项目以符合 Go 规范,添加 OpenAI 接口适配,优化长文本朗读功能(切割后合并)

This commit is contained in:
王锦强
2025-03-09 13:02:28 +08:00
parent 539f6d9ef5
commit 8f2fd68ebe
31 changed files with 2487 additions and 647 deletions

271
web/static/css/style.css Normal file
View File

@@ -0,0 +1,271 @@
/* 基本样式重置 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f7fa;
padding: 20px;
}
/* 容器 */
.container {
max-width: 1000px;
margin: 0 auto;
}
/* 页眉 */
header {
text-align: center;
margin-bottom: 30px;
padding: 20px;
}
header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
color: #2c3e50;
}
header p {
font-size: 1.2rem;
color: #7f8c8d;
margin-bottom: 20px;
}
/* 导航 */
nav {
display: flex;
justify-content: center;
margin-top: 20px;
}
nav a {
text-decoration: none;
color: #3498db;
margin: 0 15px;
padding: 5px 10px;
border-radius: 5px;
transition: all 0.3s ease;
}
nav a:hover {
background-color: #3498db;
color: #fff;
}
nav a.active {
background-color: #3498db;
color: #fff;
}
/* 卡片 */
.card {
background-color: #fff;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 25px;
margin-bottom: 25px;
}
/* 标题 */
h2 {
color: #2c3e50;
margin-bottom: 20px;
border-bottom: 1px solid #ecf0f1;
padding-bottom: 10px;
}
h3 {
color: #3498db;
margin: 20px 0 10px;
}
/* 输入区域 */
.input-group {
position: relative;
margin-bottom: 20px;
}
textarea {
width: 100%;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
resize: none;
font-size: 1rem;
font-family: inherit;
}
textarea:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
.char-counter {
position: absolute;
bottom: 10px;
right: 10px;
font-size: 0.8rem;
color: #7f8c8d;
}
/* 设置区域 */
.settings {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.setting-group {
display: flex;
flex-direction: column;
}
label {
margin-bottom: 5px;
font-weight: bold;
color: #2c3e50;
}
select, input[type="range"] {
padding: 8px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #fff;
}
select:focus {
outline: none;
border-color: #3498db;
}
/* 按钮 */
.actions {
display: flex;
justify-content: center;
margin-top: 20px;
}
button {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s ease;
}
.primary-button {
background-color: #3498db;
color: #fff;
}
.primary-button:hover {
background-color: #2980b9;
}
.secondary-button {
background-color: #ecf0f1;
color: #2c3e50;
margin: 0 5px;
}
.secondary-button:hover {
background-color: #bdc3c7;
}
/* 音频播放器 */
.audio-player {
display: flex;
flex-direction: column;
align-items: center;
}
audio {
width: 100%;
margin-bottom: 15px;
}
.audio-controls {
display: flex;
justify-content: center;
}
/* 表格 */
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f8f9fa;
font-weight: bold;
}
/* 代码 */
code, pre {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background-color: #f8f9fa;
border-radius: 3px;
padding: 2px 5px;
font-size: 0.9rem;
}
pre {
padding: 15px;
overflow-x: auto;
margin: 15px 0;
}
pre code {
padding: 0;
background-color: transparent;
}
/* 页脚 */
footer {
text-align: center;
margin-top: 40px;
padding: 20px;
color: #7f8c8d;
font-size: 0.9rem;
}
footer a {
color: #3498db;
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
/* 响应式调整 */
@media (max-width: 768px) {
.settings {
grid-template-columns: 1fr;
}
header h1 {
font-size: 2rem;
}
.card {
padding: 15px;
}
}

176
web/static/js/app.js Normal file
View File

@@ -0,0 +1,176 @@
document.addEventListener('DOMContentLoaded', function() {
// 获取DOM元素
const textInput = document.getElementById('text');
const voiceSelect = document.getElementById('voice');
const rateInput = document.getElementById('rate');
const rateValue = document.getElementById('rateValue');
const pitchInput = document.getElementById('pitch');
const pitchValue = document.getElementById('pitchValue');
const speakButton = document.getElementById('speak');
const downloadButton = document.getElementById('download');
const copyLinkButton = document.getElementById('copyLink');
const audioPlayer = document.getElementById('audioPlayer');
const resultSection = document.getElementById('resultSection');
const charCount = document.getElementById('charCount');
// 保存最后一个音频URL
let lastAudioUrl = '';
// 初始化
initVoicesList();
initEventListeners();
// 更新字符计数
textInput.addEventListener('input', function() {
charCount.textContent = this.value.length;
});
// 更新语速值显示
rateInput.addEventListener('input', function() {
const value = this.value;
rateValue.textContent = value + '%';
});
// 更新语调值显示
pitchInput.addEventListener('input', function() {
const value = this.value;
pitchValue.textContent = value + '%';
});
// 获取可用语音列表
async function initVoicesList() {
try {
const response = await fetch(`${config.basePath}/voices`);
if (!response.ok) throw new Error('获取语音列表失败');
const voices = await response.json();
// 清空并重建选项
voiceSelect.innerHTML = '';
// 按语言和名称分组
const voicesByLocale = {};
voices.forEach(voice => {
if (!voicesByLocale[voice.locale]) {
voicesByLocale[voice.locale] = [];
}
voicesByLocale[voice.locale].push(voice);
});
// 创建选项组
for (const locale in voicesByLocale) {
const optgroup = document.createElement('optgroup');
optgroup.label = voicesByLocale[locale][0].locale_name;
voicesByLocale[locale].forEach(voice => {
const option = document.createElement('option');
option.value = voice.short_name;
option.textContent = `${voice.local_name || voice.display_name} (${voice.gender})`;
// 如果是默认语音则选中
if (voice.short_name === config.defaultVoice) {
option.selected = true;
}
optgroup.appendChild(option);
});
voiceSelect.appendChild(optgroup);
}
} catch (error) {
console.error('获取语音列表失败:', error);
voiceSelect.innerHTML = '<option value="">无法加载语音列表</option>';
}
}
// 初始化事件监听器
function initEventListeners() {
// 转换按钮点击事件
speakButton.addEventListener('click', generateSpeech);
// 下载按钮点击事件
downloadButton.addEventListener('click', function() {
if (lastAudioUrl) {
const a = document.createElement('a');
a.href = lastAudioUrl;
a.download = 'speech.mp3';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
});
// 复制链接按钮点击事件
copyLinkButton.addEventListener('click', function() {
if (lastAudioUrl) {
navigator.clipboard.writeText(lastAudioUrl).then(() => {
alert('链接已复制到剪贴板');
}).catch(err => {
console.error('复制失败:', err);
// 兼容处理
const textArea = document.createElement('textarea');
textArea.value = lastAudioUrl;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
alert('链接已复制到剪贴板');
} catch (err) {
console.error('复制失败:', err);
}
document.body.removeChild(textArea);
});
}
});
}
// 生成语音
async function generateSpeech() {
const text = textInput.value.trim();
if (!text) {
alert('请输入要转换的文本');
return;
}
const voice = voiceSelect.value;
const rate = rateInput.value;
const pitch = pitchInput.value;
// 禁用按钮,显示加载状态
speakButton.disabled = true;
speakButton.textContent = '生成中...';
try {
// 构建URL参数
const params = new URLSearchParams({
t: text,
v: voice,
r: rate,
p: pitch
});
const url = `${config.basePath}/tts?${params.toString()}`;
// 更新音频播放器
audioPlayer.src = url;
lastAudioUrl = url;
// 显示结果区域
resultSection.style.display = 'block';
// 播放音频
audioPlayer.play();
} catch (error) {
console.error('生成语音失败:', error);
alert('生成语音失败,请重试');
} finally {
// 恢复按钮状态
speakButton.disabled = false;
speakButton.textContent = '转换为语音';
}
}
});

310
web/templates/api-doc.html Normal file
View File

@@ -0,0 +1,310 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API文档 - TTS服务</title>
<link rel="stylesheet" href="{{.BasePath}}/static/css/style.css">
<meta name="description" content="TTS服务API文档">
</head>
<body>
<div class="container">
<header>
<h1>TTS服务 API文档</h1>
<p>快速、高质量的文本转语音API服务</p>
<nav>
<a href="{{.BasePath}}/">主页</a>
<a href="{{.BasePath}}/api-doc" class="active">API文档</a>
</nav>
</header>
<main>
<section class="card">
<h2>API概述</h2>
<p>TTS服务API提供了简单而强大的方式将文本转换为自然语音。我们支持多种语言和声音并允许您调节语速、语调以适应不同场景需求。</p>
<p>基础URL: <code>{{.BasePath}}</code></p>
<p>所有API请求均使用HTTP协议返回标准HTTP状态码表示请求结果。</p>
</section>
<section class="card">
<h2>文本转语音 API</h2>
<h3>端点</h3>
<code>GET {{.BasePath}}/tts</code>
<h3>参数</h3>
<table>
<thead>
<tr>
<th>参数</th>
<th>类型</th>
<th>必选</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>t</code></td>
<td>string</td>
<td></td>
<td>要转换的文本需要进行URL编码</td>
</tr>
<tr>
<td><code>v</code></td>
<td>string</td>
<td></td>
<td>语音名称使用short_name格式默认: {{.DefaultVoice}}。可通过/voices接口获取所有可用语音</td>
</tr>
<tr>
<td><code>r</code></td>
<td>string</td>
<td></td>
<td>语速调整,范围: -100%到100%,默认: {{.DefaultRate}}。正值加快语速,负值减慢语速</td>
</tr>
<tr>
<td><code>p</code></td>
<td>string</td>
<td></td>
<td>语调调整,范围: -100%到100%,默认: {{.DefaultPitch}}。正值提高语调,负值降低语调</td>
</tr>
<tr>
<td><code>o</code></td>
<td>string</td>
<td></td>
<td>输出音频格式,默认: {{.DefaultFormat}}。详见下方支持的格式列表</td>
</tr>
<tr>
<td><code>s</code></td>
<td>string</td>
<td></td>
<td>情感风格可用值取决于所选语音的style_list属性。例如"cheerful"、"sad"等</td>
</tr>
</tbody>
</table>
<h3>示例请求</h3>
<pre><code>curl "{{.BasePath}}/tts?t=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E4%B8%96%E7%95%8C&v=zh-CN-XiaoxiaoNeural&r=0%25&p=0%25"</code></pre>
<h3>另一个示例(带情感风格)</h3>
<pre><code>curl "{{.BasePath}}/tts?t=%E4%BB%8A%E5%A4%A9%E5%A4%A9%E6%B0%94%E7%9C%9F%E5%A5%BD&v=zh-CN-XiaoxiaoNeural&s=cheerful"</code></pre>
<h3>响应</h3>
<p>返回音频文件内容类型取决于请求的输出格式。正常响应状态码为200。</p>
<h3>错误响应</h3>
<p>如果请求参数有误或服务出现问题将返回对应的HTTP错误码和错误消息。</p>
<table>
<thead>
<tr>
<th>状态码</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr>
<td>400</td>
<td>参数错误或缺失必要参数</td>
</tr>
<tr>
<td>404</td>
<td>请求的资源不存在</td>
</tr>
<tr>
<td>500</td>
<td>服务器内部错误</td>
</tr>
</tbody>
</table>
</section>
<section class="card">
<h2>获取可用语音 API</h2>
<h3>端点</h3>
<code>GET {{.BasePath}}/voices</code>
<h3>参数</h3>
<table>
<thead>
<tr>
<th>参数</th>
<th>类型</th>
<th>必选</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>locale</code></td>
<td>string</td>
<td></td>
<td>筛选特定语言的语音例如zh-CN中文、en-US英文</td>
</tr>
<tr>
<td><code>gender</code></td>
<td>string</td>
<td></td>
<td>筛选特定性别的语音可选值Male男性、Female女性</td>
</tr>
</tbody>
</table>
<h3>示例请求</h3>
<pre><code>curl "{{.BasePath}}/voices?locale=zh-CN&gender=Female"</code></pre>
<h3>响应</h3>
<p>返回JSON格式的可用语音列表</p>
<pre><code>[
{
"name": "Microsoft Server Speech Text to Speech Voice (zh-CN, XiaoxiaoNeural)",
"display_name": "Xiaoxiao",
"local_name": "晓晓",
"short_name": "zh-CN-XiaoxiaoNeural",
"gender": "Female",
"locale": "zh-CN",
"locale_name": "中文(中国)",
"style_list": ["cheerful", "sad", "angry", "fearful", "disgruntled"]
},
...
]</code></pre>
<p>响应字段说明:</p>
<ul>
<li><strong>name</strong>:语音的完整名称</li>
<li><strong>display_name</strong>:显示用名称(拉丁字符)</li>
<li><strong>local_name</strong>:本地化名称</li>
<li><strong>short_name</strong>简短名称用于API调用的v参数</li>
<li><strong>gender</strong>性别Male或Female</li>
<li><strong>locale</strong>:语言代码</li>
<li><strong>locale_name</strong>:语言本地化名称</li>
<li><strong>style_list</strong>:支持的情感风格列表(如有)</li>
</ul>
</section>
<section class="card">
<h2>兼容OpenAI接口 API</h2>
<h3>语音合成</h3>
<code>POST {{.BasePath}}/v1/audio/speech</code>
<h3>请求体 (JSON)</h3>
<table>
<thead>
<tr>
<th>参数</th>
<th>类型</th>
<th>必选</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>model</code></td>
<td>string</td>
<td></td>
<td>当前仅支持值: "tts-1"</td>
</tr>
<tr>
<td><code>input</code></td>
<td>string</td>
<td></td>
<td>要转换的文本内容</td>
</tr>
<tr>
<td><code>voice</code></td>
<td>string</td>
<td></td>
<td>声音名称使用Microsoft语音格式例如ja-JP-KeitaNeural、zh-CN-XiaoxiaoNeural</td>
</tr>
<tr>
<td><code>speed</code></td>
<td>number</td>
<td></td>
<td>语速调整,范围: 0.5到2.0,默认: 1.0</td>
</tr>
</tbody>
</table>
<h3>示例请求</h3>
<pre><code>curl -X POST "{{.BasePath}}/v1/audio/speech" \
-H "Content-Type: application/json" \
-d '{
"model": "tts-1",
"input": "你好,世界!",
"voice": "zh-CN-XiaoxiaoNeural"
}'</code></pre>
<h3>另一个示例(带速度调整)</h3>
<pre><code>curl -X POST "{{.BasePath}}/v1/audio/speech" \
-H "Content-Type: application/json" \
-d '{
"model": "tts-1",
"input": "こんにちは、世界!",
"voice": "ja-JP-NanamiNeural",
"speed": 1.2
}'</code></pre>
<h3>响应</h3>
<p>返回音频文件内容类型取决于请求的输出格式。正常响应状态码为200。</p>
<h3>错误响应</h3>
<p>如果请求有误将返回JSON格式的错误信息</p>
<pre><code>{
"error": {
"message": "错误信息描述",
"type": "错误类型",
"code": "错误代码"
}
}</code></pre>
</section>
<section class="card">
<h2>支持的输出格式</h2>
<table>
<thead>
<tr>
<th>格式名称</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>audio-16khz-32kbitrate-mono-mp3</code></td>
<td>MP3格式16kHz, 32kbps</td>
</tr>
<tr>
<td><code>audio-16khz-64kbitrate-mono-mp3</code></td>
<td>MP3格式16kHz, 64kbps</td>
</tr>
<tr>
<td><code>audio-16khz-128kbitrate-mono-mp3</code></td>
<td>MP3格式16kHz, 128kbps</td>
</tr>
<tr>
<td><code>audio-24khz-48kbitrate-mono-mp3</code></td>
<td>MP3格式24kHz, 48kbps</td>
</tr>
<tr>
<td><code>audio-24khz-96kbitrate-mono-mp3</code></td>
<td>MP3格式24kHz, 96kbps</td>
</tr>
<tr>
<td><code>audio-24khz-160kbitrate-mono-mp3</code></td>
<td>MP3格式24kHz, 160kbps</td>
</tr>
<tr>
<td><code>riff-16khz-16bit-mono-pcm</code></td>
<td>WAV格式16kHz</td>
</tr>
<tr>
<td><code>riff-24khz-16bit-mono-pcm</code></td>
<td>WAV格式24kHz</td>
</tr>
</tbody>
</table>
</section>
</main>
<footer>
<p>© 2025 TTS服务 | <a href="{{.BasePath}}/">返回主页</a></p>
</footer>
</div>
</body>
</html>

83
web/templates/index.html Normal file
View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文本转语音 - TTS服务</title>
<link rel="stylesheet" href="{{.BasePath}}/static/css/style.css">
<meta name="description" content="基于Microsoft Azure语音服务的在线文本转语音工具">
</head>
<body>
<div class="container">
<header>
<h1>文本转语音 (TTS)</h1>
<p>将文本转换为自然流畅的语音</p>
<nav>
<a href="{{.BasePath}}/" class="active">主页</a>
<a href="{{.BasePath}}/api-doc">API文档</a>
</nav>
</header>
<main>
<section class="card">
<h2>输入文本</h2>
<div class="input-group">
<textarea id="text" placeholder="输入要转换的文本..." rows="6" maxlength="5000"></textarea>
<div class="char-counter"><span id="charCount">0</span>/5000</div>
</div>
<div class="settings">
<div class="setting-group">
<label for="voice">语音:</label>
<select id="voice">
<option value="loading">加载中...</option>
</select>
</div>
<div class="setting-group">
<label for="rate">语速:</label>
<input type="range" id="rate" min="-50" max="50" value="0">
<span id="rateValue">0%</span>
</div>
<div class="setting-group">
<label for="pitch">语调:</label>
<input type="range" id="pitch" min="-50" max="50" value="0">
<span id="pitchValue">0%</span>
</div>
</div>
<div class="actions">
<button id="speak" class="primary-button">转换为语音</button>
</div>
</section>
<section class="card" id="resultSection" style="display:none;">
<h2>语音输出</h2>
<div class="audio-player">
<audio id="audioPlayer" controls></audio>
<div class="audio-controls">
<button id="download" class="secondary-button">下载音频</button>
<button id="copyLink" class="secondary-button">复制链接</button>
</div>
</div>
</section>
</main>
<footer>
<p>© 2025 TTS服务 | <a href="{{.BasePath}}/api-doc">API文档</a></p>
</footer>
</div>
<script>
// 存储一些全局配置
const config = {
basePath: "{{.BasePath}}",
defaultVoice: "{{.DefaultVoice}}",
defaultRate: "{{.DefaultRate}}",
defaultPitch: "{{.DefaultPitch}}"
};
</script>
<script src="{{.BasePath}}/static/js/app.js"></script>
</body>
</html>

2040
web/templates/worker.js Normal file

File diff suppressed because it is too large Load Diff