重构:分离 HTML/CSS/JS,项目结构更清晰
This commit is contained in:
12
README.md
12
README.md
@@ -30,8 +30,8 @@
|
|||||||
|
|
||||||
直接用浏览器打开 HTML 文件,无需任何安装:
|
直接用浏览器打开 HTML 文件,无需任何安装:
|
||||||
|
|
||||||
1. 下载 `speech-t2a.html` 文件
|
1. 下载并解压本项目
|
||||||
2. 双击用浏览器(Chrome/Edge/Firefox)打开即可
|
2. 双击 `index.html` 用浏览器(Chrome/Edge/Firefox)打开即可
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ cd /path/to/speech-t2a
|
|||||||
python3 -m http.server 8197
|
python3 -m http.server 8197
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 打开浏览器,访问:**http://localhost:8197/speech-t2a.html**
|
4. 打开浏览器,访问:**http://localhost:8197/index.html**
|
||||||
|
|
||||||
> ⚠️ 关闭终端会停止服务,需要保持窗口开启
|
> ⚠️ 关闭终端会停止服务,需要保持窗口开启
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ python3 -m http.server 8197
|
|||||||
|
|
||||||
**步骤:**
|
**步骤:**
|
||||||
|
|
||||||
1. 将 `speech-t2a.html` 上传到服务器,例如 `/var/www/speech-t2a/`
|
1. 将 `index.html` 上传到服务器,例如 `/var/www/speech-t2a/`
|
||||||
2. 编辑 Nginx 配置:
|
2. 编辑 Nginx 配置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -86,7 +86,7 @@ server {
|
|||||||
|
|
||||||
# 静态文件目录
|
# 静态文件目录
|
||||||
root /var/www/speech-t2a;
|
root /var/www/speech-t2a;
|
||||||
index speech-t2a.html;
|
index index.html;
|
||||||
|
|
||||||
# 允许浏览器访问
|
# 允许浏览器访问
|
||||||
location / {
|
location / {
|
||||||
@@ -106,7 +106,7 @@ sudo nginx -t
|
|||||||
sudo systemctl restart nginx
|
sudo systemctl restart nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
5. 访问:**http://你的服务器IP:8197/speech-t2a.html**
|
5. 访问:**http://你的服务器IP:8197/index.html**
|
||||||
|
|
||||||
> 💡 阿里云/腾讯云等云服务器需在安全组开放 `8197` 端口
|
> 💡 阿里云/腾讯云等云服务器需在安全组开放 `8197` 端口
|
||||||
|
|
||||||
|
|||||||
507
app.js
Normal file
507
app.js
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────────────────
|
||||||
|
let audioChunks = [];
|
||||||
|
let audioBuffer = null;
|
||||||
|
let audioBlobUrl = null;
|
||||||
|
let isPlaying = false;
|
||||||
|
let abortCtrl = null;
|
||||||
|
let animId = null;
|
||||||
|
let analyserNode = null;
|
||||||
|
let audioCtxViz = null;
|
||||||
|
|
||||||
|
// ── DOM ───────────────────────────────────────────────
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const btnPlay = $('btnPlay');
|
||||||
|
const btnStop = $('btnStop');
|
||||||
|
const btnDownload = $('btnDownload');
|
||||||
|
const btnReplay = $('btnReplay');
|
||||||
|
const logArea = $('logArea');
|
||||||
|
const statusBadge = $('statusBadge');
|
||||||
|
const progressBar = $('progressBar');
|
||||||
|
const progressFill= $('progressFill');
|
||||||
|
const charCount = $('charCount');
|
||||||
|
const speedVal = $('speedVal');
|
||||||
|
const canvas = $('waveform');
|
||||||
|
const canvasCtx = canvas.getContext('2d');
|
||||||
|
const nativeAudio = $('nativeAudio');
|
||||||
|
|
||||||
|
// ── Persistence: API Key ──────────────────────────────
|
||||||
|
const LS_KEY = 'mm_tts_apikey';
|
||||||
|
|
||||||
|
function saveApiKey() {
|
||||||
|
const val = $('apiKey').value.trim();
|
||||||
|
if (val) localStorage.setItem(LS_KEY, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadApiKey() {
|
||||||
|
const saved = localStorage.getItem(LS_KEY);
|
||||||
|
if (saved) $('apiKey').value = saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ───────────────────────────────────────────────
|
||||||
|
function init() {
|
||||||
|
loadApiKey();
|
||||||
|
$('speed').addEventListener('input', e => speedVal.textContent = parseFloat(e.target.value).toFixed(2) + 'x');
|
||||||
|
$('textInput').addEventListener('input', e => charCount.textContent = e.target.value.length);
|
||||||
|
resizeCanvas();
|
||||||
|
window.addEventListener('resize', resizeCanvas);
|
||||||
|
drawIdle();
|
||||||
|
|
||||||
|
nativeAudio.addEventListener('ended', () => {
|
||||||
|
isPlaying = false;
|
||||||
|
stopVisualizer();
|
||||||
|
setStatus('完成', 'status-done');
|
||||||
|
log('✅ 播放完毕', 'info');
|
||||||
|
onDone();
|
||||||
|
});
|
||||||
|
nativeAudio.addEventListener('error', e => {
|
||||||
|
log('⚠️ 音频播放错误: ' + (nativeAudio.error && nativeAudio.error.message), 'err');
|
||||||
|
isPlaying = false;
|
||||||
|
onDone();
|
||||||
|
});
|
||||||
|
nativeAudio.addEventListener('playing', () => {
|
||||||
|
setStatus('播放中', 'status-playing');
|
||||||
|
startVisualizer();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Canvas ─────────────────────────────────────────────
|
||||||
|
function resizeCanvas() {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = canvas.offsetWidth * dpr;
|
||||||
|
canvas.height = canvas.offsetHeight * dpr;
|
||||||
|
canvasCtx.scale(dpr, dpr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawIdle() {
|
||||||
|
const w = canvas.offsetWidth, h = canvas.offsetHeight;
|
||||||
|
canvasCtx.clearRect(0, 0, w, h);
|
||||||
|
for (let i = 0; i < 72; i++) {
|
||||||
|
const x = i * (w / 72 - 1), y = (h - 4) / 2;
|
||||||
|
canvasCtx.fillStyle = '#2a2d3a';
|
||||||
|
canvasCtx.beginPath();
|
||||||
|
canvasCtx.roundRect(x, y, w / 72 - 1, 4, 2);
|
||||||
|
canvasCtx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startVisualizer() {
|
||||||
|
if (!audioCtxViz) {
|
||||||
|
audioCtxViz = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
}
|
||||||
|
if (!analyserNode) {
|
||||||
|
analyserNode = audioCtxViz.createAnalyser();
|
||||||
|
analyserNode.fftSize = 256;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const track = nativeAudio.captureStream ? nativeAudio.captureStream().getAudioTracks()[0] : null;
|
||||||
|
if (track) {
|
||||||
|
const src = audioCtxViz.createMediaStreamSource(nativeAudio.captureStream());
|
||||||
|
src.connect(analyserNode);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
drawActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawActive() {
|
||||||
|
if (!isPlaying) { drawIdle(); return; }
|
||||||
|
const w = canvas.offsetWidth, h = canvas.offsetHeight;
|
||||||
|
canvasCtx.clearRect(0, 0, w, h);
|
||||||
|
const t = Date.now() / 200;
|
||||||
|
for (let i = 0; i < 72; i++) {
|
||||||
|
const phase = (i / 72) * Math.PI * 2;
|
||||||
|
const v = Math.abs(Math.sin(t + phase)) * 0.7 + 0.1;
|
||||||
|
const barH = Math.max(3, v * h * 0.85);
|
||||||
|
const x = i * (w / 72 - 1);
|
||||||
|
const y = (h - barH) / 2;
|
||||||
|
const grad = canvasCtx.createLinearGradient(x, y, x, y + barH);
|
||||||
|
grad.addColorStop(0, '#a78bfa'); grad.addColorStop(1, '#7c6af7');
|
||||||
|
canvasCtx.fillStyle = grad;
|
||||||
|
canvasCtx.beginPath();
|
||||||
|
canvasCtx.roundRect(x, y, w / 72 - 1, barH, 2);
|
||||||
|
canvasCtx.fill();
|
||||||
|
}
|
||||||
|
animId = requestAnimationFrame(drawActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopVisualizer() {
|
||||||
|
if (animId) { cancelAnimationFrame(animId); animId = null; }
|
||||||
|
if (analyserNode) { try { analyserNode.disconnect(); } catch(e){} }
|
||||||
|
drawIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Logging ────────────────────────────────────────────
|
||||||
|
function log(msg, type = 'info') {
|
||||||
|
const ts = new Date().toLocaleTimeString('zh-CN',{hour12:false});
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'log-line';
|
||||||
|
d.innerHTML = `<span class="log-ts">[${ts}]</span><span class="log-${type}">${esc(msg)}</span>`;
|
||||||
|
logArea.appendChild(d);
|
||||||
|
logArea.scrollTop = logArea.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
function setStatus(s, cls) { statusBadge.textContent = s; statusBadge.className = 'status-badge ' + cls; }
|
||||||
|
|
||||||
|
// ── Audio helpers ─────────────────────────────────────
|
||||||
|
function stopPlayback() {
|
||||||
|
if (isPlaying) {
|
||||||
|
nativeAudio.pause();
|
||||||
|
nativeAudio.currentTime = 0;
|
||||||
|
}
|
||||||
|
isPlaying = false;
|
||||||
|
stopVisualizer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeOf(fmt) {
|
||||||
|
return { mp3:'audio/mpeg', wav:'audio/wav', flac:'audio/flac' }[fmt] || 'audio/mpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToBytes(hex) {
|
||||||
|
hex = hex.replace(/\s/g, '');
|
||||||
|
if (!/^[0-9a-fA-F]*$/.test(hex) || hex.length % 2 !== 0) {
|
||||||
|
return new Uint8Array(0);
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBlobUrl(hexChunks, fmt) {
|
||||||
|
const allBytes = [];
|
||||||
|
for (const hex of hexChunks) {
|
||||||
|
const b = hexToBytes(hex);
|
||||||
|
if (b.length > 0) allBytes.push(b);
|
||||||
|
}
|
||||||
|
if (allBytes.length === 0) return null;
|
||||||
|
const totalLen = allBytes.reduce((s, b) => s + b.length, 0);
|
||||||
|
const combined = new Uint8Array(totalLen);
|
||||||
|
let off = 0;
|
||||||
|
for (const b of allBytes) { combined.set(b, off); off += b.length; }
|
||||||
|
const mime = mimeOf(fmt);
|
||||||
|
const blob = new Blob([combined], { type: mime });
|
||||||
|
if (audioBlobUrl) URL.revokeObjectURL(audioBlobUrl);
|
||||||
|
audioBlobUrl = URL.createObjectURL(blob);
|
||||||
|
return audioBlobUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAudioPlayback(blobUrl) {
|
||||||
|
stopPlayback();
|
||||||
|
nativeAudio.src = blobUrl;
|
||||||
|
nativeAudio.play().then(() => {
|
||||||
|
isPlaying = true;
|
||||||
|
setStatus('播放中', 'status-playing');
|
||||||
|
log('▶️ 开始播放', 'info');
|
||||||
|
startVisualizer();
|
||||||
|
}).catch(e => {
|
||||||
|
log('❌ 播放失败: ' + e.message, 'err');
|
||||||
|
onDone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAudioBlob() {
|
||||||
|
if (!audioChunks.length) return null;
|
||||||
|
const allBytes = [];
|
||||||
|
for (const hex of audioChunks) {
|
||||||
|
const b = hexToBytes(hex);
|
||||||
|
if (b.length > 0) allBytes.push(b);
|
||||||
|
}
|
||||||
|
if (allBytes.length === 0) return null;
|
||||||
|
const total = allBytes.reduce((s, b) => s + b.length, 0);
|
||||||
|
const combined = new Uint8Array(total);
|
||||||
|
let off = 0;
|
||||||
|
for (const b of allBytes) { combined.set(b, off); off += b.length; }
|
||||||
|
return new Blob([combined], { type: mimeOf($('format').value) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main synthesis ─────────────────────────────────────
|
||||||
|
async function startSynthesis() {
|
||||||
|
const rawKey = $('apiKey').value.trim();
|
||||||
|
const text = $('textInput').value.trim();
|
||||||
|
|
||||||
|
if (!rawKey) { log('❌ 请先填写 API Key', 'err'); return; }
|
||||||
|
if (!text) { log('❌ 请先填写要转换的文字', 'err'); return; }
|
||||||
|
|
||||||
|
const cleanKey = rawKey.replace(/[^\x20-\x7E]/g, '').trim();
|
||||||
|
if (!cleanKey) {
|
||||||
|
log('❌ API Key 包含无效字符(仅支持 ASCII),请重新复制粘贴正确的 Key', 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cleanKey !== rawKey) {
|
||||||
|
log('⚠️ API Key 中有不可用字符已自动清除,请确认 Key 正确', 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
saveApiKey();
|
||||||
|
|
||||||
|
let apiKey = cleanKey.startsWith('Bearer ') ? cleanKey : 'Bearer ' + cleanKey;
|
||||||
|
|
||||||
|
try {
|
||||||
|
new Headers({ 'Authorization': apiKey });
|
||||||
|
} catch(e) {
|
||||||
|
log('❌ API Key 包含非法字符,无法作为 HTTP Header: ' + e.message, 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioChunks = [];
|
||||||
|
stopPlayback();
|
||||||
|
progressFill.style.width = '0%';
|
||||||
|
progressBar.classList.add('active');
|
||||||
|
|
||||||
|
btnPlay.disabled = true; btnStop.disabled = false;
|
||||||
|
btnDownload.disabled = true; btnReplay.disabled = true;
|
||||||
|
|
||||||
|
const isStream = $('streamMode').value === 'true';
|
||||||
|
log(`🚀 发起 HTTP 请求 (${isStream ? '流式' : '非流式'})`, 'info');
|
||||||
|
setStatus('请求中', 'status-connecting');
|
||||||
|
abortCtrl = new AbortController();
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model: $('model').value,
|
||||||
|
text,
|
||||||
|
stream: isStream,
|
||||||
|
voice_setting: {
|
||||||
|
voice_id: $('voiceId').value,
|
||||||
|
speed: parseFloat($('speed').value),
|
||||||
|
vol: parseFloat($('vol').value),
|
||||||
|
pitch: parseInt($('pitch').value),
|
||||||
|
...($('emotion').value ? { emotion: $('emotion').value } : {})
|
||||||
|
},
|
||||||
|
audio_setting: {
|
||||||
|
sample_rate: parseInt($('sampleRate').value),
|
||||||
|
bitrate: 128000,
|
||||||
|
format: $('format').value,
|
||||||
|
channel: 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.minimaxi.com/v1/t2a_v2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: abortCtrl.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
log(`📡 HTTP ${response.status} ${response.statusText}`, response.ok ? 'info' : 'err');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
try {
|
||||||
|
const err = await response.json();
|
||||||
|
log(`❌ 错误: ${JSON.stringify(err)}`, 'err');
|
||||||
|
const sr = err.base_resp || {};
|
||||||
|
if (sr.status_code === 1004) log('💡 原因: 鉴权失败,检查 API Key 是否正确', 'warn');
|
||||||
|
if (sr.status_code === 1039) log('💡 原因: TPM 触发限流,稍后重试', 'warn');
|
||||||
|
if (sr.status_code === 1042) log('💡 原因: 非法字符超过 10%,请精简文本', 'warn');
|
||||||
|
} catch {}
|
||||||
|
setStatus('失败', 'status-error');
|
||||||
|
onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStream) {
|
||||||
|
setStatus('合成中', 'status-playing');
|
||||||
|
await handleStreamResponse(response);
|
||||||
|
} else {
|
||||||
|
setStatus('合成中', 'status-playing');
|
||||||
|
await handleNonStreamResponse(response);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
log('⏹️ 用户取消', 'warn');
|
||||||
|
} else if (e.message && e.message.includes('ISO-8859-1')) {
|
||||||
|
log('❌ Header 编码错误:API Key 包含非 ASCII 字符', 'err');
|
||||||
|
} else {
|
||||||
|
log('❌ 请求异常: ' + e.message, 'err');
|
||||||
|
log('🔄 尝试备用接口 api-bj.minimaxi.com...', 'warn');
|
||||||
|
try {
|
||||||
|
const r2 = await fetch('https://api-bj.minimaxi.com/v1/t2a_v2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': apiKey, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: abortCtrl.signal
|
||||||
|
});
|
||||||
|
if (r2.ok) {
|
||||||
|
if ($('streamMode').value === 'true') await handleStreamResponse(r2);
|
||||||
|
else await handleNonStreamResponse(r2);
|
||||||
|
} else {
|
||||||
|
const err2 = await r2.json().catch(() => ({}));
|
||||||
|
log(`❌ 备用接口也失败: ${JSON.stringify(err2)}`, 'err');
|
||||||
|
setStatus('失败', 'status-error');
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
} catch(e2) {
|
||||||
|
log('❌ 备用接口异常: ' + e2.message, 'err');
|
||||||
|
setStatus('失败', 'status-error');
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stream handler ─────────────────────────────────────
|
||||||
|
async function handleStreamResponse(response) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let chunkCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let nlIdx;
|
||||||
|
while ((nlIdx = buffer.indexOf('\n')) !== -1) {
|
||||||
|
const line = buffer.slice(0, nlIdx).trim();
|
||||||
|
buffer = buffer.slice(nlIdx + 1);
|
||||||
|
if (!line || !line.startsWith('data:')) continue;
|
||||||
|
const dataStr = line.slice(5).trim();
|
||||||
|
if (dataStr === '[DONE]' || dataStr === '') continue;
|
||||||
|
try {
|
||||||
|
const chunk = JSON.parse(dataStr);
|
||||||
|
const baseResp = chunk.base_resp || {};
|
||||||
|
if (baseResp.status_code && baseResp.status_code !== 0) {
|
||||||
|
log(`❌ 流错误 [${baseResp.status_code}]: ${baseResp.status_msg || ''}`, 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chunk.data && chunk.data.audio) {
|
||||||
|
chunkCount++;
|
||||||
|
audioChunks.push(chunk.data.audio);
|
||||||
|
progressFill.style.width = Math.min((chunkCount / Math.max(parseInt($('textInput').value.length / 200), 5)) * 95, 95) + '%';
|
||||||
|
log(`🔊 #${chunkCount} (${chunk.data.audio.length}B, status=${chunk.data.status})`, 'audio');
|
||||||
|
if (chunk.data.status === 2) {
|
||||||
|
log('✅ 合成完毕,共 ' + chunkCount + ' 片段', 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chunk.extra_info) {
|
||||||
|
const ei = chunk.extra_info;
|
||||||
|
log(`📋 音频: ${(ei.audio_length/1000).toFixed(1)}s / ${ei.audio_size}B / ${ei.usage_characters}字符`, 'info');
|
||||||
|
}
|
||||||
|
} catch(parseErr) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audioChunks.length) {
|
||||||
|
log('❌ 流接收完毕但无音频数据', 'err');
|
||||||
|
setStatus('失败', 'status-error');
|
||||||
|
onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
progressFill.style.width = '98%';
|
||||||
|
log('🎵 音频合成完成,开始播放...', 'info');
|
||||||
|
const blobUrl = buildBlobUrl(audioChunks, $('format').value);
|
||||||
|
startAudioPlayback(blobUrl);
|
||||||
|
btnDownload.disabled = false;
|
||||||
|
btnReplay.disabled = false;
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Non-stream handler ─────────────────────────────────
|
||||||
|
async function handleNonStreamResponse(response) {
|
||||||
|
log('📥 接收完整响应...', 'info');
|
||||||
|
let json;
|
||||||
|
try {
|
||||||
|
json = await response.json();
|
||||||
|
} catch(e) {
|
||||||
|
log('❌ 响应 JSON 解析失败: ' + e.message, 'err');
|
||||||
|
log('🔄 尝试作为原始音频处理...', 'warn');
|
||||||
|
const bytes = await response.arrayBuffer();
|
||||||
|
if (bytes.byteLength > 0) {
|
||||||
|
const blob = new Blob([bytes], { type: mimeOf($('format').value) });
|
||||||
|
if (audioBlobUrl) URL.revokeObjectURL(audioBlobUrl);
|
||||||
|
audioBlobUrl = URL.createObjectURL(blob);
|
||||||
|
startAudioPlayback(audioBlobUrl);
|
||||||
|
btnDownload.disabled = false;
|
||||||
|
btnReplay.disabled = false;
|
||||||
|
onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('失败', 'status-error');
|
||||||
|
onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('📦 JSON: ' + JSON.stringify(json).slice(0, 400), 'info');
|
||||||
|
const baseResp = json.base_resp || {};
|
||||||
|
if (baseResp.status_code && baseResp.status_code !== 0) {
|
||||||
|
log(`❌ 合成失败 [${baseResp.status_code}]: ${baseResp.status_msg || ''}`, 'err');
|
||||||
|
if (baseResp.status_code === 1004) log('💡 提示: 鉴权失败,检查 API Key 是否正确', 'warn');
|
||||||
|
setStatus('失败', 'status-error');
|
||||||
|
onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const audioHex = json.data && json.data.audio;
|
||||||
|
if (!audioHex) {
|
||||||
|
log('❌ 响应中无 audio 字段', 'err');
|
||||||
|
setStatus('失败', 'status-error');
|
||||||
|
onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
audioChunks = [audioHex];
|
||||||
|
progressFill.style.width = '90%';
|
||||||
|
log('🎵 音频就绪,开始播放...', 'info');
|
||||||
|
const blobUrl = buildBlobUrl(audioChunks, $('format').value);
|
||||||
|
startAudioPlayback(blobUrl);
|
||||||
|
if (json.extra_info) {
|
||||||
|
const ei = json.extra_info;
|
||||||
|
log(`📋 音频: ${(ei.audio_length/1000).toFixed(1)}s / ${ei.audio_size}B / ${ei.usage_characters}字符`, 'info');
|
||||||
|
}
|
||||||
|
btnDownload.disabled = false;
|
||||||
|
btnReplay.disabled = false;
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ───────────────────────────────────────────
|
||||||
|
function stopSynthesis() {
|
||||||
|
log('⏹️ 取消请求', 'warn');
|
||||||
|
stopPlayback();
|
||||||
|
if (abortCtrl) abortCtrl.abort();
|
||||||
|
progressFill.style.width = '0%';
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDone() {
|
||||||
|
btnPlay.disabled = false;
|
||||||
|
btnStop.disabled = true;
|
||||||
|
setTimeout(() => progressBar.classList.remove('active'), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replayAudio() {
|
||||||
|
if (audioBlobUrl) {
|
||||||
|
stopPlayback();
|
||||||
|
startAudioPlayback(audioBlobUrl);
|
||||||
|
} else {
|
||||||
|
log('⚠️ 没有可回放的音频', 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadAudio() {
|
||||||
|
const blob = getAudioBlob();
|
||||||
|
if (!blob) { log('⚠️ 没有可下载的音频', 'warn'); return; }
|
||||||
|
const fmt = $('format').value;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `speech_${Date.now()}.${fmt}`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
log(`💾 已下载: ${a.download} (${(blob.size/1024).toFixed(1)} KB)`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// auto-init
|
||||||
|
$('apiKey').addEventListener('blur', saveApiKey);
|
||||||
|
$('apiKey').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); saveApiKey(); $('apiKey').blur(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
init();
|
||||||
171
index.html
Normal file
171
index.html
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MiniMax Speech HD TTS</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🎙️ MiniMax Speech HD TTS</h1>
|
||||||
|
<p class="subtitle">HTTP 流式文字转语音 · speech-2.8-hd</p>
|
||||||
|
|
||||||
|
<audio id="nativeAudio" controls></audio>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<label>API Key <span style="font-weight:400;text-transform:none;font-size:0.7rem;color:#4b5563">(自动保存到本地)</span></label>
|
||||||
|
<input type="password" id="apiKey" placeholder="输入你的 MiniMax API Key(如 Group-xxxxxxxx)" autocomplete="off" />
|
||||||
|
<p class="hint">填写后回车或点击空白处保存。刷新页面不丢失。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label>音色 (voice_id)</label>
|
||||||
|
<select id="voiceId">
|
||||||
|
<optgroup label="中文">
|
||||||
|
<option value="male-qn-qingse">male-qn-qingse(青年女声)</option>
|
||||||
|
<option value="male-qn-baiyang">male-qn-baiyang(青年男声-白扬)</option>
|
||||||
|
<option value="female-tianmei">female-tianmei(甜美女声)</option>
|
||||||
|
<option value="female-shaonv">female-shaonv(少女声)</option>
|
||||||
|
<option value="male-shaonian">male-shaonian(少年声)</option>
|
||||||
|
<option value="male-zhongtan">male-zhongtan(中年男声)</option>
|
||||||
|
<option value="female-yujie">female-yujie(御姐音)</option>
|
||||||
|
<option value="female-xiaochao">female-xiaochao(小潮音色)</option>
|
||||||
|
<option value="male-happyman">male-happyman(开心男声)</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="英语">
|
||||||
|
<option value="male-cenek">male-cenek(英语男声-Cenek)</option>
|
||||||
|
<option value="female-alice">female-alice(英语女声-Alice)</option>
|
||||||
|
<option value="male-joshua">male-joshua(英语男声-Joshua)</option>
|
||||||
|
<option value="female-maverick">female-maverick(英语女声-Maverick)</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="日语">
|
||||||
|
<option value="male-jp-qingse">male-jp-qingse</option>
|
||||||
|
<option value="female-jp-qingse">female-jp-qingse</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="韩语">
|
||||||
|
<option value="male-kr-qingse">male-kr-qingse</option>
|
||||||
|
<option value="female-kr-qingse">female-kr-qingse</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>模型</label>
|
||||||
|
<select id="model">
|
||||||
|
<option value="speech-2.8-hd">speech-2.8-hd(高清)</option>
|
||||||
|
<option value="speech-2.6-hd">speech-2.6-hd</option>
|
||||||
|
<option value="speech-02-hd">speech-02-hd(克隆音质)</option>
|
||||||
|
<option value="speech-2.8-turbo">speech-2.8-turbo(快速)</option>
|
||||||
|
<option value="speech-2.6-turbo">speech-2.6-turbo</option>
|
||||||
|
<option value="speech-02-turbo">speech-02-turbo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:14px">
|
||||||
|
<label>语速 <span class="speed-val" id="speedVal">1.00x</span></label>
|
||||||
|
<input type="range" class="speed-slider" id="speed" min="0.5" max="2.0" step="0.05" value="1.0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="margin-top:14px">
|
||||||
|
<div>
|
||||||
|
<label>音频格式</label>
|
||||||
|
<select id="format">
|
||||||
|
<option value="mp3">mp3</option>
|
||||||
|
<option value="wav">wav</option>
|
||||||
|
<option value="flac">flac</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>采样率</label>
|
||||||
|
<select id="sampleRate">
|
||||||
|
<option value="16000">16000 Hz</option>
|
||||||
|
<option value="32000" selected>32000 Hz</option>
|
||||||
|
<option value="44100">44100 Hz</option>
|
||||||
|
<option value="48000">48000 Hz</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="margin-top:14px">
|
||||||
|
<div>
|
||||||
|
<label>音量</label>
|
||||||
|
<select id="vol">
|
||||||
|
<option value="0.5">0.5(低)</option>
|
||||||
|
<option value="0.75">0.75</option>
|
||||||
|
<option value="1" selected>1.0(正常)</option>
|
||||||
|
<option value="1.25">1.25</option>
|
||||||
|
<option value="1.5">1.5(高)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>音调</label>
|
||||||
|
<select id="pitch">
|
||||||
|
<option value="-5">-5(低沉)</option>
|
||||||
|
<option value="-2">-2</option>
|
||||||
|
<option value="0" selected>0(正常)</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="5">5(高亢)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="margin-top:14px">
|
||||||
|
<div>
|
||||||
|
<label>情绪(仅 HD/Turbo)</label>
|
||||||
|
<select id="emotion">
|
||||||
|
<option value="">不指定(自动)</option>
|
||||||
|
<option value="happy">happy</option>
|
||||||
|
<option value="sad">sad</option>
|
||||||
|
<option value="angry">angry</option>
|
||||||
|
<option value="surprised">surprised</option>
|
||||||
|
<option value="calm">calm</option>
|
||||||
|
<option value="whisper">whisper(2.6turbo)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>流式输出</label>
|
||||||
|
<select id="streamMode">
|
||||||
|
<option value="true">是(实时,边收边播)</option>
|
||||||
|
<option value="false" selected>否(等完成后再播)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<label>输入文字(最多 10000 字符)</label>
|
||||||
|
<textarea id="textInput" placeholder="输入要转换的文字,例如:真正的危险不是计算机开始像人一样思考,而是人开始像计算机一样思考。" maxlength="10000"></textarea>
|
||||||
|
<div style="text-align:right;font-size:0.72rem;color:var(--muted);margin-top:4px;">
|
||||||
|
<span id="charCount">0</span> / 10000
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-primary" id="btnPlay" onclick="startSynthesis()">▶ 开始合成</button>
|
||||||
|
<button class="btn btn-danger" id="btnStop" onclick="stopSynthesis()" disabled>■ 停止</button>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row" style="margin-top:8px">
|
||||||
|
<button class="btn btn-secondary" id="btnDownload" onclick="downloadAudio()" disabled>💾 下载音频</button>
|
||||||
|
<button class="btn btn-secondary" id="btnReplay" onclick="replayAudio()" disabled>🔁 重新播放</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-bar" id="progressBar">
|
||||||
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
|
</div>
|
||||||
|
<canvas id="waveform"></canvas>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||||
|
<label style="margin:0">日志</label>
|
||||||
|
<span class="status-badge status-idle" id="statusBadge">空闲</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-area" id="logArea"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
80
style.css
Normal file
80
style.css
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f1117; --surface: #1a1d27; --border: #2a2d3a;
|
||||||
|
--accent: #7c6af7; --text: #e0e0e0; --muted: #888; --radius: 10px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Inter','PingFang SC','Microsoft YaHei',sans-serif;
|
||||||
|
background: var(--bg); color: var(--text);
|
||||||
|
min-height: 100vh; display: flex; justify-content: center;
|
||||||
|
align-items: flex-start; padding: 40px 16px;
|
||||||
|
}
|
||||||
|
.container { width: 100%; max-width: 680px; }
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem; font-weight: 700; text-align: center; margin-bottom: 6px;
|
||||||
|
background: linear-gradient(135deg,#a78bfa,#f472b6);
|
||||||
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.subtitle { text-align: center; color: var(--muted); font-size: 0.8rem; margin-bottom: 28px; }
|
||||||
|
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 16px; }
|
||||||
|
label { display: block; font-size: 0.78rem; font-weight: 600; color: var(--muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
input[type="text"], input[type="password"], textarea, select {
|
||||||
|
width: 100%; background: #11131a; border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; color: var(--text); font-size: 0.9rem;
|
||||||
|
padding: 10px 12px; outline: none; transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus, select:focus { border-color: var(--accent); }
|
||||||
|
textarea { resize: vertical; min-height: 110px; line-height: 1.6; }
|
||||||
|
select { cursor: pointer; }
|
||||||
|
.btn-row { display: flex; gap: 10px; margin-top: 16px; }
|
||||||
|
.btn {
|
||||||
|
flex: 1; padding: 12px; border: none; border-radius: 6px;
|
||||||
|
font-size: 0.9rem; font-weight: 600; cursor: pointer;
|
||||||
|
transition: opacity 0.2s, transform 0.1s;
|
||||||
|
}
|
||||||
|
.btn:active { transform: scale(0.97); }
|
||||||
|
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.btn-primary { background: linear-gradient(135deg,#7c6af7,#a78bfa); color: #fff; }
|
||||||
|
.btn-danger { background: #2a1a1a; border: 1px solid #5a2a2a; color: #ff8080; }
|
||||||
|
.btn-secondary { background: #1a1d27; border: 1px solid var(--border); color: var(--text); }
|
||||||
|
.log-area {
|
||||||
|
background: #0a0b10; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 12px; font-family: 'Fira Code',monospace; font-size: 0.73rem;
|
||||||
|
color: #9ca3af; max-height: 180px; overflow-y: auto; margin-top: 10px; line-height: 1.7;
|
||||||
|
}
|
||||||
|
.log-line { display: flex; gap: 8px; }
|
||||||
|
.log-ts { color: #4b5563; flex-shrink: 0; }
|
||||||
|
.log-info { color: #6ee7b7; } .log-warn { color: #fde68a; }
|
||||||
|
.log-err { color: #f87171; } .log-audio { color: #93c5fd; }
|
||||||
|
.progress-bar {
|
||||||
|
height: 3px; background: var(--border); border-radius: 2px;
|
||||||
|
margin-top: 12px; overflow: hidden; opacity: 0; transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
.progress-bar.active { opacity: 1; }
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%; background: linear-gradient(90deg,#7c6af7,#a78bfa);
|
||||||
|
width: 0%; transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block; font-size: 0.72rem; padding: 2px 8px;
|
||||||
|
border-radius: 20px; margin-left: 8px; vertical-align: middle;
|
||||||
|
}
|
||||||
|
.status-idle { background:#1f2937; color:#6b7280; }
|
||||||
|
.status-connecting { background:#1f2a1f; color:#86efac; }
|
||||||
|
.status-playing { background:#1f1f2e; color:#a78bfa; }
|
||||||
|
.status-done { background:#1a2e1a; color:#4ade80; }
|
||||||
|
.status-error { background:#2e1a1a; color:#f87171; }
|
||||||
|
#waveform { width:100%; height:48px; margin-top:10px; border-radius:4px; display:block; }
|
||||||
|
.speed-slider {
|
||||||
|
-webkit-appearance:none; width:100%; height:4px; border-radius:2px;
|
||||||
|
background: var(--border); outline:none; margin:8px 0;
|
||||||
|
}
|
||||||
|
.speed-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance:none; width:14px; height:14px; border-radius:50%;
|
||||||
|
background:var(--accent); cursor:pointer;
|
||||||
|
}
|
||||||
|
.speed-val { font-size:0.78rem; color:var(--accent); float:right; }
|
||||||
|
.hint { font-size:0.72rem; color:var(--muted); margin-top:6px; }
|
||||||
|
.hint a { color:#a78bfa; }
|
||||||
|
#nativeAudio { display: none; }
|
||||||
Reference in New Issue
Block a user