/** * 军事科技 AI配音系统 - 前端脚本 */ const CONFIG = { API_BASE_URL: '', // 空字符串表示使用相对路径,通过Nginx代理 MAX_CHARS: 2000, EMOTIONS: { 'happy': { name: '开心', color: '#fbbf24' }, 'sad': { name: '悲伤', color: '#60a5fa' }, 'excited': { name: '激动', color: '#f97316' }, 'surprised': { name: '惊讶', color: '#a855f7' }, 'fearful': { name: '害怕', color: '#ec4899' }, 'disgusted': { name: '厌恶', color: '#14b8a6' } } }; const state = { currentEngine: 'minimax', selectedEmotion: null, generatedAudio: null, isPlaying: false, history: [] }; const elements = { editor: document.getElementById('editor'), editorPlaceholder: document.getElementById('editorPlaceholder'), charCount: document.getElementById('charCount'), switchBtns: document.querySelectorAll('.switch-btn'), emotionBtns: document.querySelectorAll('.emotion-btn'), speedSlider: document.getElementById('speedSlider'), speedValue: document.getElementById('speedValue'), pitchSlider: document.getElementById('pitchSlider'), pitchValue: document.getElementById('pitchValue'), volumeSlider: document.getElementById('volumeSlider'), volumeValue: document.getElementById('volumeValue'), generateBtn: document.getElementById('generateBtn'), clearBtn: document.getElementById('clearBtn'), audioDisplay: document.getElementById('audioDisplay'), audioControls: document.getElementById('audioControls'), playBtn: document.getElementById('playBtn'), progressFill: document.getElementById('progressFill'), timeDisplay: document.getElementById('timeDisplay'), downloadBtn: document.getElementById('downloadBtn'), audioInfo: document.getElementById('audioInfo'), infoEngine: document.getElementById('infoEngine'), historyList: document.getElementById('historyList'), loadingOverlay: document.getElementById('loadingOverlay'), loadingText: document.getElementById('loadingText'), toast: document.getElementById('toast') }; let audioElement = new Audio(); function showToast(message, type = 'info') { const toast = elements.toast; toast.querySelector('.toast-message').textContent = message; toast.className = `toast show ${type}`; setTimeout(() => toast.classList.remove('show'), 3000); } function showLoading(text = '正在处理...') { elements.loadingText.textContent = text; elements.loadingOverlay.style.display = 'flex'; } function hideLoading() { elements.loadingOverlay.style.display = 'none'; } function formatTime(seconds) { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } function updateCharCount() { const text = elements.editor.innerText || ''; const count = text.replace(/\s/g, '').length; elements.charCount.textContent = count; if (text.trim() === '') { elements.editor.classList.add('is-empty'); } else { elements.editor.classList.remove('is-empty'); } } function getSelectedText() { return window.getSelection().toString().trim(); } function applyEmotionTag(emotion, emotionName) { const selection = window.getSelection(); if (!selection.rangeCount) { showToast('请先选中要添加情绪的文字', 'error'); return; } const selectedText = selection.toString().trim(); if (!selectedText) { showToast('请先选中要添加情绪的文字', 'error'); return; } const tagHtml = `[${emotionName}]`; const range = selection.getRangeAt(0); const selectedContent = range.extractContents(); const wrapper = document.createElement('span'); wrapper.innerHTML = tagHtml; const textNode = document.createTextNode(selectedText); wrapper.appendChild(textNode); range.insertNode(wrapper); selection.removeAllRanges(); const space = document.createTextNode(' '); wrapper.after(space); elements.editor.focus(); showToast(`已添加「${emotionName}」情绪`, 'success'); clearEmotionSelection(); } function clearEmotionSelection() { state.selectedEmotion = null; elements.emotionBtns.forEach(btn => btn.classList.remove('active')); } function getPlainText() { // Remove emotion tags completely - don't include any emotion labels in the text const clone = elements.editor.cloneNode(true); // Completely remove emotion tags const tags = clone.querySelectorAll('.emotion-tag'); tags.forEach(tag => tag.remove()); return clone.innerText.trim(); } // 情绪标签映射:前端显示 → MiniMax标签 / CosyVoice指令 const EMOTION_LABELS = { 'happy': { miniMax: '[开心]', cosyvoice: '请用开心的语气' }, 'excited': { miniMax: '[激昂]', cosyvoice: '请用激昂的语气' }, 'surprised': { miniMax: '[惊讶]', cosyvoice: '请用惊讶的语气' }, 'sad': { miniMax: '[悲伤]', cosyvoice: '请用悲伤的语气' }, 'fearful': { miniMax: '[恐惧]', cosyvoice: '请用恐惧的语气' }, 'disgusted': { miniMax: '[厌恶]', cosyvoice: '请用厌恶的语气' } }; function getTextWithEmotionTags(engine) { const clone = elements.editor.cloneNode(true); if (engine === 'minimax') { const tags = clone.querySelectorAll('.emotion-tag'); tags.forEach(tag => { const emotion = tag.getAttribute('data-emotion'); const label = EMOTION_LABELS[emotion]?.miniMax || `[${emotion}]`; const textNode = document.createTextNode(label); tag.parentNode.replaceChild(textNode, tag); }); return clone.innerText.trim(); } else { const tags = clone.querySelectorAll('.emotion-tag'); tags.forEach(tag => tag.remove()); return clone.innerText.trim(); } } function getFirstEmotionInstruction() { const firstEmotionTag = elements.editor.querySelector('.emotion-tag'); if (firstEmotionTag) { const emotion = firstEmotionTag.getAttribute('data-emotion'); return EMOTION_LABELS[emotion]?.cosyvoice || null; } return null; } function clearEditor() { elements.editor.innerHTML = ''; updateCharCount(); showToast('已清空文稿'); } function updateSliderDisplay() { elements.speedValue.textContent = `${elements.speedSlider.value}x`; elements.pitchValue.textContent = elements.pitchSlider.value; elements.volumeValue.textContent = elements.volumeSlider.value; } function switchEngine(engine) { state.currentEngine = engine; elements.switchBtns.forEach(btn => { btn.classList.toggle('active', btn.dataset.engine === engine); }); const engineName = engine === 'minimax' ? 'MiniMax' : 'CosyVoice'; document.getElementById('engineBadge').textContent = `蓝皓 · ${engineName}`; showToast(`已切换到${engineName}引擎`); } async function generateAudio() { const text = getPlainText(); if (!text) { showToast('请输入配音文稿', 'error'); return; } showLoading('正在生成音频...'); elements.generateBtn.disabled = true; try { // 根据引擎获取带情绪标签的文本 const textForApi = getTextWithEmotionTags(state.currentEngine); // 获取CosyVoice的情绪指令 const emotionInstruction = getFirstEmotionInstruction(); const requestData = { text: textForApi, engine: state.currentEngine, speed: parseFloat(elements.speedSlider.value), pitch: parseInt(elements.pitchSlider.value), volume: parseFloat(elements.volumeSlider.value) }; // CosyVoice使用instruction参数传递情绪 if (state.currentEngine === 'cosyvoice' && emotionInstruction) { requestData.instruction = emotionInstruction; } const response = await fetch('/api/synthesize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestData) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || '生成失败'); } const data = await response.json(); if (data.audio) { const audioData = atob(data.audio); const audioArray = new Uint8Array(audioData.length); for (let i = 0; i < audioData.length; i++) { audioArray[i] = audioData.charCodeAt(i); } const audioBlob = new Blob([audioArray], { type: 'audio/mp3' }); const audioUrl = URL.createObjectURL(audioBlob); state.generatedAudio = { url: audioUrl, blob: audioBlob }; elements.audioDisplay.classList.add('has-audio'); elements.audioDisplay.innerHTML = `

音频已生成,点击播放

`; elements.audioControls.style.display = 'flex'; elements.audioInfo.style.display = 'block'; elements.infoEngine.textContent = state.currentEngine === 'minimax' ? 'MiniMax' : 'CosyVoice'; audioElement.src = audioUrl; addToHistory(text); showToast('音频生成成功!', 'success'); } } catch (error) { console.error('TTS Error:', error); showToast(error.message || '生成失败,请重试', 'error'); } finally { hideLoading(); elements.generateBtn.disabled = false; } } function addToHistory(text) { const item = { id: Date.now(), text: text.substring(0, 50) + (text.length > 50 ? '...' : ''), engine: state.currentEngine, timestamp: new Date().toLocaleString('zh-CN') }; state.history.unshift(item); if (state.history.length > 10) state.history.pop(); renderHistory(); localStorage.setItem('tts_history', JSON.stringify(state.history)); } function renderHistory() { if (state.history.length === 0) { elements.historyList.innerHTML = '

暂无生成记录

'; return; } elements.historyList.innerHTML = state.history.map(item => `
${item.text}
`).join(''); document.querySelectorAll('.history-item').forEach(el => { el.addEventListener('click', () => { const id = parseInt(el.dataset.id); const item = state.history.find(h => h.id === id); if (item) { elements.editor.innerText = item.text; updateCharCount(); } }); }); } function togglePlay() { if (!state.generatedAudio) return; if (state.isPlaying) { audioElement.pause(); elements.playBtn.querySelector('.icon-play').style.display = 'block'; elements.playBtn.querySelector('.icon-pause').style.display = 'none'; } else { audioElement.play(); elements.playBtn.querySelector('.icon-play').style.display = 'none'; elements.playBtn.querySelector('.icon-pause').style.display = 'block'; } state.isPlaying = !state.isPlaying; } function downloadAudio() { if (!state.generatedAudio) return; // 文件命名规则:节目名_引擎名_日期_随机数.mp3 const engineName = state.currentEngine === 'minimax' ? 'MiniMax' : 'CosyVoice'; const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); const randomNum = Math.floor(Math.random() * 10000).toString().padStart(4, '0'); const fileName = `军事科技_${engineName}_${date}_${randomNum}.mp3`; const link = document.createElement('a'); link.href = state.generatedAudio.url; link.download = fileName; link.click(); showToast('开始下载', 'success'); } elements.editor.addEventListener('input', updateCharCount); elements.editor.addEventListener('paste', () => { setTimeout(updateCharCount, 0); }); elements.switchBtns.forEach(btn => { btn.addEventListener('click', () => switchEngine(btn.dataset.engine)); }); elements.emotionBtns.forEach(btn => { btn.addEventListener('click', () => { const emotion = btn.dataset.emotion; const emotionName = CONFIG.EMOTIONS[emotion]?.name || emotion; if (state.selectedEmotion === emotion) { clearEmotionSelection(); } else { elements.emotionBtns.forEach(b => b.classList.remove('active')); btn.classList.add('active'); state.selectedEmotion = emotion; applyEmotionTag(emotion, emotionName); } }); }); elements.speedSlider.addEventListener('input', updateSliderDisplay); elements.pitchSlider.addEventListener('input', updateSliderDisplay); elements.volumeSlider.addEventListener('input', updateSliderDisplay); elements.generateBtn.addEventListener('click', generateAudio); elements.clearBtn.addEventListener('click', clearEditor); elements.playBtn.addEventListener('click', togglePlay); elements.downloadBtn.addEventListener('click', downloadAudio); audioElement.addEventListener('timeupdate', () => { const progress = (audioElement.currentTime / audioElement.duration) * 100; elements.progressFill.style.width = `${progress}%`; elements.timeDisplay.textContent = `${formatTime(audioElement.currentTime)} / ${formatTime(audioElement.duration || 0)}`; }); audioElement.addEventListener('ended', () => { state.isPlaying = false; elements.playBtn.querySelector('.icon-play').style.display = 'block'; elements.playBtn.querySelector('.icon-pause').style.display = 'none'; elements.progressFill.style.width = '0%'; }); elements.audioDisplay.addEventListener('click', () => { if (state.generatedAudio && !state.isPlaying) { togglePlay(); } }); function init() { updateCharCount(); updateSliderDisplay(); const savedHistory = localStorage.getItem('tts_history'); if (savedHistory) { try { state.history = JSON.parse(savedHistory); renderHistory(); } catch (e) {} } console.log('军事科技 AI配音系统已初始化'); } init();