重构:分离 HTML/CSS/JS,项目结构更清晰
This commit is contained in:
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();
|
||||
Reference in New Issue
Block a user