feat: enhance emotion handling in audiobook segments and UI for multi-emotion selection

This commit is contained in:
2026-03-13 14:39:51 +08:00
parent 16947d6b8b
commit 161e7fa76d
4 changed files with 37 additions and 23 deletions

View File

@@ -16,7 +16,7 @@ from db.models import AudiobookProject, AudiobookCharacter, User
logger = logging.getLogger(__name__)
_LINE_RE = re.compile(r'^【(.+?)】(.*)$')
_EMO_RE = re.compile(r'(开心|愤怒|悲伤|恐惧|厌恶|低沉|惊讶):([0-9.]+)\s*$')
_EMO_RE = re.compile(r'([^:]+):([0-9.]+)\s*$')
# Cancellation events for batch operations, keyed by project_id
_cancel_events: dict[int, asyncio.Event] = {}

View File

@@ -378,6 +378,8 @@ class LLMService:
" 【角色名】\"对话内容\"(情感词:强度)\n\n"
"情感标注规则:\n"
"- 情感词可选:开心、愤怒、悲伤、恐惧、厌恶、低沉、惊讶\n"
"- 可用 + 拼接多个情感词表达复杂情绪,如(开心+悲伤:0.4)、(愤怒+恐惧:0.5\n"
"- 多情感时强度为混合情感的整体强度,每种情感对合成结果均有贡献\n"
f"- 各情感强度上限(严格不超过):{limits_str}\n"
"- 情感不明显时可省略(情感词:强度)整个括号\n"
+ narrator_rule
@@ -453,12 +455,14 @@ class LLMService:
"所有非对话的叙述文字归属于旁白角色。\n"
"同时根据语境为每个片段判断是否有明显情绪有则设置情绪类型emo_text和强度emo_alpha无则留空。\n"
"可选情绪:开心、愤怒、悲伤、恐惧、厌恶、低沉、惊讶。\n"
"- emo_text 可用 + 拼接多个情感词(如 \"开心+悲伤\"),表达复杂混合情绪\n"
"情绪不明显或旁白时emo_text设为\"\"emo_alpha设为0。\n"
"各情绪强度上限(严格不超过):开心=0.35、愤怒=0.15、悲伤=0.1、恐惧=0.1、厌恶=0.35、低沉=0.35、惊讶=0.1。\n"
"同一角色的连续台词,情绪应尽量保持一致或仅有微弱变化,避免相邻片段间情绪跳跃。\n"
"只输出JSON数组不要有其他文字格式如下\n"
'[{"character": "旁白", "text": "叙述文字", "emo_text": "", "emo_alpha": 0}, '
'{"character": "角色名", "text": "对话内容", "emo_text": "开心", "emo_alpha": 0.3}, ...]'
'{"character": "角色名", "text": "对话内容", "emo_text": "开心", "emo_alpha": 0.3}, '
'{"character": "角色名", "text": "带泪的笑", "emo_text": "开心+悲伤", "emo_alpha": 0.4}]'
)
user_message = f"请解析以下章节文本:\n\n{chapter_text}"
result = await self.stream_chat_json(system_prompt, user_message, on_token, max_tokens=16384, usage_callback=usage_callback)

View File

@@ -192,7 +192,7 @@ class AudiobookSegment(Base):
segment_index = Column(Integer, nullable=False)
character_id = Column(Integer, ForeignKey("audiobook_characters.id"), nullable=False)
text = Column(Text, nullable=False)
emo_text = Column(String(20), nullable=True)
emo_text = Column(String(100), nullable=True)
emo_alpha = Column(Float, nullable=True)
audio_path = Column(String(500), nullable=True)
status = Column(String(20), default="pending", nullable=False)

View File

@@ -1381,9 +1381,7 @@ function CharactersPanel({
}
const EMOTION_OPTIONS = ['开心', '愤怒', '悲伤', '恐惧', '厌恶', '低沉', '惊讶', '中性']
const EMOTION_ALPHA_DEFAULTS: Record<string, number> = {
开心: 0.6, 愤怒: 0.15, 悲伤: 0.4, 恐惧: 0.4, 厌恶: 0.6, 低沉: 0.6, 惊讶: 0.3, 中性: 0.5,
}
function ChaptersPanel({
project,
@@ -1674,8 +1672,10 @@ function ChaptersPanel({
{seg.character_name || t('projectCard.segments.unknownCharacter')}
</Badge>
{!isEditing && seg.emo_text && (
<span className="text-[11px] text-muted-foreground shrink-0">
{seg.emo_text}
<span className="text-[11px] text-muted-foreground shrink-0 flex items-center gap-0.5 flex-wrap">
{seg.emo_text.split('+').map(e => (
<span key={e} className="bg-muted rounded px-1">{e.trim()}</span>
))}
{seg.emo_alpha != null && (
<span className="opacity-60 ml-0.5">{seg.emo_alpha.toFixed(2)}</span>
)}
@@ -1718,22 +1718,32 @@ function ChaptersPanel({
className="text-sm min-h-[60px] resize-y"
rows={3}
/>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.emotion')}:</span>
<select
value={editEmoText}
onChange={e => {
const v = e.target.value
setEditEmoText(v)
if (v && EMOTION_ALPHA_DEFAULTS[v]) setEditEmoAlpha(EMOTION_ALPHA_DEFAULTS[v])
}}
className="text-xs h-6 rounded border border-input bg-background px-1 focus:outline-none"
>
<option value="">{t('projectCard.segments.noEmotion')}</option>
{EMOTION_OPTIONS.map(e => <option key={e} value={e}>{e}</option>)}
</select>
<div className="space-y-1.5">
<div className="flex items-center gap-1 flex-wrap">
<span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.emotion')}:</span>
{EMOTION_OPTIONS.map(emo => {
const selectedEmos = editEmoText.split('+').filter(Boolean)
const isSelected = selectedEmos.includes(emo)
return (
<button
key={emo}
type="button"
className={`px-2 py-0.5 rounded text-xs border transition-colors ${isSelected ? "bg-primary text-primary-foreground border-primary" : "bg-muted text-muted-foreground border-transparent"}`}
onClick={() => {
const current = editEmoText.split('+').filter(Boolean)
const next = isSelected
? current.filter(e => e !== emo)
: [...current, emo]
setEditEmoText(next.join('+'))
}}
>
{emo}
</button>
)
})}
</div>
{editEmoText && (
<div className="flex items-center gap-1.5 flex-1 min-w-[120px]">
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.intensity')}:</span>
<input
type="range"