feat: 字数限制2000 + MiniMax emotion参数 + 前端情绪全局选择模式
This commit is contained in:
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+47
-97
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user