feat: CCA v6 腾讯云部署 + 审稿台(含查找替换)

- deploy/cca_route.py: Flask 蓝图(6个API端点),WAV自动转MP3
- deploy/cca.html: 4步单页流程(上传→处理→审稿→下载),查找替换(Ctrl+H)
- src/term_normalizer.py: 新增正则层(同音字/引号/书名号/小数点/波浪号)
- src/ai_proofreader.py: speaker角色识别+专家段增强Prompt+的地得加强
- src/ai_line_breaker.py: 引号不跨屏+极短行合并+短句合并间隔放宽
- cca_pipeline.py: Step 2.5 校对后二次正则兜底
- 已部署至 http://101.42.29.217/cca.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
simonkoson
2026-07-05 21:44:52 +08:00
parent ede30d3043
commit 7eb6511169
9 changed files with 2311 additions and 90 deletions
+33 -20
View File
@@ -4,9 +4,9 @@
## 🔖 状态栏 (STATUS — 每次结束 session 前必须更新这三行)
- **最后更新**Claude Code(动手开发)| 2026-07-04
- **当前状态一句话**脚本版流水线 v3 完成(绝对时间戳+严格校对不润色),等制片人周一大洋系统验证 SRT 导入效果
- **下一个动手的人从这里开始**:读完本文件,运行 `python cca_pipeline.py --help` 了解用法。凭证需填入 `cca/.env`XFYUN + DEEPSEEK)。可选下一步:用 `--audio` 跑真实 ASR(带热词注入)看是否比缓存版效果更好
- **最后更新**Claude Code(动手开发)| 2026-07-05
- **当前状态一句话**CCA 唱词助手已部署至腾讯云(http://101.42.29.217/cca.html)。完整流程可用:上传音频+A稿 → ASR+AI校对 → 审稿台(含查找替换)→ 生成SRT下载。WAV自动转MP3。进入内测阶段
- **下一个动手的人从这里开始**:读完本文件。线上地址 http://101.42.29.217/cca.html(从配音首页"唱词助手"按钮进入)。服务器凭证见 `/workspace/military_tech_voice/backend/.env`。本地调试:`python -X utf8 cca_pipeline.py --asr-cache output/asr_raw.json --script "data/重走战争老路的日本军备(A稿).docx" --output-dir output_v6`
---
@@ -51,7 +51,7 @@
## 2. 技术栈与运行方式
- **语言**Python(与主项目 backend 对齐)
- **前端**Web 界面(编导审稿台需要双屏对比 UI),技术选型待定(可复用主项目 React 或轻量方案)
- **前端**纯 HTML/CSS/JS 单页(cca.html),暗色主题匹配 lanhao 配音系统,无框架依赖
- **ASR**:讯飞开放平台 录音文件转写标准版(已有 API Key,与 doco 共用同一套讯飞凭证)
- **AI**:LLM 用于两处——① 从 A 稿提取专有名词词典;② ASR 稿校对(的地得、引号、错别字)
- **输出格式**:SRT(大洋系统兼容格式,有样本参照)
@@ -62,14 +62,17 @@
## 3. 当前进度
- **已完成至**脚本版流水线 v3 跑通——绝对时间戳 + 严格校对(只改错别字/术语/填充词,不润色不调序)+ AI折行 + 5段SRT
- **正在做**:无,等制片人周一在大洋系统验证 SRT 导入效果
- **卡点/待解**:无硬卡点。可选优化:用 `--audio` 跑真实 ASR(热词注入生效)看是否在转写层就避免"建制→舰只"类错误
- **已完成至**腾讯云部署完成 + 审稿台(含查找替换)上线。流水线 v6 + Web 审稿台 + WAV 自动转码。进入内测
- **正在做**:无(等待内测反馈)
- **卡点/待解**:无硬卡点。已知残留:ASR 切句边界跨越固定搭配(如"第二次/世界大战")暂无法修复——需要跨句拆词检测(可做但需更大短语词典)
---
## 4. 已完成(只追加,最新在上)
- [2026-07-05] **腾讯云部署 + 审稿台上线**:① deploy/cca_route.pyFlask 蓝图,6 个 API 端点:upload/status/review/save/generate/download);② deploy/cca.html(4 步单页流程:上传→处理→审稿→下载,暗色主题匹配配音系统);③ 审稿台功能:左栏 ASR 原文 vs 右栏 AI 校对稿对比、逐句编辑确认、仅看修改过滤、全部确认、**查找替换**(Ctrl+H,支持逐个/全部替换+高亮定位);④ WAV 大文件自动 ffmpeg 转 MP3(解决讯飞上传超时);⑤ 服务器架构:Nginx→静态 HTML + Flask:5000CCA 源码在 `/workspace/military_tech_voice/backend/cca_src/`,蓝图注册在 `app/routes/cca.py`。首页已加"唱词助手"入口按钮。
- [2026-07-05] **脚本版流水线 v6(14项审片修复+专家段识别)**:① term_normalizer 新增:波浪号→"到"、顿号→空格、小数点保留(0.9马赫)、同音字映射表(建制→舰只/沉默→沉没/继承→击沉)、引号上下文感知(日向号加引号但日向级不加)、书名号补全(《军事科技》);② ai_proofreader 新增:speaker 角色自动识别(解说/主持/专家)、专家采访增强 Prompt(严格删除嗯/呃/啊/这个/那么/就是说等口头语)、的地得规则大幅加强(+大量示例)、数字照抄 ASR 规则;③ ai_line_breaker 新增:引号不跨屏后处理(≤6字引号内容不拆两行)、极短行合并(≤3字+时长<1秒→并入相邻行)、极短句合并间隔放宽(≤4字句间隔阈值1200ms);④ line_breaker 修改:clean_punctuation 保留小数点、顿号→空格;⑤ pipeline 新增 Step 2.5 校对后二次正则修复(兜住 AI 校对引入的新问题)。
- [2026-07-04] **脚本版流水线 v5(四层纠错体系+折行优化)**:基于制片人逐帧审片反馈,解决 10+ 问题。新增四层纠错:① term_normalizer.py 正则层(型号短横线 F-15J/武器昵称引号"鱼鹰"/中文数字修复,零token);② 校对 Prompt 升级(+代词他→它、+的地得纠错、+A稿权重规则);③ 折行 Prompt 升级(+禁忌字规则"的了着过"不开头、+主谓宾拆分规则、+不可拆词示例);④ 折行后处理三层(超长切分→禁忌字修复→拆词检测 _fix_split_words)。新增短句合并预处理(解决专家气口碎片句问题)。
- [2026-07-04] **脚本版流水线 v3(绝对时间戳+严格校对)**:① 恢复绝对时间戳(方便在大洋时间线对位);② 重写校对 Prompt——铁律:只改错别字/同音字、术语格式、口语填充词,绝不润色/调序/替换实词;③ 校对效果:60 处修正(vs v2 的 100 处,去掉了过度修改)。输出目录 `output_v3/`
- [2026-07-04] **脚本版流水线 v2 真实测试通过**:① 热词提取(规则+AI,127个术语)→ ② 讯飞ASR(94秒完成25MB音频,357句)→ ③ AI校对(DeepSeek,修正同音字/术语格式/口语填充,"建制"→"舰只"等)→ ④ AI折行(语义断句,98%行≤14字)→ ⑤ 5段SRT输出(段内相对时间戳,从00:00:00开始)。新增:hotword_extractor.py(热词提取)、ai_proofreader.pyAI校对)、ai_line_breaker.pyAI折行)。
- [2026-07-04] 脚本版流水线骨架完成:① asr_client.py(讯飞ASR适配,从doco复用);② line_breaker.py(折行引擎,≤14字/语义断句/空白行检测);③ srt_writer.py(大洋格式SRT输出);④ segment_splitter.py(节目结构切分:导视/正片×3/预告);⑤ cca_pipeline.py(主入口串联全流程)。本地测试全部通过。
@@ -80,12 +83,15 @@
## 5. 待办(按优先级)
- [x] ~~PRD / 业务规则确认~~ → 已在对话中完成(2026-07-04
- [x] ~~脚本版流水线~~ → v3 完成(绝对时间戳+严格校对),等大洋验证
- [x] ~~AI 校对层~~ → 已实现(层防线:热词注入+DeepSeek 严格校对
- [ ] **大洋系统验证**:周一导入 SRT 测试兼容性
- [x] ~~脚本版流水线~~ → v5 完成(四层纠错+折行优化+短句合并)
- [x] ~~AI 校对层~~ → 已实现(层防线:热词→正则→AI校对→折行后处理
- [x] ~~制片人审片第一轮~~ → 10+ 问题全部解决
- [x] ~~编导审稿台~~ → 已完成(查找替换+逐句对比+编辑确认,2026-07-05
- [x] ~~部署至腾讯云~~ → 已完成(http://101.42.29.217/cca.html2026-07-05
- [ ] **内测反馈收集**:同事试用中,等待反馈
- [ ] **大洋系统验证**:导入 SRT 测试兼容性
- [ ] **热词注入真实 ASR 测试**:用 `--audio` 跑完整流水线(非缓存),验证热词在转写层的效果
- [ ] **编导审稿台**:双屏对比 UI、差异高亮、编导确认/手改交互(第二步
- [ ] **部署至 lanhao 配音 2.0**:先跑起来测试
- [ ] **首页入口按钮可能被遮挡**index.html 已添加代码但可能需要样式调整(Ctrl+F5 刷新后可见
---
@@ -118,6 +124,11 @@
- [2026-07-04] **AI 校对严格纪律**:只允许改三类——① 错别字/同音字 ② 术语格式(F-15J)③ 口语填充词删除。绝不润色、绝不调序、绝不替换实词。ASR 是已录音频的转写,改不了内容。
- [2026-07-04] **两层 ASR 纠错防线**:第一层=热词注入(预防,让讯飞在转写时就认对专有名词);第二层=AI 校对(修正,用 A 稿上下文判断同音字)。两层互补。
- [2026-07-04] **LLM 选型已定**:校对+折行+热词提取统一用 DeepSeekdeepseek-chat),性价比最优。
- [2026-07-04] **四层纠错体系**(v5 确立):① 热词注入(讯飞ASR层,预防中文同音字)→ ② term_normalizer 正则后处理(型号短横线/引号/中文数字,零token确定性替换)→ ③ AI 校对(DeepSeek,同音字/代词/的地得/术语/填充词)→ ④ 折行后处理(超长切分+禁忌字修复+拆词检测)。
- [2026-07-04] **折行三条铁律**:① 词语不可拆分到两屏 ② "的了着过地得和与及或"不能作为新行第一个字 ③ 主谓宾优先折为"主语(折行)谓语+宾语"。
- [2026-07-04] **短句合并策略**:ASR 按音频静音切句,专家气口会产生碎片短句(2-5字)。折行前先合并:≤8字+间隔<800ms→合并为一个语义单元再送AI折行。>2s静音仍插空白行。
- [2026-07-04] **A稿与ASR权重规则**:内容冲突时ASR优先(配音员可能改过措辞),但专有名词格式/写法按A稿(如F-35A、"鱼鹰"引号)。
- [2026-07-04] **国家代词不改**:指代国家时口语用"他"是可接受的,不纠正;只纠正指代武器/舰艇/飞机/导弹时的"他→它"。
---
@@ -127,13 +138,13 @@
- **"拍词"术语解释**:折行稿(去标点、按规则断行的文稿)+ 时间戳对位 = 拍词。传统靠人工实时听拍,CCA 用 ASR 时间戳代替。
- **与 doco 的区别**doco 是"播出后"整理终版文稿(三方融合);CCA 是"剪辑后、播出前"生成唱词字幕(ASR→校对→SRT)。两者共用讯飞 ASR 能力,但流程目的完全不同。
- **数据样本**`data/` 下有 A 稿 docx + mp3 音频 + 3 个人工拍词 SRT(对应正片三段)。
- **代码文件**`src/` 下是核心流水线代码(asr_client / line_breaker / ai_line_breaker / ai_proofreader / srt_writer / segment_splitter / hotword_extractor),入口 `cca_pipeline.py`
- **凭证**:需在 `cca/.env` 中填写 `XFYUN_APP_ID``XFYUN_SECRET_KEY``DEEPSEEK_API_KEY`
- **输出目录**`output/`ASR 缓存 + v1 输出)、`output_v2/`(相对时间戳版)、`output_v3/`(绝对时间戳+严格校对,当前最优)
- **代码文件**`src/` 下是核心流水线代码(asr_client / line_breaker / ai_line_breaker / ai_proofreader / srt_writer / segment_splitter / hotword_extractor / term_normalizer),入口 `cca_pipeline.py``deploy/` 下是部署文件(cca_route.py Flask 蓝图 + cca.html 前端页面 + deploy_to_server.py 部署脚本)。
- **凭证**本地需在 `cca/.env` 中填写;服务器凭证在 `/workspace/military_tech_voice/backend/.env`(讯飞大号 + DeepSeek
- **服务器架构**:腾讯云 101.42.29.217Nginx:80 → 静态文件(/var/www/voice/) + Flask:5000 代理(/api/)。CCA 源码部署在 `/workspace/military_tech_voice/backend/cca_src/`,任务数据在 `cca_data/`。Flask 无 systemd 服务,重启方式:`fuser -k 5000/tcp && cd backend && source venv/bin/activate && nohup python3 -m app.main > /tmp/flask_cca.log 2>&1 &`
- **输出目录**`output/`ASR 缓存 + v1 输出)、`output_v2/`~`output_v6/`(各版本输出)。
- **运行命令示例**
- 从缓存跑(调试校对/折行):`python -X utf8 cca_pipeline.py --asr-cache output/asr_raw.json --script "data/重走战争老路的日本军备(A稿).docx" --output-dir output_v3`
- 完整流水线(含真实 ASR):`python -X utf8 cca_pipeline.py --audio "data/重走战争老路的日本军备A0.mp3" --script "data/重走战争老路的日本军备(A稿).docx" --output-dir output_v4`
- **时间压力**:这两天要出可用版本,功能不复杂但要快。
- 从缓存跑(调试校对/折行):`python -X utf8 cca_pipeline.py --asr-cache output/asr_raw.json --script "data/重走战争老路的日本军备(A稿).docx" --output-dir output_v6`
- 完整流水线(含真实 ASR):`python -X utf8 cca_pipeline.py --audio "data/重走战争老路的日本军备A0.mp3" --script "data/重走战争老路的日本军备(A稿).docx" --output-dir output_v7`
---
@@ -143,5 +154,7 @@
- [x] ~~大洋 SRT 样本文件~~ → data/ 下已有 3 个真实样本
- [x] ~~音频格式~~ → 纯人声 MP3,无需预处理
- [x] ~~LLM 选型~~ → DeepSeekdeepseek-chat),已验证效果好、价格低
- [ ] 前端审稿台技术选型(第二步再定
- [ ] 大洋系统 SRT 导入兼容性(周一验证
- [x] ~~的地得纠错~~ → 已加入校对 Promptv5
- [x] ~~前端审稿台技术选型~~ → 纯 HTML/JS 单页,无框架(2026-07-05
- [ ] 大洋系统 SRT 导入兼容性(待验证)
- [ ] 跨句固定搭配拆词("第二次/世界大战"类问题,需大短语词典,优先级低)
+12
View File
@@ -34,6 +34,7 @@ from srt_writer import write_srt, ms_to_srt_time
from segment_splitter import split_into_segments
from hotword_extractor import extract_hotwords
from ai_proofreader import proofread_batch
from term_normalizer import normalize_terms
def main():
@@ -104,6 +105,11 @@ def main():
print(f"[流水线] ASR 共 {len(sentences)}")
# ====== Step 1.5: 术语格式化(正则后处理,不耗 token)======
if script_text:
print("[流水线] 术语格式化(型号短横线/武器昵称引号/中文数字)...")
sentences = normalize_terms(sentences, script_text)
# ====== Step 2: AI 校对 ======
if use_ai and not args.no_proofread and script_text:
print("[流水线] AI 校对中 (DeepSeek)...")
@@ -111,6 +117,12 @@ def main():
elif not script_text and not args.no_proofread and use_ai:
print("[流水线] 未提供A稿(--script),跳过AI校对")
# ====== Step 2.5: 校对后二次正则修复(兜住AI校对引入的新问题)======
if script_text:
from term_normalizer import normalize_terms as post_normalize
print("[流水线] 校对后二次正则修复...")
sentences = post_normalize(sentences, script_text)
# ====== Step 3: 节目结构切分 ======
print("[流水线] 切分节目结构...")
segments = split_into_segments(sentences)
+910
View File
@@ -0,0 +1,910 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CCA 唱词助手 - 军事科技</title>
<link rel="stylesheet" href="styles.css">
<style>
/* CCA 专用样式 */
.cca-container {
max-width: 1200px;
margin: 0 auto;
padding: 16px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.step-indicator {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 24px;
padding: 12px;
}
.step-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--border-color);
transition: all 0.3s;
}
.step-dot.active { background: var(--accent-primary); transform: scale(1.3); }
.step-dot.done { background: var(--success); }
.step-panel { display: none; flex: 1; flex-direction: column; }
.step-panel.active { display: flex; }
/* 上传区 */
.upload-zone {
border: 2px dashed var(--border-color);
border-radius: var(--radius-lg);
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: var(--bg-secondary);
margin-bottom: 16px;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: var(--accent-primary);
background: rgba(99,102,241,0.05);
}
.upload-zone.has-file {
border-color: var(--success);
border-style: solid;
}
.upload-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.6; }
.upload-label { color: var(--text-secondary); font-size: 0.95rem; margin-bottom: 4px; }
.upload-hint { color: var(--text-muted); font-size: 0.8rem; }
.upload-filename {
color: var(--success); font-weight: 600;
font-size: 1rem; margin-top: 8px;
}
.upload-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
/* 进度 */
.progress-container {
text-align: center;
padding: 60px 20px;
}
.progress-spinner {
width: 60px; height: 60px;
border: 4px solid var(--border-color);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 24px;
}
.progress-text {
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 8px;
}
.progress-detail { color: var(--text-muted); font-size: 0.85rem; }
/* 审稿台 */
.review-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.review-stats { display: flex; gap: 16px; font-size: 0.85rem; }
.review-stats span { color: var(--text-muted); }
.review-stats .count { color: var(--accent-secondary); font-weight: 600; }
.review-stats .changed { color: var(--warning); }
.review-controls {
display: flex; gap: 8px;
}
.review-list {
flex: 1;
overflow-y: auto;
max-height: calc(100vh - 320px);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-secondary);
}
.review-item {
display: grid;
grid-template-columns: 60px 1fr 1fr 80px;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border-color);
align-items: center;
font-size: 0.9rem;
transition: background 0.15s;
}
.review-item:hover { background: var(--bg-tertiary); }
.review-item.changed { background: rgba(245,158,11,0.05); }
.review-item.confirmed { opacity: 0.7; }
.review-time {
font-size: 0.75rem;
color: var(--text-muted);
font-family: monospace;
}
.review-original {
color: var(--text-muted);
font-size: 0.85rem;
}
.review-edited {
color: var(--text-primary);
padding: 4px 8px;
background: var(--bg-tertiary);
border: 1px solid transparent;
border-radius: 4px;
outline: none;
font-size: 0.9rem;
font-family: inherit;
width: 100%;
}
.review-edited:focus {
border-color: var(--accent-primary);
background: var(--bg-card);
}
.review-item.changed .review-original {
text-decoration: line-through;
color: var(--error);
}
.review-item.changed .review-edited {
color: var(--success);
}
.review-confirm-btn {
width: 28px; height: 28px;
border: 2px solid var(--border-color);
border-radius: 4px;
background: transparent;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: var(--text-muted);
transition: all 0.15s;
}
.review-confirm-btn.checked {
background: var(--success);
border-color: var(--success);
color: white;
}
.review-confirm-btn:hover { border-color: var(--success); }
.review-legend {
display: flex; gap: 16px; font-size: 0.8rem; color: var(--text-muted);
padding: 8px 0;
}
.legend-dot {
display: inline-block; width: 8px; height: 8px;
border-radius: 50%; margin-right: 4px; vertical-align: middle;
}
/* 查找替换面板 */
.replace-panel {
display: none;
background: var(--bg-secondary);
border: 1px solid var(--accent-primary);
border-radius: var(--radius-md);
padding: 16px;
margin-bottom: 12px;
animation: fadeIn 0.2s;
}
.replace-panel.active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
.replace-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.replace-row:last-child { margin-bottom: 0; }
.replace-label {
font-size: 0.8rem;
color: var(--text-muted);
width: 40px;
flex-shrink: 0;
}
.replace-input {
flex: 1;
padding: 6px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 0.85rem;
font-family: inherit;
outline: none;
}
.replace-input:focus { border-color: var(--accent-primary); }
.replace-actions {
display: flex;
gap: 6px;
align-items: center;
}
.replace-btn {
padding: 5px 10px;
border-radius: 4px;
font-size: 0.78rem;
cursor: pointer;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-secondary);
transition: all 0.15s;
white-space: nowrap;
}
.replace-btn:hover { border-color: var(--accent-primary); color: var(--text-primary); }
.replace-btn.primary { background: var(--accent-primary); color: white; border-color: var(--accent-primary); }
.replace-btn.primary:hover { opacity: 0.85; }
.replace-btn.danger { background: var(--warning); color: white; border-color: var(--warning); }
.replace-btn.danger:hover { opacity: 0.85; }
.replace-info {
font-size: 0.78rem;
color: var(--text-muted);
margin-left: 4px;
}
.replace-highlight {
background: rgba(245,158,11,0.3);
border-radius: 2px;
padding: 0 1px;
}
.replace-current {
background: rgba(99,102,241,0.4);
outline: 2px solid var(--accent-primary);
border-radius: 2px;
padding: 0 1px;
}
/* 过滤按钮 */
.filter-btn {
padding: 6px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
}
.filter-btn:hover { border-color: var(--accent-primary); color: var(--text-primary); }
.filter-btn.active { background: var(--accent-primary); color: white; border-color: var(--accent-primary); }
/* 完成页 */
.done-container { text-align: center; padding: 60px 20px; }
.done-icon { font-size: 64px; margin-bottom: 16px; }
.done-title { font-size: 1.3rem; margin-bottom: 8px; }
.done-detail { color: var(--text-muted); margin-bottom: 24px; }
/* 错误 */
.error-box {
background: rgba(239,68,68,0.1);
border: 1px solid var(--error);
border-radius: var(--radius-md);
padding: 16px;
color: var(--error);
text-align: center;
margin-top: 16px;
}
.back-link {
color: var(--text-muted);
text-decoration: none;
font-size: 0.85rem;
}
.back-link:hover { color: var(--text-primary); }
.cca-badge {
padding: 8px 16px;
background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
color: white;
font-size: 0.875rem;
font-weight: 600;
border-radius: var(--radius-full);
}
@media (max-width: 768px) {
.upload-grid { grid-template-columns: 1fr; }
.review-item { grid-template-columns: 50px 1fr 40px; }
.review-original { display: none; }
}
</style>
</head>
<body>
<div class="cca-container">
<header class="header">
<div class="logo">
<a href="index.html" class="back-link" style="margin-right:12px">← 返回配音</a>
<span class="logo-text">CCA <span class="logo-sub">唱词助手</span></span>
</div>
<div class="header-info">
<span class="cca-badge">唱词字幕</span>
</div>
</header>
<!-- 步骤指示器 -->
<div class="step-indicator">
<div class="step-dot active" id="dot-0"></div>
<div class="step-dot" id="dot-1"></div>
<div class="step-dot" id="dot-2"></div>
<div class="step-dot" id="dot-3"></div>
</div>
<!-- Step 0: 上传 -->
<div class="step-panel active" id="step-upload">
<div class="panel" style="flex:1; padding:24px;">
<h2 style="margin-bottom:8px;">上传素材</h2>
<p style="color:var(--text-muted); font-size:0.85rem; margin-bottom:24px;">
上传编导 A 稿和粗编人声音频,AI 将自动完成 ASR 转写、术语校对、的地得纠错和口头语清除
</p>
<div class="upload-grid">
<div class="upload-zone" id="zone-audio" onclick="document.getElementById('input-audio').click()">
<div class="upload-icon">&#127908;</div>
<div class="upload-label">人声音频</div>
<div class="upload-hint">MP3 / WAV,纯人声(必传)</div>
<div class="upload-filename" id="name-audio"></div>
<input type="file" id="input-audio" accept=".mp3,.wav,.m4a" style="display:none">
</div>
<div class="upload-zone" id="zone-script" onclick="document.getElementById('input-script').click()">
<div class="upload-icon">&#128196;</div>
<div class="upload-label">A 稿文件</div>
<div class="upload-hint">DOCX / TXT(强烈建议上传,用于术语校对)</div>
<div class="upload-filename" id="name-script"></div>
<input type="file" id="input-script" accept=".docx,.doc,.txt" style="display:none">
</div>
</div>
<div class="action-buttons" style="justify-content:center;">
<button class="btn btn-primary" id="btn-start" disabled style="max-width:300px;">
开始处理
</button>
</div>
</div>
</div>
<!-- Step 1: 处理中 -->
<div class="step-panel" id="step-processing">
<div class="panel" style="flex:1;">
<div class="progress-container">
<div class="progress-spinner"></div>
<div class="progress-text" id="progress-text">准备中...</div>
<div class="progress-detail" id="progress-detail">请勿关闭页面,ASR 转写通常需要 1-2 分钟</div>
</div>
</div>
</div>
<!-- Step 2: 审稿台 -->
<div class="step-panel" id="step-review">
<div class="review-header">
<div>
<span style="font-weight:600;">审稿台</span>
<span class="review-stats" style="margin-left:16px;">
总计 <span class="count" id="stat-total">0</span> 句 |
AI 修改 <span class="changed" id="stat-changed">0</span> 处 |
已确认 <span class="count" id="stat-confirmed">0</span>
</span>
</div>
<div class="review-controls">
<button class="filter-btn active" id="filter-all" onclick="filterItems('all')">全部</button>
<button class="filter-btn" id="filter-changed" onclick="filterItems('changed')">仅看修改</button>
<button class="replace-btn" onclick="toggleReplace()" id="btn-toggle-replace" title="Ctrl+H">查找替换</button>
<button class="btn btn-secondary" style="padding:6px 12px; font-size:0.8rem;" onclick="confirmAll()">全部确认</button>
<button class="btn btn-primary" id="btn-generate" style="padding:6px 16px; font-size:0.85rem;">
生成 SRT
</button>
</div>
</div>
<!-- 查找替换面板 -->
<div class="replace-panel" id="replace-panel">
<div class="replace-row">
<span class="replace-label">查找</span>
<input class="replace-input" id="replace-find" placeholder="输入要查找的文字..." oninput="onFindInput()">
<div class="replace-actions">
<span class="replace-info" id="replace-match-info"></span>
<button class="replace-btn" onclick="findPrev()" title="上一个">&#9650;</button>
<button class="replace-btn" onclick="findNext()" title="下一个">&#9660;</button>
</div>
</div>
<div class="replace-row">
<span class="replace-label">替换</span>
<input class="replace-input" id="replace-to" placeholder="替换为...">
<div class="replace-actions">
<button class="replace-btn primary" onclick="replaceCurrent()">替换当前</button>
<button class="replace-btn danger" onclick="replaceAll()">全部替换</button>
<button class="replace-btn" onclick="toggleReplace()">关闭</button>
</div>
</div>
</div>
<div class="review-legend">
<span><span class="legend-dot" style="background:var(--warning);"></span>AI 已修改</span>
<span><span class="legend-dot" style="background:var(--success);"></span>已确认</span>
<span>点击文字可直接编辑,右侧勾选确认</span>
</div>
<div class="review-list" id="review-list"></div>
</div>
<!-- Step 3: 完成 -->
<div class="step-panel" id="step-done">
<div class="panel" style="flex:1;">
<div class="done-container">
<div class="done-icon">&#9989;</div>
<div class="done-title">SRT 字幕文件生成完成</div>
<div class="done-detail" id="done-detail"></div>
<div class="action-buttons" style="justify-content:center; gap:12px;">
<button class="btn btn-primary" id="btn-download" style="max-width:200px;">
下载字幕包
</button>
<button class="btn btn-secondary" onclick="location.reload()" style="max-width:200px;">
处理下一期
</button>
</div>
</div>
</div>
</div>
<!-- 错误展示 -->
<div class="error-box" id="error-box" style="display:none;"></div>
</div>
<script>
const API = '/api/cca';
let currentTaskId = null;
let reviewData = [];
let currentFilter = 'all';
let pollTimer = null;
// === 文件上传 ===
const audioInput = document.getElementById('input-audio');
const scriptInput = document.getElementById('input-script');
const btnStart = document.getElementById('btn-start');
audioInput.addEventListener('change', () => {
const f = audioInput.files[0];
if (f) {
document.getElementById('name-audio').textContent = f.name;
document.getElementById('zone-audio').classList.add('has-file');
}
checkReady();
});
scriptInput.addEventListener('change', () => {
const f = scriptInput.files[0];
if (f) {
document.getElementById('name-script').textContent = f.name;
document.getElementById('zone-script').classList.add('has-file');
}
});
// 拖拽
['zone-audio', 'zone-script'].forEach(id => {
const zone = document.getElementById(id);
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
const inputId = id === 'zone-audio' ? 'input-audio' : 'input-script';
const input = document.getElementById(inputId);
input.files = e.dataTransfer.files;
input.dispatchEvent(new Event('change'));
});
});
function checkReady() {
btnStart.disabled = !audioInput.files[0];
}
btnStart.addEventListener('click', async () => {
if (!audioInput.files[0]) return;
const formData = new FormData();
formData.append('audio', audioInput.files[0]);
if (scriptInput.files[0]) formData.append('script', scriptInput.files[0]);
btnStart.disabled = true;
btnStart.textContent = '上传中...';
try {
const res = await fetch(`${API}/upload`, { method: 'POST', body: formData });
const data = await res.json();
if (data.error) { showError(data.error); return; }
currentTaskId = data.task_id;
goToStep(1);
startPolling();
} catch (e) {
showError('上传失败: ' + e.message);
}
});
// === 轮询状态 ===
function startPolling() {
pollTimer = setInterval(async () => {
try {
const res = await fetch(`${API}/status/${currentTaskId}`);
const data = await res.json();
document.getElementById('progress-text').textContent = data.progress || '处理中...';
if (data.status === 'review') {
clearInterval(pollTimer);
await loadReview();
goToStep(2);
} else if (data.status === 'error') {
clearInterval(pollTimer);
showError(data.error || '处理出错');
}
} catch (e) {
console.error('poll error', e);
}
}, 2000);
}
// === 审稿台 ===
async function loadReview() {
const res = await fetch(`${API}/review/${currentTaskId}`);
const data = await res.json();
reviewData = data.items;
renderReview();
}
function msToTime(ms) {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
const ss = String(s % 60).padStart(2, '0');
const mm = String(m % 60).padStart(2, '0');
const hh = String(h).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
function renderReview() {
const list = document.getElementById('review-list');
list.innerHTML = '';
let totalChanged = 0;
let totalConfirmed = 0;
reviewData.forEach((item, i) => {
if (item.has_change) totalChanged++;
if (item.confirmed) totalConfirmed++;
if (currentFilter === 'changed' && !item.has_change) return;
const row = document.createElement('div');
row.className = 'review-item' + (item.has_change ? ' changed' : '') + (item.confirmed ? ' confirmed' : '');
row.dataset.index = i;
row.innerHTML = `
<div class="review-time">${msToTime(item.start_ms)}</div>
<div class="review-original">${escHtml(item.original)}</div>
<input class="review-edited" value="${escAttr(item.edited)}" data-idx="${i}"
onfocus="this.parentElement.classList.remove('confirmed')"
onblur="onEditBlur(this)">
<button class="review-confirm-btn ${item.confirmed ? 'checked' : ''}"
onclick="toggleConfirm(${i}, this)" title="确认">
${item.confirmed ? '&#10003;' : ''}
</button>
`;
list.appendChild(row);
});
document.getElementById('stat-total').textContent = reviewData.length;
document.getElementById('stat-changed').textContent = totalChanged;
document.getElementById('stat-confirmed').textContent = totalConfirmed;
}
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function escAttr(s) { return s.replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
function onEditBlur(input) {
const idx = parseInt(input.dataset.idx);
reviewData[idx].edited = input.value;
reviewData[idx].confirmed = true;
updateStats();
autoSave();
const keyword = document.getElementById('replace-find') ? document.getElementById('replace-find').value : '';
if (keyword) {
onFindInput();
} else {
renderReview();
}
}
function toggleConfirm(idx, btn) {
reviewData[idx].confirmed = !reviewData[idx].confirmed;
btn.classList.toggle('checked');
btn.innerHTML = reviewData[idx].confirmed ? '&#10003;' : '';
btn.parentElement.classList.toggle('confirmed', reviewData[idx].confirmed);
updateStats();
autoSave();
}
function confirmAll() {
reviewData.forEach(item => item.confirmed = true);
renderReview();
autoSave();
}
function filterItems(filter) {
currentFilter = filter;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
document.getElementById('filter-' + filter).classList.add('active');
renderReview();
}
function updateStats() {
let confirmed = reviewData.filter(i => i.confirmed).length;
document.getElementById('stat-confirmed').textContent = confirmed;
}
let saveTimer = null;
function autoSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
fetch(`${API}/save/${currentTaskId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: reviewData }),
}).catch(console.error);
}, 1000);
}
// === 生成 SRT ===
document.getElementById('btn-generate').addEventListener('click', async () => {
const btn = document.getElementById('btn-generate');
btn.disabled = true;
btn.textContent = '生成中...';
// 先保存最新编辑
await fetch(`${API}/save/${currentTaskId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: reviewData }),
});
try {
const res = await fetch(`${API}/generate/${currentTaskId}`, { method: 'POST' });
const data = await res.json();
if (data.error) { showError(data.error); btn.disabled = false; btn.textContent = '生成 SRT'; return; }
document.getElementById('done-detail').textContent = data.message;
goToStep(3);
} catch (e) {
showError('生成失败: ' + e.message);
btn.disabled = false;
btn.textContent = '生成 SRT';
}
});
// === 下载 ===
document.getElementById('btn-download').addEventListener('click', () => {
window.location.href = `${API}/download/${currentTaskId}`;
});
// === 步骤控制 ===
function goToStep(n) {
document.querySelectorAll('.step-panel').forEach(p => p.classList.remove('active'));
const panels = ['step-upload', 'step-processing', 'step-review', 'step-done'];
document.getElementById(panels[n]).classList.add('active');
for (let i = 0; i < 4; i++) {
const dot = document.getElementById('dot-' + i);
dot.className = 'step-dot' + (i === n ? ' active' : (i < n ? ' done' : ''));
}
document.getElementById('error-box').style.display = 'none';
}
function showError(msg) {
const box = document.getElementById('error-box');
box.textContent = msg;
box.style.display = 'block';
}
// === 查找替换 ===
let findMatches = []; // [{idx, pos}] — idx=reviewData index, pos=match position in text
let findCursor = -1;
function toggleReplace() {
const panel = document.getElementById('replace-panel');
const btn = document.getElementById('btn-toggle-replace');
panel.classList.toggle('active');
btn.classList.toggle('active');
if (panel.classList.contains('active')) {
document.getElementById('replace-find').focus();
} else {
clearFindHighlights();
findMatches = [];
findCursor = -1;
document.getElementById('replace-match-info').textContent = '';
}
}
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'h') {
e.preventDefault();
const panel = document.getElementById('replace-panel');
if (!panel.classList.contains('active') && document.getElementById('step-review').classList.contains('active')) {
toggleReplace();
} else if (panel.classList.contains('active')) {
document.getElementById('replace-find').focus();
}
}
});
function onFindInput() {
const keyword = document.getElementById('replace-find').value;
if (!keyword) {
findMatches = [];
findCursor = -1;
document.getElementById('replace-match-info').textContent = '';
renderReview();
return;
}
findMatches = [];
reviewData.forEach((item, idx) => {
let pos = 0;
const text = item.edited;
while (true) {
const found = text.indexOf(keyword, pos);
if (found === -1) break;
findMatches.push({ idx, pos: found });
pos = found + keyword.length;
}
});
findCursor = findMatches.length > 0 ? 0 : -1;
updateFindInfo();
renderReview();
scrollToCurrentMatch();
}
function findNext() {
if (findMatches.length === 0) return;
findCursor = (findCursor + 1) % findMatches.length;
updateFindInfo();
renderReview();
scrollToCurrentMatch();
}
function findPrev() {
if (findMatches.length === 0) return;
findCursor = (findCursor - 1 + findMatches.length) % findMatches.length;
updateFindInfo();
renderReview();
scrollToCurrentMatch();
}
function updateFindInfo() {
const info = document.getElementById('replace-match-info');
if (findMatches.length === 0) {
info.textContent = '无匹配';
info.style.color = 'var(--error)';
} else {
info.textContent = `${findCursor + 1} / ${findMatches.length}`;
info.style.color = 'var(--text-muted)';
}
}
function replaceCurrent() {
if (findCursor < 0 || findCursor >= findMatches.length) return;
const keyword = document.getElementById('replace-find').value;
const replacement = document.getElementById('replace-to').value;
if (!keyword) return;
const match = findMatches[findCursor];
const item = reviewData[match.idx];
item.edited = item.edited.substring(0, match.pos) + replacement + item.edited.substring(match.pos + keyword.length);
item.confirmed = true;
onFindInput();
autoSave();
}
function replaceAll() {
const keyword = document.getElementById('replace-find').value;
const replacement = document.getElementById('replace-to').value;
if (!keyword) return;
if (findMatches.length === 0) return;
const count = findMatches.length;
const affectedIdxs = new Set(findMatches.map(m => m.idx));
affectedIdxs.forEach(idx => {
const item = reviewData[idx];
item.edited = item.edited.split(keyword).join(replacement);
item.confirmed = true;
});
onFindInput();
autoSave();
document.getElementById('replace-match-info').textContent = `已替换 ${count}`;
document.getElementById('replace-match-info').style.color = 'var(--success)';
}
function clearFindHighlights() {
renderReview();
}
function scrollToCurrentMatch() {
if (findCursor < 0 || findCursor >= findMatches.length) return;
const match = findMatches[findCursor];
const row = document.querySelector(`.review-item[data-index="${match.idx}"]`);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// 重写 renderReview 支持查找高亮
const _origRenderReview = renderReview;
renderReview = function() {
const list = document.getElementById('review-list');
list.innerHTML = '';
let totalChanged = 0;
let totalConfirmed = 0;
const keyword = document.getElementById('replace-find') ? document.getElementById('replace-find').value : '';
const currentMatch = findCursor >= 0 && findCursor < findMatches.length ? findMatches[findCursor] : null;
// 建一个快速查找表:哪些 (idx, pos) 是当前光标
let globalMatchIdx = 0;
reviewData.forEach((item, i) => {
if (item.has_change) totalChanged++;
if (item.confirmed) totalConfirmed++;
if (currentFilter === 'changed' && !item.has_change) return;
const row = document.createElement('div');
row.className = 'review-item' + (item.has_change ? ' changed' : '') + (item.confirmed ? ' confirmed' : '');
row.dataset.index = i;
// 高亮匹配关键词
let editedDisplay = escHtml(item.edited);
if (keyword && item.edited.includes(keyword)) {
let result = '';
let searchPos = 0;
const text = item.edited;
while (true) {
const found = text.indexOf(keyword, searchPos);
if (found === -1) {
result += escHtml(text.substring(searchPos));
break;
}
result += escHtml(text.substring(searchPos, found));
const isCurrent = currentMatch && currentMatch.idx === i && currentMatch.pos === found;
result += `<span class="${isCurrent ? 'replace-current' : 'replace-highlight'}">${escHtml(keyword)}</span>`;
searchPos = found + keyword.length;
}
editedDisplay = result;
}
row.innerHTML = `
<div class="review-time">${msToTime(item.start_ms)}</div>
<div class="review-original">${escHtml(item.original)}</div>
<div style="position:relative;">
<div class="review-edited-display" style="padding:4px 8px;font-size:0.9rem;min-height:1.5em;cursor:text;border:1px solid transparent;border-radius:4px;background:var(--bg-tertiary);"
onclick="startEdit(this)">${editedDisplay}</div>
<input class="review-edited" value="${escAttr(item.edited)}" data-idx="${i}" style="display:none;"
onblur="onEditBlur(this)">
</div>
<button class="review-confirm-btn ${item.confirmed ? 'checked' : ''}"
onclick="toggleConfirm(${i}, this)" title="确认">
${item.confirmed ? '&#10003;' : ''}
</button>
`;
list.appendChild(row);
});
document.getElementById('stat-total').textContent = reviewData.length;
document.getElementById('stat-changed').textContent = totalChanged;
document.getElementById('stat-confirmed').textContent = totalConfirmed;
};
function startEdit(displayDiv) {
displayDiv.style.display = 'none';
const input = displayDiv.nextElementSibling;
input.style.display = '';
input.focus();
}
</script>
</body>
</html>
+330
View File
@@ -0,0 +1,330 @@
# -*- coding: utf-8 -*-
"""
CCA 唱词助手 — Flask 路由
POST /api/cca/upload 上传 A稿+音频,启动流水线
GET /api/cca/status/<id> 轮询任务状态
GET /api/cca/review/<id> 获取审稿数据
POST /api/cca/save/<id> 保存编导修改
POST /api/cca/generate/<id> 生成最终 SRT
GET /api/cca/download/<id> 下载 SRT zip
"""
import json
import os
import sys
import uuid
import threading
import traceback
import zipfile
from io import BytesIO
from pathlib import Path
from datetime import datetime
from flask import Blueprint, request, jsonify, send_file
bp = Blueprint('cca', __name__, url_prefix='/api/cca')
# 运行时数据目录
CCA_DATA_DIR = Path('/workspace/military_tech_voice/backend/cca_data')
CCA_DATA_DIR.mkdir(parents=True, exist_ok=True)
# CCA 源码目录
CCA_SRC_DIR = Path('/workspace/military_tech_voice/backend/cca_src')
if str(CCA_SRC_DIR) not in sys.path:
sys.path.insert(0, str(CCA_SRC_DIR))
# 任务状态存储(内存,重启丢失无所谓——编导重新上传即可)
tasks = {}
def _get_task(task_id):
t = tasks.get(task_id)
if not t:
return None
return t
@bp.route('/upload', methods=['POST'])
def upload():
"""接收 A稿 + 音频,创建任务"""
if 'audio' not in request.files:
return jsonify({'error': '请上传音频文件'}), 400
audio_file = request.files['audio']
script_file = request.files.get('script')
if not audio_file.filename:
return jsonify({'error': '音频文件为空'}), 400
task_id = datetime.now().strftime('%Y%m%d_%H%M%S') + '_' + uuid.uuid4().hex[:6]
task_dir = CCA_DATA_DIR / task_id
task_dir.mkdir(parents=True, exist_ok=True)
# 保存音频
audio_ext = os.path.splitext(audio_file.filename)[1] or '.mp3'
audio_path = task_dir / f'audio{audio_ext}'
audio_file.save(str(audio_path))
# 保存 A稿
script_path = None
if script_file and script_file.filename:
script_ext = os.path.splitext(script_file.filename)[1] or '.docx'
script_path = task_dir / f'script{script_ext}'
script_file.save(str(script_path))
tasks[task_id] = {
'id': task_id,
'status': 'uploaded',
'progress': '文件已上传,准备处理...',
'audio_path': str(audio_path),
'script_path': str(script_path) if script_path else None,
'created_at': datetime.now().isoformat(),
'error': None,
'asr_sentences': None,
'proofread_sentences': None,
'review_data': None,
'final_srt_dir': None,
}
# 启动后台处理
thread = threading.Thread(target=_run_pipeline, args=(task_id,), daemon=True)
thread.start()
return jsonify({'task_id': task_id, 'status': 'processing'})
def _run_pipeline(task_id):
"""后台运行 CCA 流水线"""
task = tasks[task_id]
try:
task['status'] = 'processing'
task['progress'] = '正在提取热词...'
audio_path = task['audio_path']
script_path = task['script_path']
# WAV/大文件 → MP3 压缩(讯飞上传大文件容易超时)
if audio_path.lower().endswith('.wav'):
import subprocess
mp3_path = audio_path.rsplit('.', 1)[0] + '.mp3'
task['progress'] = '正在压缩音频(WAV→MP3...'
subprocess.run(
['ffmpeg', '-i', audio_path, '-b:a', '128k', '-y', mp3_path],
capture_output=True, timeout=300,
)
if os.path.exists(mp3_path) and os.path.getsize(mp3_path) > 0:
audio_path = mp3_path
task['audio_path'] = mp3_path
from hotword_extractor import extract_hotwords, read_docx_text, read_text_file
from asr_client import transcribe, parse_result
from term_normalizer import normalize_terms
from ai_proofreader import proofread_batch
from segment_splitter import split_into_segments
# Step 0: 热词提取
hot_words = []
script_text = ""
if script_path:
hot_words = extract_hotwords(script_path, use_ai=True)
ext = os.path.splitext(script_path)[1].lower()
if ext == '.docx':
script_text = read_docx_text(script_path)
else:
script_text = read_text_file(script_path)
task['progress'] = f'热词提取完成({len(hot_words)}个),正在 ASR 转写...'
# Step 1: ASR
sentences, raw_json = transcribe(audio_path, hot_words=hot_words if hot_words else None)
# 缓存 ASR 原始结果
task_dir = Path(audio_path).parent
cache_path = task_dir / 'asr_raw.json'
with open(cache_path, 'w', encoding='utf-8') as f:
f.write(raw_json)
task['progress'] = f'ASR 完成({len(sentences)}句),正在术语格式化...'
# 保存 ASR 原始句子(校对前,供 diff 对比)
asr_original = [(bg, ed, text, spk) for bg, ed, text, spk in sentences]
# Step 1.5: 术语格式化
if script_text:
sentences = normalize_terms(sentences, script_text)
task['progress'] = '术语格式化完成,正在 AI 校对...'
# Step 2: AI 校对
if script_text:
sentences = proofread_batch(sentences, script_text)
# Step 2.5: 校对后二次正则
if script_text:
sentences = normalize_terms(sentences, script_text)
task['progress'] = 'AI 校对完成,正在准备审稿数据...'
# 保存校对后的句子
task['asr_sentences'] = asr_original
task['proofread_sentences'] = sentences
# 构建审稿数据:逐句对比
review_items = []
for i, ((bg, ed, orig_text, spk), (_, _, proof_text, _)) in enumerate(
zip(asr_original, sentences)
):
has_change = orig_text != proof_text
review_items.append({
'index': i,
'start_ms': bg,
'end_ms': ed,
'speaker_id': spk,
'original': orig_text,
'corrected': proof_text,
'edited': proof_text,
'has_change': has_change,
'confirmed': not has_change,
})
task['review_data'] = review_items
task['status'] = 'review'
task['progress'] = '审稿数据就绪,请编导审阅确认'
# 同时保存到磁盘(防丢)
review_path = task_dir / 'review_data.json'
with open(review_path, 'w', encoding='utf-8') as f:
json.dump(review_items, f, ensure_ascii=False, indent=2)
except Exception as e:
task['status'] = 'error'
err_msg = str(e)
if '余额不足' in err_msg or 'insufficient' in err_msg.lower() or '10317' in err_msg:
task['error'] = '讯飞录音文件转写余额不足,请联系管理员充值'
else:
task['error'] = f'处理出错: {err_msg}'
task['progress'] = task['error']
traceback.print_exc()
@bp.route('/status/<task_id>', methods=['GET'])
def status(task_id):
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
return jsonify({
'task_id': task_id,
'status': task['status'],
'progress': task['progress'],
'error': task['error'],
})
@bp.route('/review/<task_id>', methods=['GET'])
def review(task_id):
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
if task['status'] not in ('review', 'completed'):
return jsonify({'error': '任务尚未就绪', 'status': task['status']}), 400
return jsonify({
'task_id': task_id,
'items': task['review_data'],
})
@bp.route('/save/<task_id>', methods=['POST'])
def save(task_id):
"""保存编导的修改(自动保存用)"""
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
data = request.get_json()
edits = data.get('items', [])
for edit in edits:
idx = edit.get('index')
if idx is not None and 0 <= idx < len(task['review_data']):
task['review_data'][idx]['edited'] = edit.get('edited', task['review_data'][idx]['edited'])
task['review_data'][idx]['confirmed'] = edit.get('confirmed', True)
return jsonify({'ok': True})
@bp.route('/generate/<task_id>', methods=['POST'])
def generate(task_id):
"""用编导确认后的文本生成最终 SRT"""
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
if task['status'] not in ('review', 'completed'):
return jsonify({'error': '任务状态不对'}), 400
try:
from ai_line_breaker import process_sentences_with_ai
from srt_writer import write_srt, ms_to_srt_time
from segment_splitter import split_into_segments
# 用编导确认后的文本重建句子列表
confirmed_sentences = []
for item in task['review_data']:
text = item['edited']
confirmed_sentences.append((
item['start_ms'], item['end_ms'], text, item['speaker_id']
))
# 切分节目结构
segments = split_into_segments(confirmed_sentences)
# 折行 + 生成 SRT
task_dir = Path(task['audio_path']).parent
srt_dir = task_dir / 'srt_output'
srt_dir.mkdir(exist_ok=True)
srt_files = []
for seg_name, seg_sentences in segments:
if not seg_sentences:
continue
subtitle_lines = process_sentences_with_ai(seg_sentences)
srt_path = srt_dir / f'{seg_name}.srt'
write_srt(subtitle_lines, str(srt_path))
srt_files.append(str(srt_path))
task['final_srt_dir'] = str(srt_dir)
task['status'] = 'completed'
task['progress'] = f'生成完成,共 {len(srt_files)} 个 SRT 文件'
return jsonify({
'ok': True,
'srt_count': len(srt_files),
'message': task['progress'],
})
except Exception as e:
return jsonify({'error': f'生成 SRT 出错: {str(e)}'}), 500
@bp.route('/download/<task_id>', methods=['GET'])
def download(task_id):
"""下载 SRT zip 包"""
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
if not task.get('final_srt_dir'):
return jsonify({'error': 'SRT 尚未生成'}), 400
srt_dir = Path(task['final_srt_dir'])
srt_files = sorted(srt_dir.glob('*.srt'))
if not srt_files:
return jsonify({'error': '无 SRT 文件'}), 404
# 打包 zip
buf = BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for srt_file in srt_files:
zf.write(srt_file, srt_file.name)
buf.seek(0)
filename = f'唱词字幕_{task_id}.zip'
return send_file(buf, mimetype='application/zip', as_attachment=True, download_name=filename)
+246
View File
@@ -0,0 +1,246 @@
# -*- coding: utf-8 -*-
"""
CCA 部署脚本 — 通过 paramiko 上传文件到腾讯云服务器
"""
import sys
sys.stdout.reconfigure(encoding='utf-8')
import os
import paramiko
from pathlib import Path
HOST = '101.42.29.217'
PORT = 22
USER = 'root'
PASS = 'liutong65'
CCA_ROOT = Path(__file__).resolve().parent.parent
SRC_DIR = CCA_ROOT / 'src'
DEPLOY_DIR = CCA_ROOT / 'deploy'
# 服务器目标路径
SERVER_CCA_SRC = '/workspace/military_tech_voice/backend/cca_src'
SERVER_FRONTEND = '/workspace/military_tech_voice/frontend'
SERVER_WWW = '/var/www/voice'
SERVER_BACKEND = '/workspace/military_tech_voice/backend'
SERVER_ROUTES = f'{SERVER_BACKEND}/app/routes'
# 需要上传的 src 模块
SRC_MODULES = [
'asr_client.py',
'line_breaker.py',
'ai_line_breaker.py',
'ai_proofreader.py',
'term_normalizer.py',
'hotword_extractor.py',
'srt_writer.py',
'segment_splitter.py',
]
def connect():
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(HOST, PORT, USER, PASS)
sftp = ssh.open_sftp()
return ssh, sftp
def run(ssh, cmd):
print(f' $ {cmd}')
_, stdout, stderr = ssh.exec_command(cmd)
out = stdout.read().decode('utf-8', errors='replace').strip()
err = stderr.read().decode('utf-8', errors='replace').strip()
if out:
print(f' {out[:500]}')
if err:
print(f' [stderr] {err[:500]}')
return out
def upload(sftp, local_path, remote_path):
print(f'{Path(local_path).name}{remote_path}')
sftp.put(str(local_path), remote_path)
def main():
print('=== CCA 部署开始 ===\n')
ssh, sftp = connect()
print('[1/7] 连接成功\n')
# Step 2: 创建 cca_src 目录 + 上传源码
print('[2/7] 上传 CCA 源码模块...')
run(ssh, f'mkdir -p {SERVER_CCA_SRC}')
for module in SRC_MODULES:
local = SRC_DIR / module
if local.exists():
upload(sftp, local, f'{SERVER_CCA_SRC}/{module}')
else:
print(f' ⚠ 跳过(不存在): {module}')
print()
# Step 3: 上传 cca_route.py 到 app/routes/
print('[3/7] 上传 cca_route.py...')
run(ssh, f'mkdir -p {SERVER_ROUTES}')
upload(sftp, DEPLOY_DIR / 'cca_route.py', f'{SERVER_ROUTES}/cca.py')
# 确保 __init__.py 存在
run(ssh, f'touch {SERVER_ROUTES}/__init__.py')
print()
# Step 4: 上传 cca.html 到前端目录
print('[4/7] 上传 cca.html...')
upload(sftp, DEPLOY_DIR / 'cca.html', f'{SERVER_FRONTEND}/cca.html')
upload(sftp, DEPLOY_DIR / 'cca.html', f'{SERVER_WWW}/cca.html')
print()
# Step 5: 配置 .env(追加 CCA 相关变量)
print('[5/7] 配置 .env...')
env_path = f'{SERVER_BACKEND}/.env'
existing_env = ''
try:
with sftp.open(env_path, 'r') as f:
existing_env = f.read().decode('utf-8', errors='replace')
except FileNotFoundError:
pass
env_additions = []
if 'XFYUN_APP_ID' not in existing_env:
env_additions.append('# === CCA 唱词助手凭证 ===')
env_additions.append('# 讯飞录音文件转写(账号1-默认)')
env_additions.append('XFYUN_APP_ID=4c423e35')
env_additions.append('XFYUN_SECRET_KEY=b9e0b97d5dda072c9b4b8fb59e7e3d22')
env_additions.append('# 讯飞备用账号2')
env_additions.append('# XFYUN_APP_ID=52ae3024')
env_additions.append('# XFYUN_SECRET_KEY=d65de0eb282a4339e2b1e14fd119e42e')
if 'DEEPSEEK_API_KEY' not in existing_env:
env_additions.append('# DeepSeek(校对+折行+热词)')
env_additions.append('DEEPSEEK_API_KEY=sk-01a7868a88a04ab494e4f05c1f3f06e2')
if env_additions:
with sftp.open(env_path, 'a') as f:
f.write('\n' + '\n'.join(env_additions) + '\n')
print(' .env 已追加 CCA 凭证')
else:
print(' .env 已有 CCA 凭证,跳过')
print()
# Step 6: 注册 CCA 蓝图到 Flask main.py
print('[6/7] 注册 CCA 蓝图...')
main_py_path = f'{SERVER_BACKEND}/app/main.py'
with sftp.open(main_py_path, 'r') as f:
main_content = f.read().decode('utf-8', errors='replace')
if 'cca' not in main_content.lower():
# 找到最后一个 register_blueprint 的位置,在其后追加
lines = main_content.split('\n')
insert_idx = -1
for i, line in enumerate(lines):
if 'register_blueprint' in line:
insert_idx = i
if insert_idx >= 0:
indent = ' ' # 匹配现有缩进
# 检查现有蓝图注册的缩进
existing_line = lines[insert_idx]
indent = existing_line[:len(existing_line) - len(existing_line.lstrip())]
cca_lines = [
'',
f'{indent}# CCA 唱词助手',
f'{indent}from app.routes.cca import bp as cca_bp',
f'{indent}app.register_blueprint(cca_bp)',
]
for j, cca_line in enumerate(cca_lines):
lines.insert(insert_idx + 1 + j, cca_line)
new_content = '\n'.join(lines)
with sftp.open(main_py_path, 'w') as f:
f.write(new_content)
print(' 已注册 CCA 蓝图')
else:
print(' ⚠ 未找到 register_blueprint,请手动注册')
else:
print(' CCA 蓝图已注册,跳过')
print()
# Step 7: 安装依赖 + 修改 index.html + 重启
print('[7/7] 安装依赖、修改首页、重启服务...')
# 安装 Python 依赖
run(ssh, f'{SERVER_BACKEND}/venv/bin/pip install openai python-docx 2>&1 | tail -5')
# 修改 index.html 添加 CCA 入口按钮
index_path = f'{SERVER_FRONTEND}/index.html'
with sftp.open(index_path, 'r') as f:
index_content = f.read().decode('utf-8', errors='replace')
if '唱词助手' not in index_content and 'cca.html' not in index_content:
# 在导航栏中找到合适位置插入按钮
# 典型位置:header 或 nav 区域的最后一个链接之后
if '<header' in index_content or '<nav' in index_content:
# 找 </header> 或 </nav> 前插入
for marker in ['</nav>', '</header>']:
if marker in index_content:
btn_html = ' <a href="cca.html" class="nav-link">唱词助手</a>\n'
index_content = index_content.replace(marker, btn_html + ' ' + marker)
break
else:
# 备用:在 body 开头加一个浮动按钮
btn_html = '<div style="position:fixed;top:10px;right:10px;z-index:9999"><a href="cca.html" style="background:#4a9eff;color:#fff;padding:8px 16px;border-radius:6px;text-decoration:none;font-size:14px">唱词助手</a></div>\n'
index_content = index_content.replace('<body>', '<body>\n' + btn_html)
with sftp.open(index_path, 'w') as f:
f.write(index_content)
# 同步到 /var/www/voice/
run(ssh, f'cp {SERVER_FRONTEND}/index.html {SERVER_WWW}/index.html')
print(' index.html 已添加唱词助手入口')
else:
print(' index.html 已有唱词助手入口,跳过')
# 增大 Nginx 上传限制(音频文件可能较大)
nginx_conf = '/etc/nginx/nginx.conf'
with sftp.open(nginx_conf, 'r') as f:
nginx_content = f.read().decode('utf-8', errors='replace')
if 'client_max_body_size' not in nginx_content:
nginx_content = nginx_content.replace(
'http {',
'http {\n client_max_body_size 200m;'
)
with sftp.open(nginx_conf, 'w') as f:
f.write(nginx_content)
run(ssh, 'nginx -t && nginx -s reload')
print(' Nginx 已增大上传限制至 200MB')
else:
print(' Nginx 上传限制已配置')
# 重启 Flask 服务
print('\n 重启 Flask...')
# 查找并重启 Flask 进程
run(ssh, 'pkill -f "flask run" || pkill -f "gunicorn" || pkill -f "python.*app" || true')
# 给进程时间退出
import time
time.sleep(2)
# 检查服务启动方式
service_out = run(ssh, 'systemctl list-units --type=service | grep -i voice || systemctl list-units --type=service | grep -i flask || true')
if 'voice' in service_out.lower() or 'flask' in service_out.lower():
service_name = service_out.split()[0] if service_out else ''
if service_name:
run(ssh, f'systemctl restart {service_name}')
else:
# 直接后台启动
run(ssh, f'cd {SERVER_BACKEND} && source venv/bin/activate && nohup python -m flask run --host=0.0.0.0 --port=5000 > /tmp/flask_cca.log 2>&1 &')
print()
print('=== CCA 部署完成 ===')
print(f'访问地址: http://{HOST}/cca.html')
print(f'API 地址: http://{HOST}/api/cca/')
sftp.close()
ssh.close()
if __name__ == '__main__':
main()
+313 -3
View File
@@ -31,13 +31,37 @@ SILENCE_THRESHOLD_MS = 2000
SYSTEM_PROMPT = """你是电视节目唱词字幕的折行助手。你的任务是将一段文字按照以下规则折成多行:
规则:
**基本规则:**
1. 每行最多14个字(中文字符、英文字母、数字各算1个字)
2. 去掉逗号、句号、感叹号、问号、顿号、分号、冒号、省略号等标点,只保留引号(""''「」)和书名号(《》)
2. 去掉逗号、句号、感叹号、问号、分号、冒号、省略号等标点,只保留引号(""'')和书名号(《》)
3. 折行要符合语义和阅读习惯,不能把词语切断
4. 每行不一定要凑满14字,可以是5字、8字、10字等,关键是语义完整
5. 保持原文内容不变,不增不减不改字
**折行禁忌(硬规则,违反即错误):**
- 禁止把一个词语拆到两行("过程"不能变成""在行末、""在下行首;"实际上"不能拆开)
- 禁止""""""""""""作为新行的第一个字
- 禁止""""""""作为新行的第一个字
- 禁止拆断固定搭配(如"F-35A和F-35B"保持同行,"RQ-4全球鹰"保持同行)
- 禁止把动宾结构拆成:主语+动词(折行)宾语。应折成:主语(折行)动词+宾语
- **引号不跨屏**:当引号""内的内容≤6个字时,上引号和下引号必须在同一行,不允许拆到两行。例如"鱼鹰""日向"号 必须保持在同一行内
**折行优先级(按此顺序选择折点):**
1. 最优:在句子成分边界折(主语|谓语+宾语,状语|主句)
2. 次优:在并列分句之间折
3. 可接受:在长定语与中心词之间折(但""必须跟前面,不能落到下一行开头)
4. 最末:硬切词间(仅当以上都超14字时)
**示例:**
- 正确:"重塑自身军事力量版图的" / "野心与企图"
- 错误:"重塑自身军事力量版图" / "的野心与企图"""开头)
- 正确:"日本国会参议院" / "今天上午表决通过了" / "防卫省设置法修正案等法案"
- 错误:"日本国会参议院今天上午" / "表决通过" / "了防卫省设置法修正案等法案"""开头,且拆断动宾)
- 正确:"2015年美国国务院" / "批准向日本军售RQ-4全球鹰"
- 错误:"2015年美国国务院批准" / "向日本军售RQ-4全球鹰"(主+谓(折行)宾)
- 正确:"所以发展"日向"" / "直升机护卫舰"(引号内容不拆开)
- 错误:"所以发展"日向" / "级直升机护卫舰"(引号被拆到两屏)
输出格式:每行一句,不加序号,不加标点(引号和书名号除外)。"""
USER_PROMPT_TEMPLATE = """请将以下文字折行(每行≤14字,去标点保引号,按语义断句):
@@ -125,6 +149,253 @@ def ai_break_batch(texts: List[str], client: OpenAI) -> List[List[str]]:
return all_results
# 常见不可拆分的双字词(高频,不求全,兜底关键场景)
# 这些词如果被折行拆到两屏,观众体验极差
_COMMON_WORDS = set([
"过程", "中间", "实际", "日本", "美国", "中国", "问题", "发展",
"军事", "武器", "装备", "能力", "力量", "防御", "进攻", "导弹",
"战斗", "战机", "战争", "国家", "历史", "世界", "方面", "系统",
"技术", "任务", "目标", "计划", "项目", "部署", "改装", "航母",
"自卫", "海军", "空军", "陆军", "预算", "宪法", "和平", "安全",
"基础", "措施", "结构", "性能", "速度", "射程", "重量", "面积",
"时候", "之后", "以后", "之前", "目前", "现在", "所以", "因此",
"但是", "虽然", "而且", "或者", "如果", "这个", "那个", "已经",
"可以", "应该", "需要", "能够", "开始", "成为", "通过", "进行",
"实现", "提升", "完成", "建设", "研发", "生产", "采购", "引进",
])
def _fix_split_words(lines: List[str]) -> List[str]:
"""
检测并修复被拆到两行的词语。
如果行末1字+下行首1字构成常见双字词,把末字移到下行。
"""
if len(lines) <= 1:
return lines
fixed = list(lines)
changed = True
max_iterations = 3 # 防止无限循环
while changed and max_iterations > 0:
changed = False
max_iterations -= 1
new_fixed = [fixed[0]]
for j in range(1, len(fixed)):
prev_line = new_fixed[-1]
curr_line = fixed[j]
if not prev_line or not curr_line:
new_fixed.append(curr_line)
continue
# 检查行末字+下行首字是否构成词
pair = prev_line[-1] + curr_line[0]
if pair in _COMMON_WORDS:
# 把上一行末字移到当前行首
new_fixed[-1] = prev_line[:-1]
curr_line = prev_line[-1] + curr_line
changed = True
# 如果上一行变空了,删掉
if not new_fixed[-1].strip():
new_fixed.pop()
new_fixed.append(curr_line)
fixed = new_fixed
# 清理:过滤空行,检查是否有超长行需要重切
from line_breaker import break_sentence
result = []
for line in fixed:
line = line.strip()
if not line:
continue
if len(line) > MAX_CHARS_SOFT:
result.extend(break_sentence(line))
else:
result.append(line)
return result if result else lines
def _fix_quote_split(lines: List[str]) -> List[str]:
"""
修复引号被拆到两屏的问题。
当上引号"在一行、下引号"在下一行,且引号内内容≤6字时,合并到同一行。
"""
if len(lines) <= 1:
return lines
from line_breaker import break_sentence
fixed = [lines[0]]
i = 1
while i < len(lines):
prev = fixed[-1]
curr = lines[i]
# 检查:上一行有"但没有配对的",当前行有"
if "" in prev and "" not in prev and "" in curr:
# 找到上引号位置,计算引号内内容长度
quote_start = prev.rfind("")
# 引号内容 = 上一行从"开始的部分 + 当前行到"为止的部分
quote_end_in_curr = curr.index("")
quoted_content = prev[quote_start+1:] + curr[:quote_end_in_curr]
if len(quoted_content) <= 6:
# 合并这两行
merged = prev + curr
if len(merged) <= MAX_CHARS_SOFT:
fixed[-1] = merged
else:
# 合并后超长,重新折行
fixed[-1:] = break_sentence(merged)
i += 1
continue
fixed.append(curr)
i += 1
return fixed
def _merge_tiny_subtitle(result: List[Tuple[int, int, str]]) -> List[Tuple[int, int, str]]:
"""
合并极短字幕行(≤3字且时长<1秒)到相邻行。
避免"东海"这种两个字单独闪一屏。
"""
if len(result) <= 1:
return result
merged = []
skip_next = False
for i, (bg, ed, text) in enumerate(result):
if skip_next:
skip_next = False
continue
duration_ms = ed - bg
is_tiny = len(text) <= 3 and duration_ms < 1000 and text.strip()
if is_tiny:
# 尝试与下一行合并
if i + 1 < len(result) and result[i+1][2].strip():
next_bg, next_ed, next_text = result[i+1]
combined = text + next_text
if len(combined) <= MAX_CHARS_SOFT:
merged.append((bg, next_ed, combined))
skip_next = True
continue
# 尝试与上一行合并
if merged and merged[-1][2].strip():
prev_bg, prev_ed, prev_text = merged[-1]
combined = prev_text + text
if len(combined) <= MAX_CHARS_SOFT:
merged[-1] = (prev_bg, ed, combined)
continue
merged.append((bg, ed, text))
return merged
MERGE_THRESHOLD_CHARS = 8 # ≤8字的句子考虑合并
MERGE_GAP_MS = 800 # 句间间隔<800ms才合并(>800ms视为有意停顿)
MERGE_GAP_TINY_MS = 1200 # 极短句(≤4字)放宽间隔阈值
def _merge_short_sentences(
sentences: List[Tuple[int, int, str, int]],
) -> List[Tuple[int, int, str, int]]:
"""
合并碎片短句:专家气口造成的短碎 ASR 句,合并成完整语义单元再折行。
规则:
- 连续的短句(≤8字)且间隔<800ms → 合并为一句
- 遇到 >2s 静音 → 不合并(是真正的话题停顿)
- 如果某句已经≥14字 → 作为独立单元不参与合并
- 合并后的句子时间戳取第一句起点到最后一句终点
"""
if not sentences:
return []
from line_breaker import clean_punctuation
merged = []
buffer = [] # [(bg, ed, text, spk), ...]
def flush_buffer():
if not buffer:
return
if len(buffer) == 1:
merged.append(buffer[0])
else:
# 合并 buffer 中所有句子
bg = buffer[0][0]
ed = buffer[-1][1]
text = "".join(item[2] for item in buffer)
spk = buffer[0][3]
merged.append((bg, ed, text, spk))
for i, (bg, ed, text, spk) in enumerate(sentences):
cleaned = clean_punctuation(text)
# 检查与 buffer 最后一句的间隔
if buffer:
gap = bg - buffer[-1][1]
# 极短句(≤4字)用更宽松的间隔阈值,让气口碎片更容易合并
threshold = MERGE_GAP_TINY_MS if len(cleaned) <= 4 else MERGE_GAP_MS
if gap > threshold:
flush_buffer()
buffer = []
# 如果当前句子很长(>28字),独立处理
if len(cleaned) > MAX_CHARS * 2:
flush_buffer()
buffer = []
merged.append((bg, ed, text, spk))
continue
# 如果当前句子中等偏长(15-28字)
if len(cleaned) > MAX_CHARS:
# 如果前面buffer里有极短句(≤5字),合并进来(专家气口碎片)
if buffer and all(len(clean_punctuation(item[2])) <= 5 for item in buffer):
buffer.append((bg, ed, text, spk))
else:
flush_buffer()
buffer = []
merged.append((bg, ed, text, spk))
continue
# 短句,看是否要合并
if len(cleaned) <= MERGE_THRESHOLD_CHARS:
# 检查合并后是否太长
buffer_text = "".join(clean_punctuation(item[2]) for item in buffer) + cleaned
if len(buffer_text) > MAX_CHARS * 3: # 合并后超过3行的量就太多了
flush_buffer()
buffer = [(bg, ed, text, spk)]
else:
buffer.append((bg, ed, text, spk))
else:
# 中等长度(9-14字),如果buffer有内容就合并进去,否则独立
if buffer:
buffer_text = "".join(clean_punctuation(item[2]) for item in buffer) + cleaned
if len(buffer_text) <= MAX_CHARS * 3:
buffer.append((bg, ed, text, spk))
else:
flush_buffer()
buffer = [(bg, ed, text, spk)]
else:
merged.append((bg, ed, text, spk))
flush_buffer()
return merged
def process_sentences_with_ai(
sentences: List[Tuple[int, int, str, int]],
batch_size: int = 15,
@@ -136,6 +407,7 @@ def process_sentences_with_ai(
输出: [(start_ms, end_ms, text), ...]
策略:
- 先合并碎片短句(专家气口造成的短碎ASR句)
- ≤14 字:直接输出(去标点)
- >14 字:批量调 AI 折行
- 句间 >2秒:插入空白行
@@ -145,6 +417,12 @@ def process_sentences_with_ai(
if not sentences:
return []
# 短句合并预处理
original_count = len(sentences)
sentences = _merge_short_sentences(sentences)
if len(sentences) != original_count:
print(f"[AI折行] 短句合并: {original_count} 句 → {len(sentences)}")
client = _create_client()
result = []
@@ -198,7 +476,7 @@ def process_sentences_with_ai(
else:
lines = [cleaned]
# 后处理:AI 偶尔返回超长行,强制二次切分
# 后处理1AI 偶尔返回超长行,强制二次切分
from line_breaker import break_sentence
final_lines = []
for line in lines:
@@ -208,6 +486,35 @@ def process_sentences_with_ai(
final_lines.append(line)
lines = final_lines
# 后处理2:禁忌字开头修复(把禁忌字并入上一行)
FORBIDDEN_START = set("的了着过地得和与及或")
if len(lines) > 1:
fixed_lines = [lines[0]]
for ln in lines[1:]:
if ln and ln[0] in FORBIDDEN_START and fixed_lines:
# 把这个字并回上一行
fixed_lines[-1] = fixed_lines[-1] + ln[0]
remainder = ln[1:]
if remainder:
# 检查上一行是否超长了
if len(fixed_lines[-1]) > MAX_CHARS_SOFT:
# 需要重新切分合并后的文本
merged = fixed_lines[-1] + remainder
fixed_lines[-1:] = break_sentence(merged)
else:
fixed_lines.append(remainder)
else:
fixed_lines.append(ln)
lines = fixed_lines
# 后处理3:拆词检测(行末+下行首构成常见双字词 → 调整折点)
if len(lines) > 1:
lines = _fix_split_words(lines)
# 后处理4:引号不跨屏(≤6字的引号内容不拆到两行)
if len(lines) > 1:
lines = _fix_quote_split(lines)
# 为子行分配时间戳
total_chars = sum(len(l) for l in lines)
duration = ed - bg
@@ -221,4 +528,7 @@ def process_sentences_with_ai(
result.append((current_ms, line_end, line))
current_ms = line_end
# 全局后处理:合并极短字幕行(≤3字+时长<1秒→并入相邻行)
result = _merge_tiny_subtitle(result)
return result
+176 -65
View File
@@ -7,11 +7,13 @@ AI 校对器 — ASR 稿与 A 稿比对 + 上下文纠错
- 军事术语规范化("f15j""F-15J"
- 的/地/得纠错
- 去除口语填充词("""那个""就是说"
- 专家采访段落强化去口头语
策略:
- 将 ASR 全文 + A 稿全文一起发给 DeepSeek
- AI 结合节目主题和上下文做纠错
- 返回修正后的句子列表 + 修改说明
- 专家采访段落用增强版 Prompt,更严格地删除口头语
"""
import json
@@ -37,21 +39,68 @@ PROOFREAD_SYSTEM_PROMPT = """你是电视军事节目《军事科技》的字幕
**铁律(违反任何一条都算失败):**
- ASR稿是已经录好的音频的转写,内容不能改——**绝不润色语句、绝不调整语序、绝不增删实词**
- 只修三类问题:① 错别字/同音字 ② 术语格式 ③ 口语填充词
- 除这三类外的一切文字,原封不动照抄,一个字都不能动
- A稿只用来判断"这个词在本期节目的语境下应该是哪个字",不能把ASR稿往A稿的措辞上靠
- 只修下列允许的几类问题,除此之外一个字都不能动
- **A稿与ASR内容冲突时ASR优先**(配音员可能改过措辞),但专有名词的正确写法/格式按A稿
- **数字表达照抄ASR原文**:不要参考A稿调整数字的位置、格式或表述方式。ASR说"马赫数0.9"就保持"马赫数0.9",不要改成A稿的"0.9马赫"
**允许修的类:**
1. **同音字/错别字**(ASR听错的字):如"建制""舰只""舰手""舰艏""继承""击沉""空花弹""滑翔弹"
2. **术语格式**:英文型号大小写+连字符("f15j""F-15J""v22""V-22""rq四""RQ-4"
3. **口语填充词删除**:只删"""""""""""""就是说""这个"这类纯填充词。如果"这个"后面紧跟名词作指示代词("这个导弹"),保留不删
**允许修的类**
1. **同音字/错别字**(ASR听错的字):如"建制""舰只""舰手""舰艏""继承""击沉""空花弹""滑翔弹""沉默""沉没"(指船只)
2. **代词纠错**:武器装备/导弹/飞机/舰艇等的代词应为""而非""。注意:指代国家时不改(国家口语中用""是可接受的)。只纠正明确指代物件(武器、军舰、飞机、导弹)的情况
3. **的/地/得纠错**(重要!ASR无法区分三"de",你必须逐句检查并修正):
- **""用在名词前**(形容词/名词 + 的 + 名词):强大的性能、日本的军备、重要的舰只
- **""用在动词前**(副词 + 地 + 动词):不断地进行、持续地推动、快速地发展、正式地把、大规模地改装、积极地推进、不断地扩大、明确地表示
- **""用在补语前**(动词 + 得 + 补语):发展得很快、做得很好、打得很准
- 判断方法:看"de"后面跟的是名词还是动词——跟动词就用"",跟名词就用"",是评价/程度补语就用""
- 常见错误模式:"不断的进行""不断地进行""持续的推动""持续地推动""正式的把""正式地把""大力的发展""大力地发展"
4. **术语格式**:英文型号大小写+连字符("f15j""F-15J""v22""V-22""rq四""RQ-4"
5. **中文数字保留**:ASR可能把"数十"转成"数10""几百"转成"几100"——必须改回中文写法
6. **武器昵称引号**:如A稿中武器有引号昵称("鱼鹰""战斧""全球鹰"),ASR中同一武器无引号时补上中文双引号
7. **口语填充词删除**:只删"""""""那个""就是说"这类纯填充词。"这个"后面紧跟名词作指示代词("这个导弹")时保留
**绝对不许做的(哪怕你觉得改了更好也不许):**
- 不许调整语序("它在性质上就是"不许改成"它本质上就是"
- 不许替换实词("不是那么特别的顺利"不许改成"不太顺利"
- 不许参考A稿的数字表达方式来改ASR的数字写法
- 不许增删标点来改变句子结构
- 不许把口语化表达改成书面语
- 不许根据A稿的措辞替换ASR中意思相同但用词不同的表达
- 不许根据A稿的措辞替换ASR中意思相同但用词不同的表达(如A稿"陆续订购"ASR说"先后采购"→保持"先后采购"
**输出格式:**
JSON数组,每个元素:{"id": 编号, "original": "原文", "corrected": "修正后", "changes": "修改说明(无修改写空字符串)"}
只输出JSON,不要其他内容。"""
PROOFREAD_EXPERT_SYSTEM_PROMPT = """你是电视军事节目《军事科技》的字幕校对专家。你将收到两份材料:
1. **ASR稿**:语音识别的转写结果,带有时间编号,是字幕的基础。**本批全部来自专家采访段落**
2. **A稿**:编导写的节目文稿(仅包含解说词,不包含专家采访内容——专家说的话A稿里没有)
你的任务是校对 ASR 稿中的**语音识别错误**,同时**严格清除专家的口头语**。
**铁律(违反任何一条都算失败):**
- ASR稿是已经录好的音频的转写,内容不能改——**绝不润色语句、绝不调整语序、绝不增删实词**
- 只修下列允许的几类问题,除此之外一个字都不能动
- 由于是专家采访,A稿中没有对应内容,所以**不要用A稿措辞替换专家的话**,A稿只用于确认专有名词写法
**允许修的类别:**
1. **同音字/错别字**(ASR听错的字):如"建制""舰只""舰手""舰艏""继承""击沉""沉默""沉没"(指船只)
2. **代词纠错**:武器装备/导弹/飞机/舰艇等的代词应为""而非""。指代国家时不改
3. **的/地/得纠错**(重要!ASR无法区分三个"de",你必须逐句检查并修正):
- **""用在名词前**(形容词/名词 + 的 + 名词)
- **""用在动词前**(副词 + 地 + 动词):不断地进行、持续地推动、正式地把、大规模地改装
- **""用在补语前**(动词 + 得 + 补语):发展得很快
- 常见错误:"不断的进行""不断地进行""持续的推动""持续地推动"
4. **术语格式**:英文型号大小写+连字符
5. **口语填充词删除(专家采访重点!必须严格执行)**:
- **必删**:嗯、呃、唉、啊(句首或句中作语气词时)、那个、这个(非指示代词时)、那么(非表示程度时)、就是说、应该说、可以说、怎么说呢、相对来讲、相对来说
- **判断"这个/那个"**:紧跟具体名词="指示代词"保留("这个导弹");单独出现或后面是虚词/停顿=口头语删除("这个呢它是"→删"这个""发展这个日向级"→删"这个"
- **判断""**:句首"啊射程""啊这个"=口头语删除;""在感叹句末尾=保留(极少出现在专家采访中)
- **判断"那么"**"那么大""那么快"=程度副词保留;"那么它就是"=口头语删除
6. **数字表达照抄ASR原文**,不参考A稿
**绝对不许做的:**
- 不许调整语序、替换实词、把口语化改书面语
- 不许用A稿的措辞替换专家的话(专家说的内容A稿没有,不存在"参考"关系)
- 不许删除有意义的词(只删纯口头语填充词)
**输出格式:**
JSON数组,每个元素:{"id": 编号, "original": "原文", "corrected": "修正后", "changes": "修改说明(无修改写空字符串)"}
@@ -76,6 +125,62 @@ def _create_client():
)
def identify_speakers(
sentences: List[Tuple[int, int, str, int]],
) -> Dict[int, str]:
"""
识别每个 speaker_id 的角色。
规则(基于《军事科技》节目结构):
- 找到说"各位观众你们好""欢迎收看军事科技"的 speaker → 主持人(也是解说配音员)
- 导视段(最早出现的)speaker 如果和主持人不同 → 也是解说(录音环境不同导致分裂)
- 剩余的 speaker → 专家/其他(统一按"专家采访"对待,加强去口头语)
返回: {speaker_id: "narration"|"host"|"expert"}
"""
if not sentences:
return {}
speaker_texts: Dict[int, str] = {}
speaker_first_appear: Dict[int, int] = {}
for i, (bg, ed, text, spk) in enumerate(sentences):
if spk not in speaker_texts:
speaker_texts[spk] = ""
speaker_first_appear[spk] = i
speaker_texts[spk] += text
roles: Dict[int, str] = {}
# 找主持人:说过"各位观众你们好"或"欢迎收看军事科技"
host_spk = None
for spk, text in speaker_texts.items():
if "各位观众" in text or "欢迎收看" in text or "主持人" in text:
host_spk = spk
roles[spk] = "host"
break
# 最早出现的 speaker 是解说(导视段配音员)
earliest_spk = min(speaker_first_appear, key=speaker_first_appear.get)
if earliest_spk not in roles:
roles[earliest_spk] = "narration"
# 如果主持人和解说是不同 speaker,两个都标记
# 如果相同,那就是同一个人(标为 narration 即可)
if host_spk is not None and host_spk == earliest_spk:
roles[host_spk] = "narration"
# 剩余的全部标为专家/其他
for spk in speaker_texts:
if spk not in roles:
roles[spk] = "expert"
role_summary = {spk: f"{role}({len([s for s in sentences if s[3]==spk])}句)"
for spk, role in roles.items()}
print(f"[校对] Speaker 角色识别: {role_summary}")
return roles
def proofread_batch(
asr_sentences: List[Tuple[int, int, str, int]],
script_text: str,
@@ -83,81 +188,87 @@ def proofread_batch(
) -> List[Tuple[int, int, str, int]]:
"""
对 ASR 句子列表做 AI 校对。
输入:
asr_sentences: [(start_ms, end_ms, text, speaker_id), ...]
script_text: A稿全文
batch_size: 每批处理的句子数
返回:
校对后的句子列表,格式同输入
专家采访段落使用增强版 Prompt(更严格的口头语清除)。
"""
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) # 浅拷贝
# 识别说话人角色
speaker_roles = identify_speakers(asr_sentences)
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)
# 按角色分组处理:专家用增强 Prompt,其余用标准 Prompt
expert_indices = []
normal_indices = []
for i, (bg, ed, text, spk) in enumerate(asr_sentences):
if speaker_roles.get(spk) == "expert":
expert_indices.append(i)
else:
normal_indices.append(i)
# 构建 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"[校对] 解说/主持 {len(normal_indices)} 句, 专家采访 {len(expert_indices)}")
print(f"[校对] 处理第 {batch_start+1}-{batch_end} 句...")
def _process_batch(indices, system_prompt, label):
nonlocal total_changes
for batch_start in range(0, len(indices), batch_size):
batch_idx = indices[batch_start:batch_start + batch_size]
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,
)
asr_lines = []
for seq, idx in enumerate(batch_idx):
asr_lines.append(f"[{seq+1}] {asr_sentences[idx][2]}")
asr_text = "\n".join(asr_lines)
result_text = resp.choices[0].message.content.strip()
print(f"[校对-{label}] 处理第 {batch_start+1}-{batch_start+len(batch_idx)} 句...")
# 尝试解析 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()
try:
resp = client.chat.completions.create(
model=os.environ.get("DEEPSEEK_MODEL", "deepseek-chat"),
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": PROOFREAD_USER_TEMPLATE.format(
script_text=script_truncated,
asr_text=asr_text,
)},
],
temperature=0.1,
max_tokens=4000,
)
corrections = json.loads(result_text)
result_text = resp.choices[0].message.content.strip()
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()
# 应用修正
for item in corrections:
idx = item.get("id", 0) - 1 # 编号从1开始
corrected = item.get("corrected", "")
changes = item.get("changes", "")
corrections = json.loads(result_text)
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})")
for item in corrections:
seq = item.get("id", 0) - 1
corrected = item.get("corrected", "")
changes = item.get("changes", "")
except json.JSONDecodeError as e:
print(f"[校对] JSON解析失败,跳过本批: {e}", file=sys.stderr)
except Exception as e:
print(f"[校对] 出错: {e}", file=sys.stderr)
if 0 <= seq < len(batch_idx) and corrected and changes:
original_idx = batch_idx[seq]
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"[校对-{label}] JSON解析失败,跳过本批: {e}", file=sys.stderr)
except Exception as e:
print(f"[校对-{label}] 出错: {e}", file=sys.stderr)
if normal_indices:
_process_batch(normal_indices, PROOFREAD_SYSTEM_PROMPT, "解说")
if expert_indices:
_process_batch(expert_indices, PROOFREAD_EXPERT_SYSTEM_PROMPT, "专家")
print(f"[校对] 完成,共修正 {total_changes}")
return corrected_sentences
+9 -2
View File
@@ -32,11 +32,18 @@ BREAK_PATTERNS = [
def clean_punctuation(text: str) -> str:
"""去掉标点,保留引号类"""
"""去掉标点,保留引号类。顿号替换为空格(唱词中并列词用空格分隔)。保留小数点。"""
result = []
for ch in text:
for i, ch in enumerate(text):
if ch in KEEP_PUNCTUATION:
result.append(ch)
elif ch == '':
result.append(' ')
elif ch == '.' or ch == '':
# 保留小数点(前后都是数字)
if i > 0 and i < len(text) - 1 and text[i-1].isdigit() and text[i+1].isdigit():
result.append(ch)
# 其他句号/英文句点删掉
elif REMOVE_PUNCTUATION.match(ch):
continue
else:
+282
View File
@@ -0,0 +1,282 @@
# -*- coding: utf-8 -*-
"""
术语格式化器 — 正则后处理层(零 token 消耗)
在 ASR 结果出来后、AI 校对之前执行。
从 A 稿中提取正确的术语写法,构建映射表,对 ASR 文本做确定性替换。
解决的问题:
- 讯飞 ASR 丢失英文型号中的短横线(F-15J→F15J, V-22→V22
- 武器昵称引号丢失(A稿有引号但ASR没带出来)
- 中文数字被转成阿拉伯数字(数十→数10)
- 数字范围符号(~→到)
- 顿号分隔词加空格
- 小数点丢失修复(09马赫→0.9马赫)
- 军事领域高频同音字修正(建制→舰只等)
"""
import re
from typing import List, Tuple, Dict, Set
# ========================================================================
# 型号短横线修复
# ========================================================================
MODEL_PATTERN = re.compile(r'[A-Z]{1,4}-\d{1,4}[A-Z]?(?:/[A-Z])?')
def _build_model_mapping(script_text: str) -> Dict[str, str]:
mapping = {}
models = set(MODEL_PATTERN.findall(script_text))
for model in models:
no_hyphen = model.replace("-", "")
if no_hyphen != model:
mapping[no_hyphen] = model
return mapping
def _fix_model_hyphens(text: str, mapping: Dict[str, str]) -> str:
if not mapping:
return text
for no_hyphen in sorted(mapping.keys(), key=len, reverse=True):
correct = mapping[no_hyphen]
pattern = re.compile(re.escape(no_hyphen) + r'(?![A-Za-z0-9])')
text = pattern.sub(correct, text)
return text
# ========================================================================
# 武器昵称引号修复(上下文感知版)
# ========================================================================
# 匹配 A 稿中 "xxx"号 / "xxx"级 / "xxx"型 / 单独 "xxx" 的模式
QUOTED_WITH_SUFFIX = re.compile(r'“([^“”„‟""]{1,8})”([号级型式舰]?)')
def _build_quote_mapping(script_text: str) -> Dict[str, Set[str]]:
"""
从 A 稿提取引号词及其后缀上下文。
返回 {词: {出现过的后缀集合}},后缀为空字符串表示单独使用。
例: {"日向": {""}, "鱼鹰": {""}} 表示 A 稿有"日向"号但没有"日向"级,有单独的"鱼鹰"
"""
mapping: Dict[str, Set[str]] = {}
for match in QUOTED_WITH_SUFFIX.finditer(script_text):
word = match.group(1).strip()
suffix = match.group(2)
if 2 <= len(word) <= 6:
if word not in mapping:
mapping[word] = set()
mapping[word].add(suffix)
return mapping
def _check_bare_occurrences(script_text: str, word: str, suffixes: Set[str]) -> Set[str]:
"""
检查 A 稿中该词的无引号出现,看哪些后缀组合是不加引号的。
例如 A 稿有 "日向级"(无引号),说明"日向级"不该加引号。
"""
bare_suffixes = set()
for suffix in ["", "", "", "", "", ""]:
bare_pattern = word + suffix if suffix else word
quoted_pattern = f"{word}{suffix}"
# 在 A 稿中出现了无引号版本 且 没有对应的有引号版本
if bare_pattern in script_text and quoted_pattern not in script_text:
bare_suffixes.add(suffix)
return bare_suffixes
def _fix_weapon_quotes(text: str, quote_mapping: Dict[str, Set[str]], script_text: str) -> str:
"""对文本中无引号的武器昵称补上引号(上下文感知)"""
if not quote_mapping:
return text
for word in sorted(quote_mapping.keys(), key=len, reverse=True):
quoted_suffixes = quote_mapping[word]
bare_suffixes = _check_bare_occurrences(script_text, word, quoted_suffixes)
# 对每个在 A 稿中确实带引号的后缀组合,在 ASR 文本中补引号
for suffix in quoted_suffixes:
if suffix and suffix not in bare_suffixes:
# 匹配 "word+suffix"(无引号),替换为 "word"+suffix
target = word + suffix
replacement = f"{word}{suffix}"
pattern = re.compile(
r'(?<!“)' + re.escape(target) + r'(?!”)'
)
text = pattern.sub(replacement, text)
elif not suffix:
# 单独出现(无后缀),但要避免替换那些在 A 稿中不带引号的后缀组合
# 用负向前瞻排除不该加引号的后缀
exclude_chars = "".join(bare_suffixes - {""}) if bare_suffixes else ""
if exclude_chars:
lookahead = f'(?![{re.escape(exclude_chars)}])'
else:
lookahead = ''
pattern = re.compile(
r'(?<!“)(?<!《)' + re.escape(word) + lookahead + r'(?!”)(?!》)'
)
text = pattern.sub(f'{word}', text)
return text
# ========================================================================
# 中文数字修复
# ========================================================================
CHINESE_NUM_FIXES = [
(re.compile(r'数10([年架艘枚门辆台套件个发种类])'), r'数十\1'),
(re.compile(r'数100([年架艘枚门辆台套件个发种类])'), r'数百\1'),
(re.compile(r'数1000([年架艘枚门辆台套件个发种类])'), r'数千\1'),
(re.compile(r'几10([年架艘枚门辆台套件个发种类])'), r'几十\1'),
(re.compile(r'几100([年架艘枚门辆台套件个发种类])'), r'几百\1'),
]
def _fix_chinese_numbers(text: str) -> str:
for pattern, replacement in CHINESE_NUM_FIXES:
text = pattern.sub(replacement, text)
return text
# ========================================================================
# 数字范围符号修复:~ → 到
# ========================================================================
# 匹配 数字~数字 或 数字~数字 的模式
RANGE_TILDE = re.compile(r'(\d)[~](\d)')
def _fix_range_symbol(text: str) -> str:
return RANGE_TILDE.sub(r'\1到\2', text)
# ========================================================================
# 顿号→空格(唱词中并列词用空格分隔)
# ========================================================================
def _fix_enumeration_pause(text: str) -> str:
return text.replace("", " ")
# ========================================================================
# 节目名称书名号补全
# ========================================================================
# 需要带书名号的固定名称(节目名等)
# 格式: (裸名称, 带书名号版本)
BOOK_TITLE_NAMES = [
("军事科技", "《军事科技》"),
("军事报道", "《军事报道》"),
]
def _fix_book_titles(text: str) -> str:
for bare, titled in BOOK_TITLE_NAMES:
# 只替换没有被书名号包围的裸名称
pattern = re.compile(r'(?<!《)' + re.escape(bare) + r'(?!》)')
text = pattern.sub(titled, text)
return text
# ========================================================================
# 小数点丢失修复(09马赫→0.9马赫 等)
# ========================================================================
# 匹配丢失小数点的情况:
# 1. "09马赫" → "0.9马赫"(数字在单位前)
# 2. "马赫数09" → "马赫数0.9"(数字在单位后)
# 3. 通用:非正常的 0+单个数字 紧跟/紧接单位
LOST_DECIMAL_BEFORE_UNIT = re.compile(r'(?<!\d)0(\d)(\s*(?:马赫|倍|秒|米|千米|公里))')
LOST_DECIMAL_AFTER_UNIT = re.compile(r'(马赫数|倍数|速度约)0(\d)(?!\d)')
def _fix_lost_decimal(text: str) -> str:
text = LOST_DECIMAL_BEFORE_UNIT.sub(r'0.\1\2', text)
text = LOST_DECIMAL_AFTER_UNIT.sub(r'\g<1>0.\2', text)
return text
# ========================================================================
# 军事领域高频同音字修正
# ========================================================================
# 格式: (错误写法正则, 正确写法, A稿中应有的验证词)
# 只有当 A 稿中存在正确写法时才替换,避免误改
HOMOPHONE_PAIRS = [
# 海军
("建制", "舰只", "舰只"),
("舰手", "舰艏", "舰艏"),
("舰位", "舰尾", "舰尾"),
("继承", "击沉", "击沉"),
("沉默", "沉没", "沉没"),
("空花弹", "滑翔弹", "滑翔弹"),
("建支", "舰只", "舰只"),
("坚支", "舰只", "舰只"),
# 其他
("符和", "符合", "符合"),
("决意", "决议", "决议"),
]
def _build_homophone_mapping(script_text: str) -> Dict[str, str]:
mapping = {}
for wrong, correct, verify_word in HOMOPHONE_PAIRS:
if verify_word in script_text:
mapping[wrong] = correct
return mapping
def _fix_homophones(text: str, mapping: Dict[str, str]) -> str:
if not mapping:
return text
for wrong, correct in mapping.items():
text = text.replace(wrong, correct)
return text
# ========================================================================
# 主入口
# ========================================================================
def normalize_terms(
sentences: List[Tuple[int, int, str, int]],
script_text: str,
) -> List[Tuple[int, int, str, int]]:
"""
对 ASR 句子列表做术语格式化(确定性正则替换,不调 AI)。
在 ASR 结果出来后、AI 校对之前调用。
"""
if not sentences:
return []
if not script_text:
return list(sentences)
model_mapping = _build_model_mapping(script_text)
quote_mapping = _build_quote_mapping(script_text)
homophone_mapping = _build_homophone_mapping(script_text)
if model_mapping:
print(f"[术语格式化] 型号映射 {len(model_mapping)} 条: {list(model_mapping.items())[:5]}")
if quote_mapping:
print(f"[术语格式化] 引号昵称 {len(quote_mapping)} 个: {dict((k, list(v)) for k, v in list(quote_mapping.items())[:5])}")
if homophone_mapping:
print(f"[术语格式化] 同音字映射 {len(homophone_mapping)} 条: {list(homophone_mapping.items())[:5]}")
result = []
fix_count = 0
for bg, ed, text, spk in sentences:
original = text
text = _fix_model_hyphens(text, model_mapping)
text = _fix_weapon_quotes(text, quote_mapping, script_text)
text = _fix_chinese_numbers(text)
text = _fix_range_symbol(text)
text = _fix_enumeration_pause(text)
text = _fix_lost_decimal(text)
text = _fix_homophones(text, homophone_mapping)
text = _fix_book_titles(text)
if text != original:
fix_count += 1
result.append((bg, ed, text, spk))
print(f"[术语格式化] 完成,修正 {fix_count}")
return result