Files
military-tech-voice3.0/frontend/app.js
T

370 lines
13 KiB
JavaScript
Raw Normal View History

/**
* 军事科技 AI配音系统 - 前端脚本
*/
const CONFIG = {
API_BASE_URL: '',
MAX_CHARS: 2000,
};
const ENGINE_CONFIG = {
minimax: {
name: 'MiniMax',
emotionMode: 'global',
color: '#00d4aa'
},
cosyvoice: {
name: 'CosyVoice',
emotionMode: 'global',
color: '#ff6b6b'
}
};
// 情绪按钮与 API emotion 值的映射
const EMOTION_MAP = {
'happy': { emoji: '😊', label: '开心', apiValue: 'happy' },
'sad': { emoji: '😢', label: '悲伤', apiValue: 'sad' },
'angry': { emoji: '🔥', label: '激昂', apiValue: 'angry' },
'surprised': { emoji: '❗', label: '惊讶', apiValue: 'surprised' },
'fearful': { emoji: '😨', label: '紧张', apiValue: 'fearful' },
'disgusted': { emoji: '😒', label: '厌恶', apiValue: 'disgusted' },
};
const state = {
currentEngine: 'minimax',
currentEmotion: 'neutral',
selectedEmotion: null,
generatedAudio: null,
isPlaying: false,
history: []
};
const elements = {
editor: document.getElementById('editor'),
editorPlaceholder: document.getElementById('editorPlaceholder'),
charCount: document.getElementById('charCount'),
engineSelect: document.getElementById('engine-select'),
emotionBtns: document.querySelectorAll('.emotion-btn'),
emotionGlobal: document.getElementById('emotion-global'),
emotionPerSentence: document.getElementById('emotion-per-sentence'),
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();
}
/**
* 切换情绪状态(全局选择模式)
* 点击一个 emoji 按钮设置当前情绪,再点取消;同时只能选一个
*/
function applyEmotionTag(emotion) {
const emotionInfo = EMOTION_MAP[emotion];
if (!emotionInfo) return;
if (state.currentEmotion === emotionInfo.apiValue) {
// 再次点击同一情绪,取消选择
state.currentEmotion = 'neutral';
state.selectedEmotion = null;
elements.emotionBtns.forEach(btn => btn.classList.remove('active'));
showToast('已取消情绪选择');
} else {
// 选择新情绪
state.currentEmotion = emotionInfo.apiValue;
state.selectedEmotion = emotion;
elements.emotionBtns.forEach(btn => {
btn.classList.toggle('active', btn.dataset.emotion === emotion);
});
showToast(`已选择「${emotionInfo.label}」情绪风格`, 'success');
}
}
function clearEmotionSelection() {
state.selectedEmotion = null;
state.currentEmotion = 'neutral';
elements.emotionBtns.forEach(btn => btn.classList.remove('active'));
}
function getPlainText() {
// 直接返回纯文本,不再处理情绪标签
return elements.editor.innerText.trim();
}
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;
const config = ENGINE_CONFIG[engine];
if (!config) return;
// Update engine badge
const badge = document.getElementById('engineBadge');
badge.textContent = `蓝皓 · ${config.name}`;
badge.style.background = config.color;
// Update engine select value
elements.engineSelect.value = engine;
// Switch emotion UI mode
updateEmotionMode(config.emotionMode);
// Reset emotion state
clearEmotionSelection();
showToast(`已切换到${config.name}引擎`);
}
function updateEmotionMode(mode) {
if (mode === 'global') {
elements.emotionGlobal.style.display = 'flex';
elements.emotionPerSentence.style.display = 'none';
} else if (mode === 'perSentence') {
elements.emotionGlobal.style.display = 'none';
elements.emotionPerSentence.style.display = 'block';
}
}
async function generateAudio() {
const text = getPlainText();
if (!text) {
showToast('请输入配音文稿', 'error');
return;
}
showLoading('正在生成音频...');
elements.generateBtn.disabled = true;
try {
const requestData = {
text: text,
engine: state.currentEngine,
speed: parseFloat(elements.speedSlider.value),
pitch: parseInt(elements.pitchSlider.value),
volume: parseFloat(elements.volumeSlider.value),
emotion: state.currentEmotion
};
// CosyVoice使用instruction参数传递情绪
if (state.currentEngine === 'cosyvoice' && state.currentEmotion !== 'neutral') {
const emotionInfo = Object.values(EMOTION_MAP).find(e => e.apiValue === state.currentEmotion);
if (emotionInfo) {
requestData.instruction = `请用${emotionInfo.label}的语气`;
}
}
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 = ENGINE_CONFIG[state.currentEngine].name;
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 = ENGINE_CONFIG[state.currentEngine].name;
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.engineSelect.addEventListener('change', (e) => {
switchEngine(e.target.value);
});
elements.emotionBtns.forEach(btn => {
btn.addEventListener('click', () => {
const emotion = btn.dataset.emotion;
applyEmotionTag(emotion);
});
});
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();