'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 = `[${ts}]${esc(msg)}`; logArea.appendChild(d); logArea.scrollTop = logArea.scrollHeight; } function esc(s) { return String(s).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();