feat: CCA 唱词助手子项目 v3 — 脚本版流水线完成

新增 cca/ 子项目:编导A稿+人声音频 → ASR+AI校对+AI折行 → 5段SRT字幕。
- 讯飞录音文件转写标准版(热词注入)
- DeepSeek AI校对(严格纪律:只改错别字/术语/填充词,不润色)
- DeepSeek AI折行(语义断句,≤14字/行)
- 节目结构自动切分(导视/正片×3/预告)
- 绝对时间戳SRT输出(大洋系统兼容)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
simonkoson
2026-07-04 15:25:08 +08:00
parent 1ccb37f0c7
commit ede30d3043
21 changed files with 5530 additions and 1 deletions
+224
View File
@@ -0,0 +1,224 @@
# -*- coding: utf-8 -*-
"""
AI 折行引擎 — 用 DeepSeek 对 ASR 长句做语义折行
对于 ≤14 字的句子直接输出,>14 字的句子批量发给 AI 折行。
"""
import json
import os
import sys
from pathlib import Path
from typing import List, Tuple
try:
from dotenv import load_dotenv
_env_path = Path(__file__).resolve().parent.parent / ".env"
if _env_path.exists():
load_dotenv(str(_env_path), override=True)
except Exception:
pass
from openai import OpenAI
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "").strip()
DEEPSEEK_BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com").strip()
DEEPSEEK_MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-chat").strip()
MAX_CHARS = 14
MAX_CHARS_SOFT = 16
SILENCE_THRESHOLD_MS = 2000
SYSTEM_PROMPT = """你是电视节目唱词字幕的折行助手。你的任务是将一段文字按照以下规则折成多行:
规则:
1. 每行最多14个字(中文字符、英文字母、数字各算1个字)
2. 去掉逗号、句号、感叹号、问号、顿号、分号、冒号、省略号等标点,只保留引号(""''「」)和书名号(《》)
3. 折行要符合语义和阅读习惯,不能把词语切断
4. 每行不一定要凑满14字,可以是5字、8字、10字等,关键是语义完整
5. 保持原文内容不变,不增不减不改字
输出格式:每行一句,不加序号,不加标点(引号和书名号除外)。"""
USER_PROMPT_TEMPLATE = """请将以下文字折行(每行≤14字,去标点保引号,按语义断句):
{text}"""
BATCH_USER_PROMPT = """请将以下编号文字逐条折行(每行≤14字,去标点保引号,按语义断句)。
每条之间用空行分隔,保持编号对应。
{numbered_texts}"""
def _create_client() -> OpenAI:
if not DEEPSEEK_API_KEY:
raise ValueError("请在 .env 中设置 DEEPSEEK_API_KEY")
return OpenAI(api_key=DEEPSEEK_API_KEY, base_url=DEEPSEEK_BASE_URL)
def ai_break_single(text: str, client: OpenAI) -> List[str]:
"""单句 AI 折行"""
resp = client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": USER_PROMPT_TEMPLATE.format(text=text)},
],
temperature=0.1,
max_tokens=500,
)
result = resp.choices[0].message.content.strip()
lines = [l.strip() for l in result.split("\n") if l.strip()]
return lines
def ai_break_batch(texts: List[str], client: OpenAI) -> List[List[str]]:
"""
批量 AI 折行(减少 API 调用次数)
每批最多 20 条,避免输出过长出错
"""
if not texts:
return []
numbered = "\n".join(f"[{i+1}] {t}" for i, t in enumerate(texts))
resp = client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": BATCH_USER_PROMPT.format(numbered_texts=numbered)},
],
temperature=0.1,
max_tokens=3000,
)
result = resp.choices[0].message.content.strip()
# 解析结果:按空行或编号分隔
all_results = []
current_lines = []
for line in result.split("\n"):
line = line.strip()
# 检测新编号开头 [N] 或纯空行作为分隔
if not line:
if current_lines:
all_results.append(current_lines)
current_lines = []
continue
# 去掉可能的编号前缀
import re
cleaned = re.sub(r'^\[\d+\]\s*', '', line)
if cleaned:
current_lines.append(cleaned)
if current_lines:
all_results.append(current_lines)
# 如果解析结果数量不匹配,回退到逐条处理
if len(all_results) != len(texts):
print(f"[AI折行] 批量解析不匹配 (期望{len(texts)}条,得到{len(all_results)}条),回退逐条处理")
all_results = []
for text in texts:
lines = ai_break_single(text, client)
all_results.append(lines)
return all_results
def process_sentences_with_ai(
sentences: List[Tuple[int, int, str, int]],
batch_size: int = 15,
) -> List[Tuple[int, int, str]]:
"""
用 AI 折行处理 ASR 句子列表。
输入: [(start_ms, end_ms, text, speaker_id), ...]
输出: [(start_ms, end_ms, text), ...]
策略:
- ≤14 字:直接输出(去标点)
- >14 字:批量调 AI 折行
- 句间 >2秒:插入空白行
"""
from line_breaker import clean_punctuation
if not sentences:
return []
client = _create_client()
result = []
# 先收集需要 AI 折行的句子索引
needs_ai = [] # (original_index, text)
for i, (bg, ed, text, spk) in enumerate(sentences):
cleaned = clean_punctuation(text)
if len(cleaned) > MAX_CHARS:
needs_ai.append((i, cleaned))
# 批量调 AI
ai_results = {} # index -> [lines]
if needs_ai:
print(f"[AI折行] 共 {len(needs_ai)} 句需要 AI 折行...")
for batch_start in range(0, len(needs_ai), batch_size):
batch = needs_ai[batch_start:batch_start + batch_size]
batch_texts = [t for _, t in batch]
batch_indices = [idx for idx, _ in batch]
print(f"[AI折行] 处理第 {batch_start+1}-{batch_start+len(batch)} 条...")
try:
broken = ai_break_batch(batch_texts, client)
for idx, lines in zip(batch_indices, broken):
ai_results[idx] = lines
except Exception as e:
print(f"[AI折行] 批量失败: {e},回退逐条处理")
for idx, text in batch:
try:
lines = ai_break_single(text, client)
ai_results[idx] = lines
except Exception as e2:
print(f"[AI折行] 第{idx}句失败: {e2},使用机械切分")
from line_breaker import break_sentence
ai_results[idx] = break_sentence(text)
# 组装最终结果
for i, (bg, ed, text, spk) in enumerate(sentences):
# 检查空白
if i > 0:
prev_ed = sentences[i - 1][1]
gap = bg - prev_ed
if gap > SILENCE_THRESHOLD_MS:
result.append((prev_ed, bg, ""))
cleaned = clean_punctuation(text)
if not cleaned.strip():
continue
if i in ai_results:
lines = ai_results[i]
else:
lines = [cleaned]
# 后处理:AI 偶尔返回超长行,强制二次切分
from line_breaker import break_sentence
final_lines = []
for line in lines:
if len(line) > MAX_CHARS_SOFT:
final_lines.extend(break_sentence(line))
else:
final_lines.append(line)
lines = final_lines
# 为子行分配时间戳
total_chars = sum(len(l) for l in lines)
duration = ed - bg
current_ms = bg
for line in lines:
if not line.strip():
continue
line_duration = int(duration * len(line) / total_chars) if total_chars > 0 else 0
line_end = min(current_ms + line_duration, ed)
result.append((current_ms, line_end, line))
current_ms = line_end
return result
+163
View File
@@ -0,0 +1,163 @@
# -*- coding: utf-8 -*-
"""
AI 校对器 — ASR 稿与 A 稿比对 + 上下文纠错
解决的核心问题:
- ASR 同音字误识别("建制""舰只""舰手""舰艏"
- 军事术语规范化("f15j""F-15J"
- 的/地/得纠错
- 去除口语填充词("""那个""就是说"
策略:
- 将 ASR 全文 + A 稿全文一起发给 DeepSeek
- AI 结合节目主题和上下文做纠错
- 返回修正后的句子列表 + 修改说明
"""
import json
import os
import sys
from pathlib import Path
from typing import List, Tuple, Dict
try:
from dotenv import load_dotenv
_env_path = Path(__file__).resolve().parent.parent / ".env"
if _env_path.exists():
load_dotenv(str(_env_path), override=True)
except Exception:
pass
PROOFREAD_SYSTEM_PROMPT = """你是电视军事节目《军事科技》的字幕校对专家。你将收到两份材料:
1. **ASR稿**:语音识别的转写结果,带有时间编号,是字幕的基础
2. **A稿**:编导写的节目文稿(仅包含解说词,不包含专家采访的具体内容)
你的任务是校对 ASR 稿中的**语音识别错误**。
**铁律(违反任何一条都算失败):**
- ASR稿是已经录好的音频的转写,内容不能改——**绝不润色语句、绝不调整语序、绝不增删实词**
- 只修三类问题:① 错别字/同音字 ② 术语格式 ③ 口语填充词
- 除这三类外的一切文字,原封不动照抄,一个字都不能动
- A稿只用来判断"这个词在本期节目的语境下应该是哪个字",不能把ASR稿往A稿的措辞上靠
**允许修的三类:**
1. **同音字/错别字**(ASR听错的字):如"建制""舰只""舰手""舰艏""继承""击沉""空花弹""滑翔弹"
2. **术语格式**:英文型号大小写+连字符("f15j""F-15J""v22""V-22""rq四""RQ-4"
3. **口语填充词删除**:只删"""""""""""那个""就是说""这个"这类纯填充词。如果"这个"后面紧跟名词作指示代词("这个导弹"),保留不删
**绝对不许做的(哪怕你觉得改了更好也不许):**
- 不许调整语序("它在性质上就是"不许改成"它本质上就是"
- 不许替换实词("不是那么特别的顺利"不许改成"不太顺利"
- 不许增删标点来改变句子结构
- 不许把口语化表达改成书面语
- 不许根据A稿的措辞替换ASR中意思相同但用词不同的表达
**输出格式:**
JSON数组,每个元素:{"id": 编号, "original": "原文", "corrected": "修正后", "changes": "修改说明(无修改写空字符串)"}
只输出JSON,不要其他内容。"""
PROOFREAD_USER_TEMPLATE = """**A稿(节目文稿,仅供参考):**
{script_text}
**ASR稿(需要校对,请逐条检查):**
{asr_text}"""
def _create_client():
api_key = os.environ.get("DEEPSEEK_API_KEY", "").strip()
if not api_key:
raise ValueError("请在 .env 中设置 DEEPSEEK_API_KEY")
from openai import OpenAI
return OpenAI(
api_key=api_key,
base_url=os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
)
def proofread_batch(
asr_sentences: List[Tuple[int, int, str, int]],
script_text: str,
batch_size: int = 30,
) -> List[Tuple[int, int, str, int]]:
"""
对 ASR 句子列表做 AI 校对。
输入:
asr_sentences: [(start_ms, end_ms, text, speaker_id), ...]
script_text: A稿全文
batch_size: 每批处理的句子数
返回:
校对后的句子列表,格式同输入
"""
if not asr_sentences:
return []
client = _create_client()
# A稿截取(太长的话截前8000字,够提供上下文了)
script_truncated = script_text[:8000] if len(script_text) > 8000 else script_text
corrected_sentences = list(asr_sentences) # 浅拷贝
total_changes = 0
for batch_start in range(0, len(asr_sentences), batch_size):
batch = asr_sentences[batch_start:batch_start + batch_size]
batch_end = batch_start + len(batch)
# 构建 ASR 文本(带编号)
asr_lines = []
for i, (bg, ed, text, spk) in enumerate(batch):
asr_lines.append(f"[{i+1}] {text}")
asr_text = "\n".join(asr_lines)
print(f"[校对] 处理第 {batch_start+1}-{batch_end} 句...")
try:
resp = client.chat.completions.create(
model=os.environ.get("DEEPSEEK_MODEL", "deepseek-chat"),
messages=[
{"role": "system", "content": PROOFREAD_SYSTEM_PROMPT},
{"role": "user", "content": PROOFREAD_USER_TEMPLATE.format(
script_text=script_truncated,
asr_text=asr_text,
)},
],
temperature=0.1,
max_tokens=4000,
)
result_text = resp.choices[0].message.content.strip()
# 尝试解析 JSON
# 去掉可能的 markdown 代码块标记
if result_text.startswith("```"):
result_text = result_text.split("\n", 1)[1]
if result_text.endswith("```"):
result_text = result_text[:-3]
result_text = result_text.strip()
corrections = json.loads(result_text)
# 应用修正
for item in corrections:
idx = item.get("id", 0) - 1 # 编号从1开始
corrected = item.get("corrected", "")
changes = item.get("changes", "")
if 0 <= idx < len(batch) and corrected and changes:
original_idx = batch_start + idx
bg, ed, _, spk = corrected_sentences[original_idx]
corrected_sentences[original_idx] = (bg, ed, corrected, spk)
total_changes += 1
print(f" 修正: '{item.get('original','')}''{corrected}' ({changes})")
except json.JSONDecodeError as e:
print(f"[校对] JSON解析失败,跳过本批: {e}", file=sys.stderr)
except Exception as e:
print(f"[校对] 出错: {e}", file=sys.stderr)
print(f"[校对] 完成,共修正 {total_changes}")
return corrected_sentences
+229
View File
@@ -0,0 +1,229 @@
# -*- coding: utf-8 -*-
"""
讯飞 ASR 客户端 — 适配自 doco/src/doco/asr_adapter.py
录音文件转写标准版: https://raasr.xfyun.cn/v2/api
"""
import base64
import hashlib
import hmac
import json
import os
import sys
import time
import wave
from pathlib import Path
from urllib.parse import quote
from typing import List, Tuple, Optional
import requests
# ========================================================================
# 凭证
# ========================================================================
try:
from dotenv import load_dotenv
_env_path = Path(__file__).resolve().parent.parent / ".env"
if _env_path.exists():
load_dotenv(str(_env_path), override=True)
except Exception:
pass
APP_ID = os.environ.get("XFYUN_APP_ID", "").strip()
SECRET_KEY = os.environ.get("XFYUN_SECRET_KEY", "").strip()
# ========================================================================
# 接口配置
# ========================================================================
HOST = "https://raasr.xfyun.cn/v2/api"
UPLOAD_URL = HOST + "/upload"
RESULT_URL = HOST + "/getResult"
LANGUAGE = "cn"
PD = "mil"
ENG_SMOOTHPROC = "true"
ENG_COLLOQPROC = "true"
ROLE_TYPE = "1"
ROLE_NUM = "0"
POLL_INTERVAL_SECONDS = 30
MAX_WAIT_MINUTES = 30
# ========================================================================
# 签名
# ========================================================================
def _make_signa(app_id: str, secret_key: str, ts: str) -> str:
base_string = (app_id + ts).encode("utf-8")
md5_str = hashlib.md5(base_string).hexdigest()
mac = hmac.new(
secret_key.encode("utf-8"),
md5_str.encode("utf-8"),
digestmod=hashlib.sha1,
)
return base64.b64encode(mac.digest()).decode("utf-8")
def _get_audio_duration_ms(filepath: str) -> int:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".wav":
with wave.open(filepath, "rb") as wf:
return int(round(wf.getnframes() / wf.getframerate() * 1000))
if ext == ".mp3":
try:
from mutagen.mp3 import MP3
return int(MP3(filepath).info.length * 1000)
except ImportError:
print("[警告] 需要 mutagen 库来读取 MP3 时长: pip install mutagen", file=sys.stderr)
return 0
raise ValueError(f"不支持的音频格式: {ext}")
# ========================================================================
# 上传
# ========================================================================
def upload_audio(filepath: str, hot_words: Optional[List[str]] = None) -> str:
if not APP_ID or not SECRET_KEY:
raise ValueError("请先在 .env 中设置 XFYUN_APP_ID 和 XFYUN_SECRET_KEY")
file_size = os.path.getsize(filepath)
file_name = os.path.basename(filepath)
duration_ms = _get_audio_duration_ms(filepath)
ts = str(int(time.time()))
signa = _make_signa(APP_ID, SECRET_KEY, ts)
params = {
"appId": APP_ID,
"signa": signa,
"ts": ts,
"fileSize": str(file_size),
"fileName": file_name,
"duration": str(duration_ms),
"language": LANGUAGE,
"pd": PD,
"eng_smoothproc": ENG_SMOOTHPROC,
"eng_colloqproc": ENG_COLLOQPROC,
"roleType": ROLE_TYPE,
"roleNum": ROLE_NUM,
}
if hot_words:
params["hotWord"] = "|".join(hot_words[:200])
url_parts = [f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in params.items()]
url = f"{UPLOAD_URL}?{'&'.join(url_parts)}"
with open(filepath, "rb") as f:
audio_bytes = f.read()
print(f"[ASR] 上传音频: {file_name} ({file_size/1024/1024:.1f}MB)")
resp = requests.post(url, headers={"Content-Type": "application/json"}, data=audio_bytes, timeout=300)
data = resp.json()
if data.get("code") != "000000":
raise RuntimeError(f"上传失败: code={data.get('code')}, desc={data.get('descInfo')}")
order_id = data["content"]["orderId"]
print(f"[ASR] 上传成功, orderId={order_id}")
return order_id
# ========================================================================
# 轮询
# ========================================================================
def poll_until_done(order_id: str) -> dict:
start_time = time.time()
print(f"[ASR] 开始轮询 (每{POLL_INTERVAL_SECONDS}秒)...")
while True:
elapsed = time.time() - start_time
if elapsed > MAX_WAIT_MINUTES * 60:
raise TimeoutError(f"超过 {MAX_WAIT_MINUTES} 分钟未完成")
ts = str(int(time.time()))
signa = _make_signa(APP_ID, SECRET_KEY, ts)
params = {
"appId": APP_ID,
"signa": signa,
"ts": ts,
"orderId": order_id,
"resultType": "transfer",
}
url_parts = [f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in params.items()]
url = f"{RESULT_URL}?{'&'.join(url_parts)}"
resp = requests.post(url, timeout=30)
data = resp.json()
order_info = data.get("content", {}).get("orderInfo", {})
status = order_info.get("status")
if status == 4:
print(f"[ASR] 转写完成 (耗时{int(elapsed)}秒)")
return data
if status == -1:
raise RuntimeError(f"转写失败: {data}")
print(f"[ASR] 等待中... ({int(elapsed)}秒)")
time.sleep(POLL_INTERVAL_SECONDS)
# ========================================================================
# 解析
# ========================================================================
def parse_result(order_result_str: str) -> List[Tuple[int, int, str, int]]:
"""
解析讯飞返回结果
返回 [(start_ms, end_ms, text, speaker_id), ...]
"""
if not order_result_str:
return []
cleaned = order_result_str.replace("\\\\", "\\")
outer = json.loads(cleaned)
sentences = []
for item in outer.get("lattice", []):
inner_str = item.get("json_1best", "")
if not inner_str:
continue
inner = json.loads(inner_str)
st = inner.get("st", {})
bg = int(st.get("bg", 0))
ed = int(st.get("ed", 0))
rl = int(st.get("rl", 0))
words = []
for rt in st.get("rt", []):
for ws in rt.get("ws", []):
for cw in ws.get("cw", []):
w = cw.get("w", "").strip()
wp = cw.get("wp", "n")
if w and wp != "g":
words.append(w)
sentence = "".join(words).strip()
if sentence:
sentences.append((bg, ed, sentence, rl))
return sentences
# ========================================================================
# 完整流程
# ========================================================================
def transcribe(audio_path: str, hot_words: Optional[List[str]] = None) -> Tuple[List[Tuple[int, int, str, int]], str]:
"""
完整转写: 上传 → 轮询 → 解析
返回 (sentences, raw_json_str)
sentences: [(start_ms, end_ms, text, speaker_id), ...]
"""
order_id = upload_audio(audio_path, hot_words=hot_words)
result_data = poll_until_done(order_id)
order_result_str = result_data["content"]["orderResult"]
sentences = parse_result(order_result_str)
return sentences, order_result_str
+210
View File
@@ -0,0 +1,210 @@
# -*- coding: utf-8 -*-
"""
热词提取器 — 从 A 稿中提取军事专有名词,注入讯飞 ASR 热词
两种模式:
1. 规则提取(不消耗 API): 匹配 A 稿中的武器型号、专有名词等
2. AI 提取(消耗 DeepSeek API): 让 AI 理解全文后提取专业术语
热词用途: 注入讯飞 ASR 的 hotWord 参数,提升领域识别准确率
讯飞限制: 最多 200 个热词,每个 2-16 字,用 | 分隔
"""
import os
import re
import sys
from pathlib import Path
from typing import List, Optional
# ========================================================================
# 规则提取
# ========================================================================
# 常见军事装备型号模式
MILITARY_PATTERNS = [
# 武器型号: F-35B, F-15J, RQ-4, V-22 等
re.compile(r'[A-Z]{1,3}-?\d{1,4}[A-Z]?(?:/[A-Z])?'),
# 中文+数字型号: 12式, 25式, 17式
re.compile(r'\d{1,3}式'),
# 导弹/武器名称中的英文缩写
re.compile(r'(?:MK|ESSM|LM|OPY|FCS)-?\d*[A-Z]*'),
]
# 军事领域高频词(手动维护,补充 ASR 容易错的同音词)
MILITARY_VOCAB = [
# 海军
"舰只", "舰艇", "舰载机", "护卫舰", "驱逐舰", "航空母舰", "航母",
"出云级", "出云号", "日向级", "日向号", "加贺号", "最上级",
"伊势号", "满载排水量", "飞行甲板", "舰艏", "舰艉", "舰宽",
"垂直发射系统", "近防系统", "反潜", "扫雷",
# 空军
"战斗机", "隐身战斗机", "舰载机", "无人机", "旋翼机",
"航空自卫队", "航空宇宙自卫队",
# 陆军/导弹
"巡航导弹", "反舰导弹", "高超音速", "滑翔弹",
"战斧", "陆上自卫队", "海上自卫队",
"岸基", "弹径", "弹体", "射程", "马赫数",
# 通用军事
"防卫省", "自卫队", "专守防卫", "和平宪法",
"军备", "军售", "军费", "军工", "军舰",
"进攻性", "防御性", "远程打击", "精确打击",
"作战编队", "态势感知", "火力演习",
# 人名/地名
"蓝皓", "熊本县", "南鸟岛", "东富士",
# 节目相关
"军事科技", "国防军事频道",
]
def extract_by_rules(text: str) -> List[str]:
"""用正则从文本中提取军事术语"""
found = set()
# 正则匹配
for pattern in MILITARY_PATTERNS:
for match in pattern.finditer(text):
word = match.group().strip()
if 2 <= len(word) <= 16:
found.add(word)
# 固定词表匹配(只加文本中确实出现的词)
for word in MILITARY_VOCAB:
if word in text:
found.add(word)
return sorted(found)
# ========================================================================
# AI 提取
# ========================================================================
AI_EXTRACT_PROMPT = """你是军事节目的专有名词提取助手。请从以下节目文稿中提取所有军事专有名词和术语。
提取范围:
1. 武器装备型号(如 F-35B、12式反舰导弹、战斧巡航导弹)
2. 军事单位/部队名称(如 航空自卫队、陆上自卫队)
3. 军舰/飞机名称(如 出云号、日向级)
4. 军事术语(如 垂直发射系统、高超音速滑翔弹、专守防卫)
5. 人名、地名(如 蓝皓、熊本县、南鸟岛)
6. 容易被语音识别混淆的词(如 "舰只"容易被识别为"建制""舰艏"容易被识别为"舰手"
输出格式:每行一个词,不加序号,不加解释。每个词 2-16 字。"""
def extract_by_ai(text: str) -> List[str]:
"""用 DeepSeek 从文本中提取专有名词"""
try:
from dotenv import load_dotenv
_env_path = Path(__file__).resolve().parent.parent / ".env"
if _env_path.exists():
load_dotenv(str(_env_path), override=True)
except Exception:
pass
api_key = os.environ.get("DEEPSEEK_API_KEY", "").strip()
if not api_key:
print("[热词] DeepSeek 未配置,跳过 AI 提取", file=sys.stderr)
return []
from openai import OpenAI
client = OpenAI(
api_key=api_key,
base_url=os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
)
# 文稿太长时截取前6000字
truncated = text[:6000] if len(text) > 6000 else text
resp = client.chat.completions.create(
model=os.environ.get("DEEPSEEK_MODEL", "deepseek-chat"),
messages=[
{"role": "system", "content": AI_EXTRACT_PROMPT},
{"role": "user", "content": truncated},
],
temperature=0.1,
max_tokens=2000,
)
result = resp.choices[0].message.content.strip()
words = []
for line in result.split("\n"):
word = line.strip().strip("-").strip("·").strip("").strip()
if word and 2 <= len(word) <= 16:
words.append(word)
return words
# ========================================================================
# 读取 A 稿
# ========================================================================
def read_docx_text(docx_path: str) -> str:
"""从 docx 文件中提取纯文本"""
try:
from docx import Document
doc = Document(docx_path)
return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
except ImportError:
print("[热词] 需要 python-docx 库: pip install python-docx", file=sys.stderr)
return ""
def read_text_file(path: str) -> str:
"""读取 txt 文件"""
with open(path, "r", encoding="utf-8") as f:
return f.read()
# ========================================================================
# 主入口
# ========================================================================
def extract_hotwords(
script_path: str,
use_ai: bool = True,
max_words: int = 200,
) -> List[str]:
"""
从 A 稿提取热词列表
script_path: A 稿路径 (.docx 或 .txt)
use_ai: 是否使用 AI 提取(默认 True)
max_words: 最大热词数(讯飞限制 200)
"""
ext = os.path.splitext(script_path)[1].lower()
if ext == ".docx":
text = read_docx_text(script_path)
elif ext in (".txt", ".md"):
text = read_text_file(script_path)
else:
print(f"[热词] 不支持的文件格式: {ext}", file=sys.stderr)
return []
if not text:
return []
# 规则提取(免费)
rule_words = extract_by_rules(text)
print(f"[热词] 规则提取: {len(rule_words)}")
# AI 提取(可选)
ai_words = []
if use_ai:
print("[热词] AI 提取中...")
ai_words = extract_by_ai(text)
print(f"[热词] AI 提取: {len(ai_words)}")
# 合并去重
seen = set()
merged = []
for word in ai_words + rule_words: # AI 结果优先
if word not in seen:
seen.add(word)
merged.append(word)
# 截取前 max_words 个
result = merged[:max_words]
print(f"[热词] 最终热词: {len(result)}")
return result
+156
View File
@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""
折行引擎 — 将 ASR 句子按拍词规则折成 ≤14 字/行的字幕行
规则:
A. 每行 ≤ 14 字
B. ASR 中 >2 秒空白 → 插入空白行
C. 按语义断句(不机械凑满 14 字)
D. 去掉逗号/句号/感叹号/问号等标点,只保留引号
"""
import re
from typing import List, Tuple
MAX_CHARS_PER_LINE = 14
MAX_CHARS_SOFT = 16 # 找不到好断点时允许的最大宽容值
SILENCE_THRESHOLD_MS = 2000 # >2秒空白插入空行
# 保留的标点(引号类)
KEEP_PUNCTUATION = set('""''「」『』《》')
# 需要去掉的标点
REMOVE_PUNCTUATION = re.compile(r'[,。!?、;:…—·\,\.\!\?\;\:]')
# 语义断句的优先切分点(按优先级排序)
BREAK_PATTERNS = [
re.compile(r'(?<=[。!?])'),
re.compile(r'(?<=[,、;:])'),
re.compile(r'(?<=》)'),
re.compile('(?<=[”’』」])'), # 右引号后: " ' 』 」
]
def clean_punctuation(text: str) -> str:
"""去掉标点,保留引号类"""
result = []
for ch in text:
if ch in KEEP_PUNCTUATION:
result.append(ch)
elif REMOVE_PUNCTUATION.match(ch):
continue
else:
result.append(ch)
return "".join(result)
def break_sentence(text: str) -> List[str]:
"""
将一个句子按语义折行,每行 ≤ MAX_CHARS_PER_LINE 字。
先尝试在自然断句点切分,如果不行就硬切。
"""
if len(text) <= MAX_CHARS_PER_LINE:
return [text] if text.strip() else []
# 14-16字且找不到好断点时,允许不切(人工拍词也偶尔允许略超)
if len(text) <= MAX_CHARS_SOFT:
# 只有在有明显语义断点时才切
for pattern in BREAK_PATTERNS:
matches = list(pattern.finditer(text))
if matches:
pos = matches[-1].end()
if 3 <= pos <= len(text) - 3:
return [text[:pos].strip(), text[pos:].strip()]
return [text]
lines = []
remaining = text
while len(remaining) > MAX_CHARS_PER_LINE:
# 在前14字范围内找最佳切分点(从后往前找)
best_pos = -1
window = remaining[:MAX_CHARS_PER_LINE]
# 尝试在语义点切分
for pattern in BREAK_PATTERNS:
matches = list(pattern.finditer(window))
if matches:
pos = matches[-1].end()
if 3 <= pos <= MAX_CHARS_PER_LINE:
best_pos = pos
break
if best_pos == -1:
# 没有好的语义切分点,尝试在常见虚词前切
for i in range(min(MAX_CHARS_PER_LINE, len(remaining)) - 1, 2, -1):
ch = remaining[i]
if ch in "的了是在和与而但又或则也还却并且从向把被让给":
best_pos = i
break
if best_pos == -1:
# 实在找不到,硬切在14字
best_pos = MAX_CHARS_PER_LINE
line = remaining[:best_pos].strip()
if line:
lines.append(line)
remaining = remaining[best_pos:].strip()
if remaining.strip():
lines.append(remaining.strip())
return lines
def process_sentences(
sentences: List[Tuple[int, int, str, int]],
) -> List[Tuple[int, int, str]]:
"""
将 ASR 句子列表处理为折行后的字幕行列表。
输入: [(start_ms, end_ms, text, speaker_id), ...]
输出: [(start_ms, end_ms, text), ...] 其中 text="" 表示空白行
处理逻辑:
1. 检测句子间空白 >2秒 → 插入空白行
2. 清理标点
3. 按规则折行
4. 为折行后的子行分配时间戳(按字数比例)
"""
if not sentences:
return []
result = []
for i, (bg, ed, text, _spk) in enumerate(sentences):
# 检查与前一句的空白
if i > 0:
prev_ed = sentences[i - 1][1]
gap = bg - prev_ed
if gap > SILENCE_THRESHOLD_MS:
# 插入空白行,占据空白时段
result.append((prev_ed, bg, ""))
# 清理标点
cleaned = clean_punctuation(text)
if not cleaned.strip():
continue
# 折行
lines = break_sentence(cleaned)
if not lines:
continue
# 为每个子行按字数比例分配时间戳
total_chars = sum(len(l) for l in lines)
duration = ed - bg
current_ms = bg
for line in lines:
line_duration = int(duration * len(line) / total_chars) if total_chars > 0 else 0
line_end = min(current_ms + line_duration, ed)
result.append((current_ms, line_end, line))
current_ms = line_end
return result
+179
View File
@@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
"""
节目结构切分器 — 将 ASR 结果按节目结构拆分为 5 段
结构: 导视 + 正片(3段) + 下期预告
标志词:
- 导视结束 / 正片开始: "各位观众你们好""我是主持人蓝皓"
- 正片结束: "好了观众朋友们""感谢您.*关注.*军事科技"
- 正片之后 = 下期预告
正片拆3段: 按时长大致均分,优先在角色转换处(speaker_id 变化)切分
"""
import re
from typing import List, Tuple, Optional
# 标志词模式
PATTERN_SHOW_START = re.compile(r"各位观众你们好|我是主持人蓝皓")
PATTERN_SHOW_END = re.compile(r"好了观众朋友们|感谢您.*关注.*军事科技|感谢您持续关注")
def find_segment_boundaries(
sentences: List[Tuple[int, int, str, int]],
) -> Tuple[int, int]:
"""
找到正片开始和结束的句子索引。
返回 (show_start_idx, show_end_idx)
- show_start_idx: "各位观众你们好"所在句子的索引
- show_end_idx: "好了观众朋友们"所在句子的索引
"""
show_start_idx = 0
show_end_idx = len(sentences) - 1
for i, (_, _, text, _) in enumerate(sentences):
if PATTERN_SHOW_START.search(text):
show_start_idx = i
break
for i in range(len(sentences) - 1, -1, -1):
_, _, text, _ = sentences[i]
if PATTERN_SHOW_END.search(text):
show_end_idx = i
break
return show_start_idx, show_end_idx
def split_show_into_three(
sentences: List[Tuple[int, int, str, int]],
start_idx: int,
end_idx: int,
) -> Tuple[int, int]:
"""
将正片(start_idx 到 end_idx)拆成 3 段。
返回两个切分点索引 (split1_idx, split2_idx)
策略: 按时长三等分,然后在附近找 speaker_id 变化的位置。
"""
if end_idx - start_idx < 6:
# 太短了,均分
third = (end_idx - start_idx) // 3
return start_idx + third, start_idx + 2 * third
show_start_ms = sentences[start_idx][0]
show_end_ms = sentences[end_idx][1]
total_duration = show_end_ms - show_start_ms
target1_ms = show_start_ms + total_duration // 3
target2_ms = show_start_ms + 2 * total_duration // 3
split1 = _find_best_split(sentences, start_idx, end_idx, target1_ms)
split2 = _find_best_split(sentences, split1 + 1, end_idx, target2_ms)
# 确保 split2 > split1
if split2 <= split1:
split2 = split1 + (end_idx - split1) // 2
return split1, split2
def _find_best_split(
sentences: List[Tuple[int, int, str, int]],
range_start: int,
range_end: int,
target_ms: int,
search_window: int = 15,
) -> int:
"""
在 target_ms 附近(±search_window 句)找最佳切分点。
优先找 speaker_id 变化的位置,其次找 >2秒 空白。
"""
# 先找到时间上最接近 target_ms 的句子
closest_idx = range_start
min_diff = abs(sentences[range_start][0] - target_ms)
for i in range(range_start, min(range_end + 1, len(sentences))):
diff = abs(sentences[i][0] - target_ms)
if diff < min_diff:
min_diff = diff
closest_idx = i
# 在附近找 speaker 变化点
search_lo = max(range_start + 1, closest_idx - search_window)
search_hi = min(range_end, closest_idx + search_window)
best_idx = closest_idx
best_score = 0
for i in range(search_lo, search_hi):
score = 0
# speaker 变化加分
if sentences[i][3] != sentences[i - 1][3] and sentences[i][3] != 0:
score += 10
# 空白间隔加分
gap = sentences[i][0] - sentences[i - 1][1]
if gap > 2000:
score += 5
elif gap > 1000:
score += 2
# 离目标越近加分
time_diff = abs(sentences[i][0] - target_ms)
time_score = max(0, 5 - time_diff / 10000)
score += time_score
if score > best_score:
best_score = score
best_idx = i
return best_idx
def split_into_segments(
sentences: List[Tuple[int, int, str, int]],
) -> List[Tuple[str, List[Tuple[int, int, str, int]]]]:
"""
将全部 ASR 句子拆分为 5 段。
返回: [("导视", [...]), ("正片1", [...]), ("正片2", [...]), ("正片3", [...]), ("预告", [...])]
如果找不到标志词,则只输出单段。
"""
if not sentences:
return [("正片1", [])]
show_start_idx, show_end_idx = find_segment_boundaries(sentences)
# 导视: 0 到 show_start_idx-1
intro = sentences[:show_start_idx] if show_start_idx > 0 else []
# 正片: show_start_idx 到 show_end_idx
show_sentences = sentences[show_start_idx:show_end_idx + 1]
# 预告: show_end_idx+1 到末尾
trailer = sentences[show_end_idx + 1:] if show_end_idx < len(sentences) - 1 else []
# 正片拆3段
if len(show_sentences) > 6:
split1, split2 = split_show_into_three(sentences, show_start_idx, show_end_idx)
# 转为相对于 show_sentences 的索引
rel_split1 = split1 - show_start_idx
rel_split2 = split2 - show_start_idx
show_part1 = show_sentences[:rel_split1]
show_part2 = show_sentences[rel_split1:rel_split2]
show_part3 = show_sentences[rel_split2:]
else:
show_part1 = show_sentences
show_part2 = []
show_part3 = []
segments = []
if intro:
segments.append(("导视", intro))
segments.append(("正片1", show_part1))
if show_part2:
segments.append(("正片2", show_part2))
if show_part3:
segments.append(("正片3", show_part3))
if trailer:
segments.append(("预告", trailer))
return segments
+49
View File
@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""
SRT 生成器 — 将折行后的字幕行列表写成大洋系统兼容的 SRT 文件
格式参照 data/ 下的真实样本:
- 序号从 1 开始
- 时间格式: HH:MM:SS,mmm --> HH:MM:SS,mmm
- 每条字幕一行文字(不多行)
- 空白行(屏幕清字幕)写为空内容
- 条目之间用空行分隔
"""
from typing import List, Tuple
def ms_to_srt_time(ms: int) -> str:
"""毫秒 → SRT 时间格式 HH:MM:SS,mmm"""
if ms < 0:
ms = 0
hours = ms // 3600000
minutes = (ms % 3600000) // 60000
seconds = (ms % 60000) // 1000
millis = ms % 1000
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"
def write_srt(
subtitle_lines: List[Tuple[int, int, str]],
output_path: str,
time_offset: int = 0,
) -> None:
"""
写入 SRT 文件
subtitle_lines: [(start_ms, end_ms, text), ...] text="" 表示空白行
output_path: 输出文件路径
time_offset: 时间偏移(用于正片拆分后各段从0开始计时的情况,这里不用,保持绝对时间)
"""
with open(output_path, "w", encoding="utf-8") as f:
for idx, (start_ms, end_ms, text) in enumerate(subtitle_lines, 1):
start = ms_to_srt_time(start_ms - time_offset)
end = ms_to_srt_time(end_ms - time_offset)
f.write(f"{idx}\n")
f.write(f"{start} --> {end}\n")
# 空白行写一个空格(参照样本中的做法)
f.write(f"{text if text else ' '}\n")
f.write("\n")
print(f"[SRT] 已写入: {output_path} ({len(subtitle_lines)} 条)")