From 81460f06d70a9a4d93b4bf49375a0f76b06bf98b Mon Sep 17 00:00:00 2001 From: lanhao Date: Tue, 30 Jun 2026 23:47:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AD=97=E6=95=B0=E9=99=90=E5=88=B6200?= =?UTF-8?q?0=20+=20MiniMax=20emotion=E5=8F=82=E6=95=B0=20+=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=83=85=E7=BB=AA=E5=85=A8=E5=B1=80=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routes/tts_synthesize.py | 8 +- backend/app/services/minimax_service.py | 3 +- frontend/app.js | 144 ++++++++---------------- frontend/index.html | 6 +- 4 files changed, 57 insertions(+), 104 deletions(-) diff --git a/backend/app/routes/tts_synthesize.py b/backend/app/routes/tts_synthesize.py index 081a6c0..fff4d86 100644 --- a/backend/app/routes/tts_synthesize.py +++ b/backend/app/routes/tts_synthesize.py @@ -25,12 +25,13 @@ def synthesize(): pitch = data.get('pitch', 0) volume = data.get('volume', 1.0) instruction = data.get('instruction') # 用于CosyVoice的情绪指令 + emotion = data.get('emotion', 'neutral') # 用于MiniMax的情绪参数 if not text.strip(): return jsonify({'error': '文本不能为空'}), 400 - if len(text) > 500: - return jsonify({'error': '文本不能超过500字'}), 400 + if len(text) > 2000: + return jsonify({'error': '文本不能超过2000字'}), 400 try: if engine == 'cosyvoice': @@ -46,7 +47,8 @@ def synthesize(): text=text, speed=speed, pitch=pitch, - volume=volume + volume=volume, + emotion=emotion ) return jsonify({ diff --git a/backend/app/services/minimax_service.py b/backend/app/services/minimax_service.py index 00225de..4f83cd2 100644 --- a/backend/app/services/minimax_service.py +++ b/backend/app/services/minimax_service.py @@ -3,7 +3,7 @@ import base64 import requests 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""" api_key = os.getenv('MINIMAX_API_KEY', '') if not api_key: @@ -27,6 +27,7 @@ def synthesize_minimax(text, speed=1.0, pitch=0, volume=1.0): 'speed': speed, 'vol': volume_int, 'pitch': pitch, + 'emotion': emotion, }, 'audio_setting': { 'audio_sample_rate': 32000, diff --git a/frontend/app.js b/frontend/app.js index 724e03a..f7a415c 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -5,18 +5,21 @@ 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' } - } +}; + +// 情绪按钮与 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, @@ -90,85 +93,40 @@ function getSelectedText() { return window.getSelection().toString().trim(); } -function applyEmotionTag(emotion, emotionName) { - const selection = window.getSelection(); - if (!selection.rangeCount) { - showToast('请先选中要添加情绪的文字', 'error'); - return; +/** + * 切换情绪状态(全局选择模式) + * 点击一个 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'); } - const selectedText = selection.toString().trim(); - if (!selectedText) { - showToast('请先选中要添加情绪的文字', 'error'); - return; - } - const tagHtml = `[${emotionName}]`; - 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; + state.currentEmotion = 'neutral'; 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; + // 直接返回纯文本,不再处理情绪标签 + return elements.editor.innerText.trim(); } function clearEditor() { @@ -202,23 +160,23 @@ async function generateAudio() { showLoading('正在生成音频...'); elements.generateBtn.disabled = true; try { - // 根据引擎获取带情绪标签的文本 - const textForApi = getTextWithEmotionTags(state.currentEngine); - // 获取CosyVoice的情绪指令 - const emotionInstruction = getFirstEmotionInstruction(); - const requestData = { - text: textForApi, + text: text, engine: state.currentEngine, speed: parseFloat(elements.speedSlider.value), pitch: parseInt(elements.pitchSlider.value), - volume: parseFloat(elements.volumeSlider.value) + volume: parseFloat(elements.volumeSlider.value), + emotion: state.currentEmotion }; - + // CosyVoice使用instruction参数传递情绪 - if (state.currentEngine === 'cosyvoice' && emotionInstruction) { - requestData.instruction = emotionInstruction; + 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' }, @@ -338,15 +296,7 @@ elements.switchBtns.forEach(btn => { 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); - } + applyEmotionTag(emotion); }); }); elements.speedSlider.addEventListener('input', updateSliderDisplay); diff --git a/frontend/index.html b/frontend/index.html index 904b607..0e7a0e5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -50,11 +50,11 @@ 情绪: - + - ← 选中文字后点击添加情绪 + ← 选择整段语音的情绪风格 @@ -63,7 +63,7 @@
请输入配音文稿...

- 💡 提示:选中文字后点击上方情绪标签,可为选中文本添加情绪 + 💡 提示:点击上方情绪按钮选择整段语音的情绪风格,生成时自动应用