Initial commit: 军事科技AI配音系统 v1.0

- Flask backend with MiniMax and CosyVoice TTS engines
- Frontend UI with emotion tags, speed/pitch/volume controls
- Nginx reverse proxy configuration
- Dual engine support (MiniMax speech-2.8-hd, CosyVoice v3.5-flash)
This commit is contained in:
2026-06-30 21:00:20 +08:00
commit d6edc69c65
17 changed files with 1551 additions and 0 deletions
+383
View File
@@ -0,0 +1,383 @@
/**
* 军事科技 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 = `<span class="emotion-tag" data-emotion="${emotion}" contenteditable="false">[${emotionName}]</span>`;
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 = `
<div class="audio-ready">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
<p>音频已生成,点击播放</p>
</div>`;
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 = '<div class="history-empty"><p>暂无生成记录</p></div>';
return;
}
elements.historyList.innerHTML = state.history.map(item => `
<div class="history-item" data-id="${item.id}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5,3 19,12 5,21"/>
</svg>
<span>${item.text}</span>
</div>`).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();