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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)} 条)")
|
||||
Reference in New Issue
Block a user