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();
|
||||
@@ -0,0 +1,186 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>军事科技 - AI智能配音</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<svg class="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polygon points="10,8 16,12 10,16"/>
|
||||
</svg>
|
||||
<span class="logo-text">军事科技 <span class="logo-sub">AI配音</span></span>
|
||||
</div>
|
||||
<div class="header-info">
|
||||
<span class="engine-badge" id="engineBadge">蓝皓</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Center Panel -->
|
||||
<section class="panel panel-center">
|
||||
<div class="panel-header">
|
||||
<h2>配音文稿</h2>
|
||||
<div class="char-count">
|
||||
<span id="charCount">0</span> / 2000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-group">
|
||||
<span class="toolbar-label">引擎:</span>
|
||||
<button class="switch-btn active" data-engine="minimax">
|
||||
<span class="engine-icon minimax-icon">M</span>
|
||||
MiniMax
|
||||
</button>
|
||||
<button class="switch-btn" data-engine="cosyvoice">
|
||||
<span class="engine-icon cosyvoice-icon">C</span>
|
||||
CosyVoice
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar-group emotion-group">
|
||||
<span class="toolbar-label">情绪:</span>
|
||||
<button class="emotion-btn" data-emotion="happy" title="开心愉快">😊</button>
|
||||
<button class="emotion-btn" data-emotion="sad" title="悲伤">😢</button>
|
||||
<button class="emotion-btn" data-emotion="excited" title="激动">🔥</button>
|
||||
<button class="emotion-btn" data-emotion="surprised" title="惊讶">❗</button>
|
||||
<button class="emotion-btn" data-emotion="fearful" title="害怕">😨</button>
|
||||
<button class="emotion-btn" data-emotion="disgusted" title="厌恶">😒</button>
|
||||
<span class="toolbar-hint">← 选中文字后点击添加情绪</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="editor-wrapper">
|
||||
<div class="editor" id="editor" contenteditable="true" spellcheck="false"></div>
|
||||
<div class="editor-placeholder" id="editorPlaceholder">
|
||||
请输入配音文稿...<br><br>
|
||||
<span class="hint">💡 提示:选中文字后点击上方情绪标签,可为选中文本添加情绪</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parameter Controls -->
|
||||
<div class="param-controls">
|
||||
<div class="param-group">
|
||||
<label>语速</label>
|
||||
<input type="range" id="speedSlider" min="0.5" max="2.0" step="0.1" value="1.0">
|
||||
<span class="param-value" id="speedValue">1.0x</span>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<label>声调</label>
|
||||
<input type="range" id="pitchSlider" min="-12" max="12" step="1" value="0">
|
||||
<span class="param-value" id="pitchValue">0</span>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<label>音量</label>
|
||||
<input type="range" id="volumeSlider" min="0" max="10" step="0.1" value="1.0">
|
||||
<span class="param-value" id="volumeValue">1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-primary" id="generateBtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5,3 19,12 5,21"/>
|
||||
</svg>
|
||||
生成配音
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="clearBtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
||||
</svg>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Right Panel -->
|
||||
<aside class="panel panel-right">
|
||||
<div class="panel-header">
|
||||
<h2>音频预览</h2>
|
||||
</div>
|
||||
|
||||
<div class="audio-display" id="audioDisplay">
|
||||
<div class="audio-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M9 18V5l12-2v13"/>
|
||||
<circle cx="6" cy="18" r="3"/>
|
||||
<circle cx="18" cy="16" r="3"/>
|
||||
</svg>
|
||||
<p>生成音频后在此预览</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="audio-controls" id="audioControls" style="display: none;">
|
||||
<button class="control-btn play-btn" id="playBtn">
|
||||
<svg class="icon-play" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5,3 19,12 5,21"/>
|
||||
</svg>
|
||||
<svg class="icon-pause" viewBox="0 0 24 24" fill="currentColor" style="display: none;">
|
||||
<rect x="6" y="4" width="4" height="16"/>
|
||||
<rect x="14" y="4" width="4" height="16"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
<span class="time-display" id="timeDisplay">00:00 / 00:00</span>
|
||||
<button class="control-btn download-btn" id="downloadBtn" title="下载">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="audio-info" id="audioInfo" style="display: none;">
|
||||
<div class="info-row">
|
||||
<span class="info-label">引擎</span>
|
||||
<span class="info-value" id="infoEngine">MiniMax</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">采样率</span>
|
||||
<span class="info-value">32kHz</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">格式</span>
|
||||
<span class="info-value">MP3</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="history-section">
|
||||
<h3>历史记录</h3>
|
||||
<div class="history-list" id="historyList">
|
||||
<div class="history-empty">
|
||||
<p>暂无生成记录</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<div class="loading-overlay" id="loadingOverlay" style="display: none;">
|
||||
<div class="loading-spinner">
|
||||
<svg viewBox="0 0 50 50">
|
||||
<circle cx="25" cy="25" r="20" fill="none" stroke="currentColor" stroke-width="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p id="loadingText">正在生成音频...</p>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast">
|
||||
<span class="toast-message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,348 @@
|
||||
/* 军事科技 AI配音系统 - 样式表 */
|
||||
|
||||
:root {
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
--bg-tertiary: #1a1a25;
|
||||
--bg-card: #1e1e2a;
|
||||
--bg-card-hover: #252535;
|
||||
--accent-primary: #6366f1;
|
||||
--accent-secondary: #818cf8;
|
||||
--accent-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a1a1aa;
|
||||
--text-muted: #71717a;
|
||||
--border-color: #27272a;
|
||||
--border-hover: #3f3f46;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--emotion-happy: #fbbf24;
|
||||
--emotion-sad: #60a5fa;
|
||||
--emotion-excited: #f97316;
|
||||
--emotion-surprised: #a855f7;
|
||||
--emotion-fearful: #ec4899;
|
||||
--emotion-disgusted: #14b8a6;
|
||||
--minimax-color: #00d4aa;
|
||||
--cosyvoice-color: #ff6b6b;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--radius-full: 9999px;
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
--transition-fast: 0.15s ease;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.logo { display: flex; align-items: center; gap: 16px; }
|
||||
.logo-icon { width: 36px; height: 36px; color: var(--accent-primary); }
|
||||
.logo-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
background: var(--accent-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.logo-sub { font-size: 0.875rem; opacity: 0.8; margin-left: 8px; }
|
||||
|
||||
.engine-badge {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 350px;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.panel-header h2 { font-size: 1rem; font-weight: 600; }
|
||||
.char-count { font-size: 0.75rem; color: var(--text-muted); }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar-group { display: flex; align-items: center; gap: 8px; }
|
||||
.toolbar-label { font-size: 0.85rem; color: var(--text-secondary); }
|
||||
.toolbar-hint { font-size: 0.7rem; color: var(--text-muted); margin-left: 8px; }
|
||||
|
||||
.switch-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
.switch-btn:hover { border-color: var(--border-hover); color: var(--text-primary); }
|
||||
.switch-btn.active { background: var(--bg-card); border-color: var(--accent-primary); color: var(--text-primary); }
|
||||
|
||||
.engine-icon {
|
||||
width: 18px; height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.65rem; font-weight: 700;
|
||||
}
|
||||
.minimax-icon { background: var(--minimax-color); color: #0a0a0f; }
|
||||
.cosyvoice-icon { background: var(--cosyvoice-color); color: white; }
|
||||
|
||||
.emotion-btn {
|
||||
width: 32px; height: 32px;
|
||||
font-size: 1.1rem;
|
||||
background: transparent;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.emotion-btn:hover { transform: scale(1.15); border-color: var(--text-muted); }
|
||||
.emotion-btn.active { border-color: var(--accent-primary); background: rgba(99, 102, 241, 0.2); }
|
||||
|
||||
.editor-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%; height: 100%; min-height: 300px;
|
||||
padding: 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
line-height: 1.8;
|
||||
outline: none;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.editor:focus { border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); }
|
||||
|
||||
.editor-placeholder {
|
||||
position: absolute; top: 16px; left: 16px;
|
||||
color: var(--text-muted); font-size: 0.95rem;
|
||||
line-height: 1.6; pointer-events: none; display: none;
|
||||
}
|
||||
.editor:empty + .editor-placeholder, .editor.is-empty .editor-placeholder { display: block; }
|
||||
.editor-placeholder .hint { font-size: 0.8rem; opacity: 0.7; }
|
||||
|
||||
.emotion-tag {
|
||||
display: inline; padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem; font-weight: 600;
|
||||
vertical-align: middle; margin: 0 2px;
|
||||
}
|
||||
.emotion-tag[data-emotion="happy"] { background: rgba(251,191,36,0.2); color: var(--emotion-happy); }
|
||||
.emotion-tag[data-emotion="sad"] { background: rgba(96,165,250,0.2); color: var(--emotion-sad); }
|
||||
.emotion-tag[data-emotion="excited"] { background: rgba(249,115,22,0.2); color: var(--emotion-excited); }
|
||||
.emotion-tag[data-emotion="surprised"] { background: rgba(168,85,247,0.2); color: var(--emotion-surprised); }
|
||||
.emotion-tag[data-emotion="fearful"] { background: rgba(236,72,153,0.2); color: var(--emotion-fearful); }
|
||||
.emotion-tag[data-emotion="disgusted"] { background: rgba(20,184,166,0.2); color: var(--emotion-disgusted); }
|
||||
|
||||
.param-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.param-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.param-group label { font-size: 0.8rem; color: var(--text-secondary); }
|
||||
.param-group input[type="range"] {
|
||||
width: 100%; height: 6px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-full);
|
||||
outline: none; -webkit-appearance: none;
|
||||
}
|
||||
.param-group input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 16px; height: 16px;
|
||||
background: var(--accent-primary); border-radius: 50%; cursor: pointer;
|
||||
}
|
||||
.param-value { font-size: 0.85rem; font-weight: 600; text-align: center; }
|
||||
|
||||
.action-buttons { display: flex; gap: 16px; margin-top: auto; }
|
||||
.btn {
|
||||
flex: 1;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border: none; border-radius: var(--radius-md);
|
||||
font-size: 0.95rem; font-weight: 600;
|
||||
cursor: pointer; transition: var(--transition-fast);
|
||||
}
|
||||
.btn svg { width: 18px; height: 18px; }
|
||||
.btn-primary { background: var(--accent-gradient); color: white; }
|
||||
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 4px 20px rgba(99,102,241,0.4); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
.btn-secondary { background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border-color); }
|
||||
.btn-secondary:hover { background: var(--bg-card); color: var(--text-primary); }
|
||||
|
||||
.audio-display {
|
||||
background: var(--bg-tertiary);
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 32px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
min-height: 150px; margin-bottom: 16px;
|
||||
}
|
||||
.audio-display.has-audio { border-style: solid; border-color: var(--accent-primary); }
|
||||
.audio-placeholder svg { width: 48px; height: 48px; color: var(--text-muted); margin-bottom: 12px; }
|
||||
.audio-placeholder p { color: var(--text-muted); font-size: 0.85rem; }
|
||||
|
||||
.audio-controls {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 40px; height: 40px;
|
||||
background: var(--bg-card); border: none; border-radius: 50%;
|
||||
color: var(--text-primary); cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
.control-btn:hover { background: var(--accent-primary); }
|
||||
.control-btn svg { width: 16px; height: 16px; }
|
||||
.play-btn { width: 48px; height: 48px; background: var(--accent-gradient); }
|
||||
.play-btn:hover { transform: scale(1.1); }
|
||||
.progress-bar { flex: 1; height: 4px; background: var(--bg-card); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--accent-gradient); width: 0%; transition: width 0.1s linear; }
|
||||
.time-display { font-size: 0.75rem; color: var(--text-muted); min-width: 80px; text-align: center; }
|
||||
.download-btn:hover { background: var(--success); }
|
||||
|
||||
.audio-info {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.info-row {
|
||||
display: flex; justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.info-row:not(:last-child) { border-bottom: 1px solid var(--border-color); }
|
||||
.info-label { color: var(--text-muted); }
|
||||
.info-value { color: var(--text-primary); font-weight: 500; }
|
||||
|
||||
.history-section h3 {
|
||||
font-size: 0.9rem; font-weight: 600;
|
||||
margin-bottom: 12px; color: var(--text-secondary);
|
||||
}
|
||||
.history-list { flex: 1; overflow-y: auto; max-height: 180px; }
|
||||
.history-empty { text-align: center; padding: 16px; color: var(--text-muted); font-size: 0.85rem; }
|
||||
.history-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
.history-item:hover { background: var(--bg-card-hover); }
|
||||
.history-item svg { width: 14px; height: 14px; color: var(--text-muted); flex-shrink: 0; }
|
||||
.history-item span { flex: 1; font-size: 0.8rem; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(10,10,15,0.9);
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.loading-spinner { width: 60px; height: 60px; margin-bottom: 16px; }
|
||||
.loading-spinner svg { width: 100%; height: 100%; animation: spin 1s linear infinite; }
|
||||
.loading-spinner circle { stroke: var(--accent-primary); stroke-dasharray: 90,150; stroke-linecap: round; }
|
||||
@keyframes spin { 100% { transform: rotate(360deg); } }
|
||||
#loadingText { color: var(--text-secondary); font-size: 1rem; }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 32px; left: 50%;
|
||||
transform: translateX(-50%) translateY(100px);
|
||||
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md); padding: 12px 24px;
|
||||
box-shadow: var(--shadow-md); opacity: 0;
|
||||
transition: all 0.3s ease; z-index: 1001;
|
||||
}
|
||||
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||
.toast.success { border-color: var(--success); }
|
||||
.toast.error { border-color: var(--error); }
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.main-content { grid-template-columns: 1fr; }
|
||||
.panel-right { display: none; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.toolbar { flex-direction: column; gap: 12px; }
|
||||
.param-controls { grid-template-columns: 1fr; }
|
||||
}
|
||||
Reference in New Issue
Block a user