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
+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