# -*- coding: utf-8 -*- """ CCA 唱词助手 — Flask 路由 POST /api/cca/upload 上传 A稿+音频,启动流水线 GET /api/cca/status/ 轮询任务状态 GET /api/cca/review/ 获取审稿数据 POST /api/cca/save/ 保存编导修改 POST /api/cca/generate/ 生成最终 SRT GET /api/cca/download/ 下载 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/', 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/', 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/', 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/', 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/', 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)