重构:分离 HTML/CSS/JS,项目结构更清晰

This commit is contained in:
zwbcc
2026-03-28 20:26:10 +08:00
parent cf7a5f365a
commit c1a69cd50d
4 changed files with 764 additions and 6 deletions

507
app.js Normal file
View 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,'&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';
}
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();