2026-06-30 21:00:20 +08:00
|
|
|
/**
|
|
|
|
|
* 军事科技 AI配音系统 - 前端脚本
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const CONFIG = {
|
2026-07-01 00:48:05 +08:00
|
|
|
API_BASE_URL: '',
|
2026-06-30 21:00:20 +08:00
|
|
|
MAX_CHARS: 2000,
|
2026-06-30 23:47:23 +08:00
|
|
|
};
|
|
|
|
|
|
2026-07-01 00:48:05 +08:00
|
|
|
const ENGINE_CONFIG = {
|
|
|
|
|
minimax: {
|
|
|
|
|
name: 'MiniMax',
|
|
|
|
|
emotionMode: 'global',
|
|
|
|
|
color: '#00d4aa'
|
|
|
|
|
},
|
|
|
|
|
cosyvoice: {
|
|
|
|
|
name: 'CosyVoice',
|
|
|
|
|
emotionMode: 'global',
|
|
|
|
|
color: '#ff6b6b'
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-30 23:47:23 +08:00
|
|
|
// 情绪按钮与 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' },
|
2026-06-30 21:00:20 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const state = {
|
|
|
|
|
currentEngine: 'minimax',
|
2026-06-30 23:47:23 +08:00
|
|
|
currentEmotion: 'neutral',
|
2026-06-30 21:00:20 +08:00
|
|
|
selectedEmotion: null,
|
|
|
|
|
generatedAudio: null,
|
|
|
|
|
isPlaying: false,
|
|
|
|
|
history: []
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const elements = {
|
|
|
|
|
editor: document.getElementById('editor'),
|
|
|
|
|
editorPlaceholder: document.getElementById('editorPlaceholder'),
|
|
|
|
|
charCount: document.getElementById('charCount'),
|
2026-07-01 00:48:05 +08:00
|
|
|
engineSelect: document.getElementById('engine-select'),
|
2026-06-30 21:00:20 +08:00
|
|
|
emotionBtns: document.querySelectorAll('.emotion-btn'),
|
2026-07-01 00:48:05 +08:00
|
|
|
emotionGlobal: document.getElementById('emotion-global'),
|
|
|
|
|
emotionPerSentence: document.getElementById('emotion-per-sentence'),
|
2026-06-30 21:00:20 +08:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 23:47:23 +08:00
|
|
|
/**
|
|
|
|
|
* 切换情绪状态(全局选择模式)
|
|
|
|
|
* 点击一个 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');
|
2026-06-30 21:00:20 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearEmotionSelection() {
|
|
|
|
|
state.selectedEmotion = null;
|
2026-06-30 23:47:23 +08:00
|
|
|
state.currentEmotion = 'neutral';
|
2026-06-30 21:00:20 +08:00
|
|
|
elements.emotionBtns.forEach(btn => btn.classList.remove('active'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getPlainText() {
|
2026-06-30 23:47:23 +08:00
|
|
|
// 直接返回纯文本,不再处理情绪标签
|
|
|
|
|
return elements.editor.innerText.trim();
|
2026-06-30 21:00:20 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-07-01 00:48:05 +08:00
|
|
|
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';
|
|
|
|
|
}
|
2026-06-30 21:00:20 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function generateAudio() {
|
|
|
|
|
const text = getPlainText();
|
|
|
|
|
if (!text) {
|
|
|
|
|
showToast('请输入配音文稿', 'error');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
showLoading('正在生成音频...');
|
|
|
|
|
elements.generateBtn.disabled = true;
|
|
|
|
|
try {
|
|
|
|
|
const requestData = {
|
2026-06-30 23:47:23 +08:00
|
|
|
text: text,
|
2026-06-30 21:00:20 +08:00
|
|
|
engine: state.currentEngine,
|
|
|
|
|
speed: parseFloat(elements.speedSlider.value),
|
|
|
|
|
pitch: parseInt(elements.pitchSlider.value),
|
2026-06-30 23:47:23 +08:00
|
|
|
volume: parseFloat(elements.volumeSlider.value),
|
|
|
|
|
emotion: state.currentEmotion
|
2026-06-30 21:00:20 +08:00
|
|
|
};
|
2026-06-30 23:47:23 +08:00
|
|
|
|
2026-06-30 21:00:20 +08:00
|
|
|
// CosyVoice使用instruction参数传递情绪
|
2026-06-30 23:47:23 +08:00
|
|
|
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}的语气`;
|
|
|
|
|
}
|
2026-06-30 21:00:20 +08:00
|
|
|
}
|
2026-06-30 23:47:23 +08:00
|
|
|
|
2026-06-30 21:00:20 +08:00
|
|
|
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); });
|
2026-07-01 00:48:05 +08:00
|
|
|
elements.engineSelect.addEventListener('change', (e) => {
|
|
|
|
|
switchEngine(e.target.value);
|
2026-06-30 21:00:20 +08:00
|
|
|
});
|
|
|
|
|
elements.emotionBtns.forEach(btn => {
|
|
|
|
|
btn.addEventListener('click', () => {
|
|
|
|
|
const emotion = btn.dataset.emotion;
|
2026-06-30 23:47:23 +08:00
|
|
|
applyEmotionTag(emotion);
|
2026-06-30 21:00:20 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
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();
|