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:
+383
@@ -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();
|
||||
Reference in New Issue
Block a user