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:
+33
-20
@@ -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.py(Flask 蓝图,6 个 API 端点:upload/status/review/save/generate/download);② deploy/cca.html(4 步单页流程:上传→处理→审稿→下载,暗色主题匹配配音系统);③ 审稿台功能:左栏 ASR 原文 vs 右栏 AI 校对稿对比、逐句编辑确认、仅看修改过滤、全部确认、**查找替换**(Ctrl+H,支持逐个/全部替换+高亮定位);④ WAV 大文件自动 ffmpeg 转 MP3(解决讯飞上传超时);⑤ 服务器架构:Nginx→静态 HTML + Flask:5000,CCA 源码在 `/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.py(AI校对)、ai_line_breaker.py(AI折行)。
|
||||
- [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.html,2026-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 选型已定**:校对+折行+热词提取统一用 DeepSeek(deepseek-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.217,Nginx: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 选型~~ → DeepSeek(deepseek-chat),已验证效果好、价格低
|
||||
- [ ] 前端审稿台技术选型(第二步再定)
|
||||
- [ ] 大洋系统 SRT 导入兼容性(周一验证)
|
||||
- [x] ~~的地得纠错~~ → 已加入校对 Prompt(v5)
|
||||
- [x] ~~前端审稿台技术选型~~ → 纯 HTML/JS 单页,无框架(2026-07-05)
|
||||
- [ ] 大洋系统 SRT 导入兼容性(待验证)
|
||||
- [ ] 跨句固定搭配拆词("第二次/世界大战"类问题,需大短语词典,优先级低)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">🎤</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">📄</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="上一个">▲</button>
|
||||
<button class="replace-btn" onclick="findNext()" title="下一个">▼</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">✅</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 ? '✓' : ''}
|
||||
</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, '"').replace(/</g, '<'); }
|
||||
|
||||
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 ? '✓' : '';
|
||||
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 ? '✓' : ''}
|
||||
</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>
|
||||
@@ -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)
|
||||
@@ -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
@@ -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 偶尔返回超长行,强制二次切分
|
||||
# 后处理1:AI 偶尔返回超长行,强制二次切分
|
||||
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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user