feat: enhance emotion handling in audiobook segments and UI for multi-emotion selection
This commit is contained in:
@@ -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] = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user