feat: Implement AI script generation for audiobook projects
This commit is contained in:
@@ -15,6 +15,9 @@ from db.models import AudiobookProject, AudiobookCharacter, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LINE_RE = re.compile(r'^【(.+?)】(.*)$')
|
||||
_EMO_RE = re.compile(r'((开心|愤怒|悲伤|恐惧|厌恶|低沉|惊讶):([0-9.]+))\s*$')
|
||||
|
||||
# Cancellation events for batch operations, keyed by project_id
|
||||
_cancel_events: dict[int, asyncio.Event] = {}
|
||||
|
||||
@@ -161,6 +164,340 @@ def _split_into_chapters(text: str) -> list[str]:
|
||||
return chapters
|
||||
|
||||
|
||||
def parse_ai_script(script_text: str, char_map: dict) -> list[dict]:
|
||||
results = []
|
||||
for raw_line in script_text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
m = _LINE_RE.match(line)
|
||||
if not m:
|
||||
if results:
|
||||
results[-1]["text"] = results[-1]["text"] + " " + line
|
||||
continue
|
||||
speaker = m.group(1).strip()
|
||||
content = m.group(2).strip()
|
||||
|
||||
emo_text = None
|
||||
emo_alpha = None
|
||||
emo_m = _EMO_RE.search(content)
|
||||
if emo_m:
|
||||
emo_text = emo_m.group(1)
|
||||
try:
|
||||
emo_alpha = float(emo_m.group(2))
|
||||
except ValueError:
|
||||
emo_alpha = None
|
||||
content = content[:emo_m.start()].strip()
|
||||
|
||||
if content.startswith('"') and content.endswith('"'):
|
||||
content = content[1:-1].strip()
|
||||
elif content.startswith('"') and content.endswith('"'):
|
||||
content = content[1:-1].strip()
|
||||
|
||||
character = speaker
|
||||
|
||||
results.append({
|
||||
"character": character,
|
||||
"text": content,
|
||||
"emo_text": emo_text,
|
||||
"emo_alpha": emo_alpha,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
async def generate_ai_script(project_id: int, user: User, db: Session) -> None:
|
||||
from core.database import SessionLocal
|
||||
|
||||
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
|
||||
if not project or not project.script_config:
|
||||
return
|
||||
|
||||
key = str(project_id)
|
||||
ps.reset(key)
|
||||
cfg = project.script_config
|
||||
|
||||
try:
|
||||
crud.update_audiobook_project_status(db, project_id, "analyzing")
|
||||
ps.append_line(key, f"[AI剧本] 项目「{project.title}」开始生成剧本")
|
||||
|
||||
llm = _get_llm_service(db)
|
||||
_llm_model = crud.get_system_setting(db, "llm_model")
|
||||
_user_id = user.id
|
||||
|
||||
def _log_usage(prompt_tokens: int, completion_tokens: int) -> None:
|
||||
log_db = SessionLocal()
|
||||
try:
|
||||
crud.create_usage_log(log_db, _user_id, prompt_tokens, completion_tokens,
|
||||
model=_llm_model, context="ai_script_generate")
|
||||
finally:
|
||||
log_db.close()
|
||||
|
||||
genre = cfg.get("genre", "")
|
||||
subgenre = cfg.get("subgenre", "")
|
||||
premise = cfg.get("premise", "")
|
||||
style = cfg.get("style", "")
|
||||
num_characters = cfg.get("num_characters", 5)
|
||||
num_chapters = cfg.get("num_chapters", 8)
|
||||
|
||||
ps.append_line(key, f"\n[Step 1] 生成 {num_characters} 个角色...\n")
|
||||
ps.append_line(key, "")
|
||||
|
||||
def on_token(token: str) -> None:
|
||||
ps.append_token(key, token)
|
||||
|
||||
characters_data = await llm.generate_story_characters(
|
||||
genre=genre, subgenre=subgenre, premise=premise, style=style,
|
||||
num_characters=num_characters, usage_callback=_log_usage,
|
||||
)
|
||||
|
||||
has_narrator = any(c.get("name") in ("narrator", "旁白") for c in characters_data)
|
||||
if not has_narrator:
|
||||
characters_data.insert(0, {
|
||||
"name": "旁白",
|
||||
"gender": "未知",
|
||||
"description": "第三人称旁白叙述者",
|
||||
"instruct": (
|
||||
"音色信息:浑厚醇厚的男性中低音,嗓音饱满有力,带有传统说书人的磁性与感染力\n"
|
||||
"身份背景:中国传统说书艺人,精通评书、章回小说叙述艺术,深谙故事节奏与听众心理\n"
|
||||
"年龄设定:中年男性,四五十岁,声音历经岁月沉淀,成熟稳重而不失活力\n"
|
||||
"外貌特征:面容沉稳,气度从容,台风大气,给人以可信赖的叙述者印象\n"
|
||||
"性格特质:沉稳睿智,叙事冷静客观,情到深处能引发共鸣,不动声色间娓娓道来\n"
|
||||
"叙事风格:语速适中偏慢,抑扬顿挫,擅长铺垫悬念,停顿恰到好处,语气庄重而生动,富有画面感"
|
||||
)
|
||||
})
|
||||
|
||||
ps.append_line(key, f"\n\n[完成] 角色列表:{', '.join(c.get('name', '') for c in characters_data)}")
|
||||
|
||||
crud.delete_audiobook_segments(db, project_id)
|
||||
crud.delete_audiobook_characters(db, project_id)
|
||||
|
||||
backend_type = user.user_preferences.get("default_backend", "aliyun") if user.user_preferences else "aliyun"
|
||||
|
||||
for char_data in characters_data:
|
||||
name = char_data.get("name", "旁白")
|
||||
if name == "narrator":
|
||||
name = "旁白"
|
||||
instruct = char_data.get("instruct", "")
|
||||
description = char_data.get("description", "")
|
||||
gender = char_data.get("gender") or ("未知" if name == "旁白" else None)
|
||||
try:
|
||||
voice_design = crud.create_voice_design(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
name=f"[有声书] {project.title} - {name}",
|
||||
instruct=instruct,
|
||||
backend_type=backend_type,
|
||||
preview_text=description[:100] if description else None,
|
||||
)
|
||||
crud.create_audiobook_character(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
name=name,
|
||||
gender=gender,
|
||||
description=description,
|
||||
instruct=instruct,
|
||||
voice_design_id=voice_design.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create char/voice for {name}: {e}")
|
||||
|
||||
crud.update_audiobook_project_status(db, project_id, "characters_ready")
|
||||
ps.append_line(key, f"\n[状态] 角色创建完成,请确认角色后继续生成剧本")
|
||||
ps.mark_done(key)
|
||||
|
||||
user_id = user.id
|
||||
|
||||
async def _generate_all_previews():
|
||||
temp_db = SessionLocal()
|
||||
try:
|
||||
characters = crud.list_audiobook_characters(temp_db, project_id)
|
||||
char_ids = [c.id for c in characters]
|
||||
finally:
|
||||
temp_db.close()
|
||||
if not char_ids:
|
||||
return
|
||||
sem = asyncio.Semaphore(3)
|
||||
async def _gen(char_id: int):
|
||||
async with sem:
|
||||
local_db = SessionLocal()
|
||||
try:
|
||||
db_user = crud.get_user_by_id(local_db, user_id)
|
||||
await generate_character_preview(project_id, char_id, db_user, local_db)
|
||||
except Exception as e:
|
||||
logger.error(f"Background preview failed for char {char_id}: {e}")
|
||||
finally:
|
||||
local_db.close()
|
||||
await asyncio.gather(*[_gen(cid) for cid in char_ids])
|
||||
|
||||
asyncio.create_task(_generate_all_previews())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"generate_ai_script failed for project {project_id}: {e}", exc_info=True)
|
||||
ps.append_line(key, f"\n[错误] {e}")
|
||||
ps.mark_done(key)
|
||||
crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e))
|
||||
|
||||
|
||||
async def generate_ai_script_chapters(project_id: int, user: User, db: Session) -> None:
|
||||
from core.database import SessionLocal
|
||||
|
||||
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
|
||||
if not project or not project.script_config:
|
||||
return
|
||||
|
||||
key = str(project_id)
|
||||
ps.reset(key)
|
||||
cfg = project.script_config
|
||||
|
||||
try:
|
||||
genre = cfg.get("genre", "")
|
||||
subgenre = cfg.get("subgenre", "")
|
||||
premise = cfg.get("premise", "")
|
||||
style = cfg.get("style", "")
|
||||
num_chapters = cfg.get("num_chapters", 8)
|
||||
|
||||
llm = _get_llm_service(db)
|
||||
_llm_model = crud.get_system_setting(db, "llm_model")
|
||||
_user_id = user.id
|
||||
|
||||
def _log_usage(prompt_tokens: int, completion_tokens: int) -> None:
|
||||
log_db = SessionLocal()
|
||||
try:
|
||||
crud.create_usage_log(log_db, _user_id, prompt_tokens, completion_tokens,
|
||||
model=_llm_model, context="ai_script_chapters")
|
||||
finally:
|
||||
log_db.close()
|
||||
|
||||
def on_token(token: str) -> None:
|
||||
ps.append_token(key, token)
|
||||
|
||||
db_characters = crud.list_audiobook_characters(db, project_id)
|
||||
characters_data = [
|
||||
{"name": c.name, "gender": c.gender or "未知", "description": c.description or "", "instruct": c.instruct or ""}
|
||||
for c in db_characters
|
||||
]
|
||||
char_map = {c.name: c for c in db_characters}
|
||||
backend_type = user.user_preferences.get("default_backend", "aliyun") if user.user_preferences else "aliyun"
|
||||
|
||||
ps.append_line(key, f"[AI剧本] 开始生成 {num_chapters} 章大纲...\n")
|
||||
ps.append_line(key, "")
|
||||
|
||||
chapters_data = await llm.generate_chapter_outline(
|
||||
genre=genre, subgenre=subgenre, premise=premise, style=style,
|
||||
num_chapters=num_chapters, characters=characters_data, usage_callback=_log_usage,
|
||||
)
|
||||
|
||||
ps.append_line(key, f"\n\n[完成] 大纲:{len(chapters_data)} 章")
|
||||
|
||||
crud.delete_audiobook_chapters(db, project_id)
|
||||
crud.delete_audiobook_segments(db, project_id)
|
||||
|
||||
project_audio_dir = Path(settings.OUTPUT_DIR) / "audiobook" / str(project_id)
|
||||
for subdir in ("segments", "chapters"):
|
||||
d = project_audio_dir / subdir
|
||||
if d.exists():
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
for ch_data in chapters_data:
|
||||
idx = ch_data.get("index", 0)
|
||||
title = ch_data.get("title", f"第 {idx + 1} 章")
|
||||
summary = ch_data.get("summary", "")
|
||||
crud.create_audiobook_chapter(db, project_id, idx, summary, title=title)
|
||||
|
||||
crud.update_audiobook_project_status(db, project_id, "ready")
|
||||
|
||||
ps.append_line(key, f"\n[Step 2] 逐章生成对话脚本...\n")
|
||||
|
||||
for ch_data in chapters_data:
|
||||
idx = ch_data.get("index", 0)
|
||||
title = ch_data.get("title", f"第 {idx + 1} 章")
|
||||
summary = ch_data.get("summary", "")
|
||||
|
||||
ps.append_line(key, f"\n第 {idx + 1} 章「{title}」→ ")
|
||||
ps.append_line(key, "")
|
||||
|
||||
chapter_obj = crud.get_audiobook_chapter_by_index(db, project_id, idx)
|
||||
if not chapter_obj:
|
||||
continue
|
||||
|
||||
try:
|
||||
script_text = await llm.generate_chapter_script(
|
||||
genre=genre, premise=premise,
|
||||
chapter_index=idx, chapter_title=title, chapter_summary=summary,
|
||||
characters=characters_data, on_token=on_token, usage_callback=_log_usage,
|
||||
)
|
||||
|
||||
chapter_obj.source_text = script_text
|
||||
db.commit()
|
||||
|
||||
segments_data = parse_ai_script(script_text, char_map)
|
||||
|
||||
unknown_speakers = {
|
||||
seg["character"] for seg in segments_data
|
||||
if seg["character"] != "旁白" and seg["character"] not in char_map
|
||||
}
|
||||
for speaker_name in sorted(unknown_speakers):
|
||||
try:
|
||||
npc_instruct = (
|
||||
"音色信息:普通自然的中性成年人声音,语调平和\n"
|
||||
"身份背景:故事中的路人或配角\n"
|
||||
"年龄设定:成年人\n"
|
||||
"外貌特征:普通外貌\n"
|
||||
"性格特质:平淡自然\n"
|
||||
"叙事风格:语速正常,语气自然"
|
||||
)
|
||||
npc_voice = crud.create_voice_design(
|
||||
db=db, user_id=user.id,
|
||||
name=f"[有声书] {project.title} - {speaker_name}",
|
||||
instruct=npc_instruct, backend_type=backend_type,
|
||||
)
|
||||
npc_char = crud.create_audiobook_character(
|
||||
db=db, project_id=project_id, name=speaker_name,
|
||||
description=f"配角:{speaker_name}",
|
||||
instruct=npc_instruct, voice_design_id=npc_voice.id,
|
||||
)
|
||||
char_map[speaker_name] = npc_char
|
||||
ps.append_line(key, f"\n[NPC] 自动创建配角:{speaker_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create NPC {speaker_name}: {e}")
|
||||
|
||||
crud.delete_audiobook_segments_for_chapter(db, project_id, idx)
|
||||
|
||||
seg_counter = 0
|
||||
for seg in segments_data:
|
||||
seg_text = seg.get("text", "").strip()
|
||||
if not seg_text:
|
||||
continue
|
||||
char = char_map.get(seg.get("character", "旁白")) or char_map.get("旁白")
|
||||
if not char:
|
||||
continue
|
||||
crud.create_audiobook_segment(
|
||||
db, project_id, char.id, seg_text,
|
||||
chapter_index=idx, segment_index=seg_counter,
|
||||
emo_text=seg.get("emo_text"), emo_alpha=seg.get("emo_alpha"),
|
||||
)
|
||||
seg_counter += 1
|
||||
|
||||
crud.update_audiobook_chapter_status(db, chapter_obj.id, "ready")
|
||||
ps.append_line(key, f"\n✓ {seg_counter} 段")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chapter {idx} script generation failed: {e}", exc_info=True)
|
||||
ps.append_line(key, f"\n[错误] {e}")
|
||||
crud.update_audiobook_chapter_status(db, chapter_obj.id, "error", error_message=str(e))
|
||||
|
||||
crud.update_audiobook_project_status(db, project_id, "ready")
|
||||
ps.append_line(key, f"\n\n[完成] AI剧本生成完毕,项目已就绪")
|
||||
ps.mark_done(key)
|
||||
logger.info(f"AI script chapters generation complete for project {project_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"generate_ai_script_chapters failed for project {project_id}: {e}", exc_info=True)
|
||||
ps.append_line(key, f"\n[错误] {e}")
|
||||
ps.mark_done(key)
|
||||
crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e))
|
||||
|
||||
|
||||
async def analyze_project(project_id: int, user: User, db: Session, turbo: bool = False) -> None:
|
||||
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
|
||||
if not project:
|
||||
@@ -242,10 +579,10 @@ async def analyze_project(project_id: int, user: User, db: Session, turbo: bool
|
||||
usage_callback=_log_analyze_usage,
|
||||
)
|
||||
|
||||
has_narrator = any(c.get("name") == "narrator" for c in characters_data)
|
||||
has_narrator = any(c.get("name") in ("narrator", "旁白") for c in characters_data)
|
||||
if not has_narrator:
|
||||
characters_data.insert(0, {
|
||||
"name": "narrator",
|
||||
"name": "旁白",
|
||||
"gender": "未知",
|
||||
"description": "第三人称旁白叙述者",
|
||||
"instruct": (
|
||||
@@ -266,10 +603,12 @@ async def analyze_project(project_id: int, user: User, db: Session, turbo: bool
|
||||
backend_type = user.user_preferences.get("default_backend", "aliyun") if user.user_preferences else "aliyun"
|
||||
|
||||
for char_data in characters_data:
|
||||
name = char_data.get("name", "narrator")
|
||||
name = char_data.get("name", "旁白")
|
||||
if name == "narrator":
|
||||
name = "旁白"
|
||||
instruct = char_data.get("instruct", "")
|
||||
description = char_data.get("description", "")
|
||||
gender = char_data.get("gender") or ("未知" if name == "narrator" else None)
|
||||
gender = char_data.get("gender") or ("未知" if name == "旁白" else None)
|
||||
try:
|
||||
voice_design = crud.create_voice_design(
|
||||
db=db,
|
||||
@@ -443,7 +782,7 @@ async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) ->
|
||||
ps.append_line(key, f"\n[回退] {e}")
|
||||
failed_chunks += 1
|
||||
last_error = str(e)
|
||||
narrator = char_map.get("narrator")
|
||||
narrator = char_map.get("旁白") or char_map.get("narrator")
|
||||
if narrator:
|
||||
crud.create_audiobook_segment(
|
||||
db, project_id, narrator.id, chunk.strip(),
|
||||
@@ -457,7 +796,7 @@ async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) ->
|
||||
seg_text = seg.get("text", "").strip()
|
||||
if not seg_text:
|
||||
continue
|
||||
char = char_map.get(seg.get("character", "narrator")) or char_map.get("narrator")
|
||||
char = char_map.get(seg.get("character", "旁白")) or char_map.get("旁白") or char_map.get("narrator")
|
||||
if not char:
|
||||
continue
|
||||
seg_emo_text = seg.get("emo_text", "") or None
|
||||
@@ -834,8 +1173,6 @@ async def generate_character_preview(project_id: int, char_id: int, user: User,
|
||||
audio_path = output_base / f"char_{char_id}.wav"
|
||||
|
||||
preview_name = char.name
|
||||
if preview_name == "narrator":
|
||||
preview_name = "旁白"
|
||||
|
||||
preview_desc = ""
|
||||
if char.description:
|
||||
|
||||
Reference in New Issue
Block a user