Files
speech-tts/app.js

508 lines
17 KiB
JavaScript
Raw Permalink 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.
'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();