feat: 字数限制2000 + MiniMax emotion参数 + 前端情绪全局选择模式

This commit is contained in:
lanhao
2026-06-30 23:47:23 +08:00
parent d6edc69c65
commit 81460f06d7
4 changed files with 57 additions and 104 deletions
+5 -3
View File
@@ -25,12 +25,13 @@ def synthesize():
pitch = data.get('pitch', 0) pitch = data.get('pitch', 0)
volume = data.get('volume', 1.0) volume = data.get('volume', 1.0)
instruction = data.get('instruction') # 用于CosyVoice的情绪指令 instruction = data.get('instruction') # 用于CosyVoice的情绪指令
emotion = data.get('emotion', 'neutral') # 用于MiniMax的情绪参数
if not text.strip(): if not text.strip():
return jsonify({'error': '文本不能为空'}), 400 return jsonify({'error': '文本不能为空'}), 400
if len(text) > 500: if len(text) > 2000:
return jsonify({'error': '文本不能超过500字'}), 400 return jsonify({'error': '文本不能超过2000字'}), 400
try: try:
if engine == 'cosyvoice': if engine == 'cosyvoice':
@@ -46,7 +47,8 @@ def synthesize():
text=text, text=text,
speed=speed, speed=speed,
pitch=pitch, pitch=pitch,
volume=volume volume=volume,
emotion=emotion
) )
return jsonify({ return jsonify({
+2 -1
View File
@@ -3,7 +3,7 @@ import base64
import requests import requests
from io import BytesIO from io import BytesIO
def synthesize_minimax(text, speed=1.0, pitch=0, volume=1.0): def synthesize_minimax(text, speed=1.0, pitch=0, volume=1.0, emotion='neutral'):
"""调用MiniMax TTS API""" """调用MiniMax TTS API"""
api_key = os.getenv('MINIMAX_API_KEY', '') api_key = os.getenv('MINIMAX_API_KEY', '')
if not api_key: if not api_key:
@@ -27,6 +27,7 @@ def synthesize_minimax(text, speed=1.0, pitch=0, volume=1.0):
'speed': speed, 'speed': speed,
'vol': volume_int, 'vol': volume_int,
'pitch': pitch, 'pitch': pitch,
'emotion': emotion,
}, },
'audio_setting': { 'audio_setting': {
'audio_sample_rate': 32000, 'audio_sample_rate': 32000,
+46 -96
View File
@@ -5,18 +5,21 @@
const CONFIG = { const CONFIG = {
API_BASE_URL: '', // 空字符串表示使用相对路径,通过Nginx代理 API_BASE_URL: '', // 空字符串表示使用相对路径,通过Nginx代理
MAX_CHARS: 2000, MAX_CHARS: 2000,
EMOTIONS: { };
'happy': { name: '开心', color: '#fbbf24' },
'sad': { name: '悲伤', color: '#60a5fa' }, // 情绪按钮与 API emotion 值的映射
'excited': { name: '激动', color: '#f97316' }, const EMOTION_MAP = {
'surprised': { name: '惊讶', color: '#a855f7' }, 'happy': { emoji: '😊', label: '开心', apiValue: 'happy' },
'fearful': { name: '害怕', color: '#ec4899' }, 'sad': { emoji: '😢', label: '悲伤', apiValue: 'sad' },
'disgusted': { name: '厌恶', color: '#14b8a6' } 'angry': { emoji: '🔥', label: '激昂', apiValue: 'angry' },
} 'surprised': { emoji: '❗', label: '惊讶', apiValue: 'surprised' },
'fearful': { emoji: '😨', label: '紧张', apiValue: 'fearful' },
'disgusted': { emoji: '😒', label: '厌恶', apiValue: 'disgusted' },
}; };
const state = { const state = {
currentEngine: 'minimax', currentEngine: 'minimax',
currentEmotion: 'neutral',
selectedEmotion: null, selectedEmotion: null,
generatedAudio: null, generatedAudio: null,
isPlaying: false, isPlaying: false,
@@ -90,85 +93,40 @@ function getSelectedText() {
return window.getSelection().toString().trim(); return window.getSelection().toString().trim();
} }
function applyEmotionTag(emotion, emotionName) { /**
const selection = window.getSelection(); * 切换情绪状态(全局选择模式)
if (!selection.rangeCount) { * 点击一个 emoji 按钮设置当前情绪,再点取消;同时只能选一个
showToast('请先选中要添加情绪的文字', 'error'); */
return; 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');
} }
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() { function clearEmotionSelection() {
state.selectedEmotion = null; state.selectedEmotion = null;
state.currentEmotion = 'neutral';
elements.emotionBtns.forEach(btn => btn.classList.remove('active')); elements.emotionBtns.forEach(btn => btn.classList.remove('active'));
} }
function getPlainText() { function getPlainText() {
// Remove emotion tags completely - don't include any emotion labels in the text // 直接返回纯文本,不再处理情绪标签
const clone = elements.editor.cloneNode(true); return elements.editor.innerText.trim();
// 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() { function clearEditor() {
@@ -202,23 +160,23 @@ async function generateAudio() {
showLoading('正在生成音频...'); showLoading('正在生成音频...');
elements.generateBtn.disabled = true; elements.generateBtn.disabled = true;
try { try {
// 根据引擎获取带情绪标签的文本
const textForApi = getTextWithEmotionTags(state.currentEngine);
// 获取CosyVoice的情绪指令
const emotionInstruction = getFirstEmotionInstruction();
const requestData = { const requestData = {
text: textForApi, text: text,
engine: state.currentEngine, engine: state.currentEngine,
speed: parseFloat(elements.speedSlider.value), speed: parseFloat(elements.speedSlider.value),
pitch: parseInt(elements.pitchSlider.value), pitch: parseInt(elements.pitchSlider.value),
volume: parseFloat(elements.volumeSlider.value) volume: parseFloat(elements.volumeSlider.value),
emotion: state.currentEmotion
}; };
// CosyVoice使用instruction参数传递情绪 // CosyVoice使用instruction参数传递情绪
if (state.currentEngine === 'cosyvoice' && emotionInstruction) { if (state.currentEngine === 'cosyvoice' && state.currentEmotion !== 'neutral') {
requestData.instruction = emotionInstruction; const emotionInfo = Object.values(EMOTION_MAP).find(e => e.apiValue === state.currentEmotion);
if (emotionInfo) {
requestData.instruction = `请用${emotionInfo.label}的语气`;
}
} }
const response = await fetch('/api/synthesize', { const response = await fetch('/api/synthesize', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -338,15 +296,7 @@ elements.switchBtns.forEach(btn => {
elements.emotionBtns.forEach(btn => { elements.emotionBtns.forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const emotion = btn.dataset.emotion; const emotion = btn.dataset.emotion;
const emotionName = CONFIG.EMOTIONS[emotion]?.name || emotion; applyEmotionTag(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.speedSlider.addEventListener('input', updateSliderDisplay);
+3 -3
View File
@@ -50,11 +50,11 @@
<span class="toolbar-label">情绪:</span> <span class="toolbar-label">情绪:</span>
<button class="emotion-btn" data-emotion="happy" title="开心愉快">😊</button> <button class="emotion-btn" data-emotion="happy" title="开心愉快">😊</button>
<button class="emotion-btn" data-emotion="sad" 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="angry" title="激">🔥</button>
<button class="emotion-btn" data-emotion="surprised" 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="fearful" title="害怕">😨</button>
<button class="emotion-btn" data-emotion="disgusted" title="厌恶">😒</button> <button class="emotion-btn" data-emotion="disgusted" title="厌恶">😒</button>
<span class="toolbar-hint">← 选中文字后点击添加情绪</span> <span class="toolbar-hint">← 选择整段语音的情绪风格</span>
</div> </div>
</div> </div>
@@ -63,7 +63,7 @@
<div class="editor" id="editor" contenteditable="true" spellcheck="false"></div> <div class="editor" id="editor" contenteditable="true" spellcheck="false"></div>
<div class="editor-placeholder" id="editorPlaceholder"> <div class="editor-placeholder" id="editorPlaceholder">
请输入配音文稿...<br><br> 请输入配音文稿...<br><br>
<span class="hint">💡 提示:选中文字后点击上方情绪标签,可为选中文本添加情绪</span> <span class="hint">💡 提示:点击上方情绪按钮选择整段语音的情绪风格,生成时自动应用</span>
</div> </div>
</div> </div>