Files
speech-tts/speech-t2a.html
2026-03-28 20:12:54 +08:00

800 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
<style>
: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; }
/* hidden native audio element */
#nativeAudio { display: none; }
</style>
</head>
<body>
<div class="container">
<h1>🎙️ MiniMax Speech HD TTS</h1>
<p class="subtitle">HTTP 流式文字转语音 · speech-2.8-hd</p>
<!-- Hidden native audio for reliable playback -->
<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">whisper2.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>
'use strict';
// ── State ──────────────────────────────────────────────
let audioChunks = []; // base64 string chunks
let audioBuffer = null; // decoded AudioBuffer (for visualization)
let audioBlobUrl = null; // blob URL for native <audio>
let isPlaying = false;
let abortCtrl = null;
let animId = null;
let analyserNode = null;
let sourceNode = 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;
}
// Auto-save on input blur / enter
$('apiKey').addEventListener('blur', saveApiKey);
$('apiKey').addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); saveApiKey(); $('apiKey').blur(); }
});
// ── 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();
// Wire native audio events
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();
});
}
init();
// ── 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();
}
}
let audioCtxViz = null;
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) {
// Fallback: simulate with oscillator-free approach
}
drawActive();
}
function drawActive() {
if (!isPlaying) { drawIdle(); return; }
// Animate idle-ish bars since native audio doesn't feed analyser reliably cross-origin
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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';
}
// Convert hex string (lowercase or uppercase) to Uint8Array
function hexToBytes(hex) {
// Remove any whitespace
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) {
// Each chunk is a hex-encoded audio string — decode each and concatenate
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; }
// Sanitize: strip non-ASCII / non-ISO-8859-1 chars from key
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;
// Extra guard: verify header value is clean for fetch()
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');
// Decode MiniMax status_code
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 字符(可能是粘贴时带进了特殊字符),已自动清理,请确认 Key 是否正确', '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;
let lastStatus = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Split on newlines
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);
lastStatus = chunk.data.status;
// Progress: estimate based on chunk count, capped
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) {
// skip malformed line
}
}
}
} 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');
// Maybe it's raw audio?
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');
}
</script>
</body>
</html>