遗忘之海金山螃蟹打法分享 遗忘之海金山螃蟹如何玩
2026-08-08 3445317
2026-08-08 0
一、引言
之前我发表的一篇文章https://blog.csdn.net/pla88888888/article/details/162817260(K100_AI两卡全离线部署音视频说话人角色确认转录系统),采用SoulX-Transcriber多模态大模型实现,在实测中发现超长音视频文件分析时会出现重复输出。这次我尝试用Speaker-Reasoner-4194h多模态大模型来解决该问题,自编程序思路来源于官方代码,代码链接为:https://github.com/ASLP-lab/Speaker-Reasoner/tree/main,如果您觉得该大模型对本工作有用,请引用:
@article{lin2026speakerreasoner,
title={Speaker-Reasoner: Scaling Interaction Turns and Reasoning Patterns for Timestamped Speaker-Attributed ASR},
author={Zhennan Lin and Shuai Wang and Zhaokai Sun and Pengyuan Xie and Chuan Xie and Jie Liu and Qiang Zhang and Lei Xie},
year={2026},
eprint={2604.03074},
archivePrefix={arXiv},
primaryClass={eess.AS},
url={https://arxiv.org/abs/2604.03074},
}
二、方案设计
1. 项目背景与目标
在会议记录、访谈整理等场景中,精确的说话人分离与时间轴对齐是核心需求。本系统旨在利用多模态大模型“Speaker-Reasoner-4194h”的音频理解与推理能力,通过多轮对话机制,实现端到端的智能转录:用户只需上传音频或视频文件,即可自动获得带有说话人ID、性别、精确时间戳的完整转写文本。
2. 系统架构
系统采用B/S架构,前端基于HTML模板提供简洁的上传界面;后端基于Flask框架,负责请求接收、任务调度与结果返回。核心组件包括:
文件预处理模块:使用ffmpeg将任意格式视频/音频统一转码为16kHz单声道WAV,保证模型输入一致性。
推理引擎:通过HTTP接口调用自部署的vLLM服务,承载Speaker-Reasoner-4194h模型。
多轮对话控制模块:实现自适应温度调节、重复输出检测、最大重试与边界校验,确保推理鲁棒性。
转录解析模块:支持秒数、HH:MM:SS、MM:SS.ss等多类时间戳格式,并过滤占位符等无效行,输出结构化片段。
3. 核心工作流程
(1)用户通过Web页面上传文件,后端进行安全校验并保存为临时文件。
(2)ffmpeg转换为16kHz单声道WAV格式,创建临时工作目录用于存放后续音频片段。
(3)发起首轮对话:模型接收完整音频与初始提示,被要求概括整体并给出第一个片段的起止时间边界。
(4)进入循环:每轮解析模型返回的
(5)模型在最终回复中输出
(6)解析转录文本,转换为带有start、end、speaker、text的标准JSON分段数组,连同原始文本一并返回前端展示。
(7)全程自动清理所有临时文件,保证服务器空间安全。
4. 关键技术细节
鲁棒性设计:单轮最多重试5次,前两次使用极低温度(0.01)和受限采样以抑制随机性,后续逐步放宽;引入重复惩罚系数,并检测连续重复10次以上的模式,一旦发现即触发重试。
边界校验:严格检查
资源管理:上传限制500MB,所有临时文件使用try-finally确保释放,避免残留。
多格式兼容:解析模块同时支持Speaker [0.0, 3.68]: text和[00:01:23 -> 00:01:45] Speaker1: text等,并通过统一的秒数→时间码转换函数保证输出一致。
5. 部署与展望
系统部署于Flask内置服务器,监听5000端口,可与vLLM服务同机或分布式部署。后续可扩展为异步任务队列,支持更大规模并发处理,并增加说话人声纹注册与自动命名等功能,进一步提升转录体验。
下图展示了整体数据流:

三、实施方法及代码
3.1硬件环境
本方案的硬件平台为一台H3C服务器,配置如下:
组件 | 规格 |
CPU | 2×海光74902.7GHz64C |
内存 | 16×32GDDR5 |
GPU | 8×海光DCU64GBK100_AI |
需占用两张K100_AI显卡
3.2软件栈
本方案的软件栈基于Docker容器化技术构建,使用经过海光DCU适配的vLLM推理镜像:
镜像(Speaker-Reasoner-4194h):
harbor.sourcefind.cn:5443/dcu/admin/base/custom:vllm0.15.1-ubuntu22.04-dtk26.04-0130-py3.10-20260220
该镜像基于vLLM0.15.1推理框架、DTK26.04,Python3.10环境,双卡部署Speaker-Reasoner-4194h多模态大模型。
软件项目:
1、Speaker-Reasoner-4194h环境部署参照https://developer.sourcefind.cn/modelzoo/list/qwen3-omni_vllm/detail?post_id=3563caba-356c-11f1-be71-0242ac150003里面操作步骤,项目下载链接为:https://developer.sourcefind.cn/codes/modelzoo/qwen3-omni_vllm/-/archive/v1.0/qwen3-omni_vllm-v1.0.zip。
2、离线音视频说话人角色确认转录系统自编程序代码如下:
Speaker-Reasoner-web-2.py代码如下:
import osimport reimport tempfileimport subprocessimport requestsimport loggingfrom flask import Flask, request, jsonify, render_templatefrom werkzeug.utils import secure_filenameapp = Flask(__name__)app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024# 500 MBVLLM_URL = "http://localhost:8082/v1/chat/completions"MODEL_NAME = "Speaker-Reasoner-4194h"# 提示词(来自 plugin/prompt_4194h.json)INITIAL_USER_TEXT = ("分析这段音频$X$,将其划分为合适的片段。对于每个片段,""请逐步推导说话人ID、性别、时间戳及转录内容。""现在,请先概括音频整体情况,并给出第一个片段的边界。")TURN_USER_TEXT = "当前片段作为O_{turn}。"logging.basicConfig(level=logging.INFO)logger = logging.getLogger(__name__)def _seconds_to_hms(seconds_str: str) -> str:"""将秒数字符串转换为 HH:MM:SS.sss 格式,便于前端统一处理"""try:total_seconds = float(seconds_str)except ValueError:return seconds_str# 转换失败则原样返回hours = int(total_seconds // 3600)minutes = int((total_seconds % 3600) // 60)secs = total_seconds % 60return f"{hours:02d}:{minutes:02d}:{secs:06.3f}"def parse_transcript(text: str):"""解析转录文本,支持多种时间戳格式,过滤无意义的占位行。增加支持:Speaker [秒数, 秒数]: 内容"""lines = text.strip().split('n')segments = []# 匹配 --:--:-- --> --:--:-- 占位行placeholder_pattern = re.compile(r'^--:--:--s*(?:-->|→)s*--:--:--$')# 新增:匹配 "Female 1 [0.0, 3.68]: 王姐,我爸妈来看你了。" 格式pattern_sec = re.compile(r'(.+?)s*[s*(d+(?:.d+)?)s*,s*(d+(?:.d+)?)s*]s*:s*(.*)')# 原有格式pattern_hms = re.compile(r'[(d{2}:d{2}:d{2})s*->s*(d{2}:d{2}:d{2})]s*(Speakerd+):s*(.*)')pattern_ms= re.compile(r'[(d{2}:d{2}.d+)s*-->s*(d{2}:d{2}.d+)]s*(Speakerd+):s*(.*)')pattern_no_time = re.compile(r'(Speakerd+):s*(.*)')for line in lines:line = line.strip()if not line:continueif placeholder_pattern.match(line):continue# 1. 秒数格式match = pattern_sec.match(line)if match:speaker, start_sec, end_sec, content = match.groups()segments.append({'start': _seconds_to_hms(start_sec),'end': _seconds_to_hms(end_sec),'speaker': speaker.strip(),'text': content.strip()})continue# 2. HH:MM:SS 格式match = pattern_hms.match(line)if match:start, end, speaker, content = match.groups()segments.append({'start': start, 'end': end, 'speaker': speaker, 'text': content})continue# 3. MM:SS.ss 格式match = pattern_ms.match(line)if match:start, end, speaker, content = match.groups()segments.append({'start': start, 'end': end, 'speaker': speaker, 'text': content})continue# 4. 仅有 Speaker 无时间match = pattern_no_time.match(line)if match:speaker, content = match.groups()segments.append({'start': None, 'end': None, 'speaker': speaker, 'text': content})else:segments.append({'start': None, 'end': None, 'speaker': None, 'text': line})return segmentsdef cut_audio_segment(wav_path: str, start_sec: float, end_sec: float, tmp_dir: str) -> str:"""使用 ffmpeg 从 wav 文件中切出一段音频"""segment_path = os.path.join(tmp_dir,f"{os.path.splitext(os.path.basename(wav_path))[0]}_{start_sec:.3f}_{end_sec:.3f}.wav")cmd = ['ffmpeg','-i', wav_path,'-ss', str(start_sec),'-to', str(end_sec),'-c:a', 'pcm_s16le','-y',segment_path]subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)return segment_pathdef call_speaker_reasoner(wav_path: str, tmp_dir: str) -> str:"""通过 HTTP 调用 vLLM 服务完成多轮对话,返回最终 标签内的文本。"""messages = [{"role": "user","content": [{"type": "audio_url", "audio_url": {"url": f"file://{os.path.abspath(wav_path)}"}},{"type": "text", "text": INITIAL_USER_TEXT}]}]max_turns = 100max_retries_per_turn = 5current_turn = 0answer = Nonelast_response = ""segment_files = []while current_turn < max_turns:turn_success = Falsecontent = ""# 用于记录本轮响应for attempt in range(max_retries_per_turn):try:# 动态调整温度if attempt < 2:temperature = 0.01top_p = 0.1top_k = 1else:temperature = min(0.1 + (attempt - 2) * 0.1, 1.0)top_p = 0.9top_k = -1# 增加 repetition_penalty 以减少重复输出repetition_penalty = 1.1 + attempt * 0.05payload = {"model": MODEL_NAME,"messages": messages,"temperature": temperature,"top_p": top_p,"top_k": top_k,"max_tokens": 8192,"repetition_penalty": repetition_penalty}resp = requests.post(VLLM_URL, json=payload, timeout=600)resp.raise_for_status()result = resp.json()content = result['choices'][0]['message']['content']# 简单重复检测if re.search(r'(.+?)1{10,}', content.strip()):raise RuntimeError("Repeated output detected")messages.append({"role": "assistant", "content": content})# 检查 answer_match = re.search(r'(.*?) ', content, re.DOTALL)if answer_match:answer = answer_match.group(1).strip()current_turn = max_turns# 跳出外层循环turn_success = Truebreak# 检查 ,获取下一段边界box_match = re.search(r'[s*(d+(?:.d+)?)s*,s*(d+(?:.d+)?)s*] ', content)if not box_match:# 尝试宽松匹配:任何 [数字, 数字] 形式fallback = re.search(r'[s*(d+(?:.d+)?)s*,s*(d+(?:.d+)?)s*]', content)if fallback:start_time = float(fallback.group(1))end_time = float(fallback.group(2))else:# 彻底找不到边界,视为对话结束logger.info("No or time boundary found, ending conversation.")turn_success = Truecurrent_turn = max_turnsbreakelse:start_time = float(box_match.group(1))end_time = float(box_match.group(2))# 验证边界有效性(结束时间必须大于起始时间)if end_time <= start_time:logger.warning("Invalid time boundary: [%s, %s]. Ending conversation.", start_time, end_time)turn_success = Truecurrent_turn = max_turnsbreak# 切出音频片段segment_path = cut_audio_segment(wav_path, start_time, end_time, tmp_dir)segment_files.append(segment_path)turn_text = TURN_USER_TEXT.format(turn=current_turn + 1)messages.append({"role": "user","content": [{"type": "audio_url", "audio_url": {"url": f"file://{os.path.abspath(segment_path)}"}},{"type": "text", "text": turn_text}]})turn_success = Truecurrent_turn += 1breakexcept Exception as e:logger.warning("Turn %d, attempt %d failed: %s", current_turn, attempt, e)last_response = content if content else last_response# 回滚可能已添加的错误 assistant 消息if messages and messages[-1]['role'] == 'assistant':messages.pop()# 如果是重复输出错误,可以额外记录详细内容以便调试if "Repeated output" in str(e):logger.debug("Repeated content sample: %s", content[:200] if content else "N/A")if not turn_success:logger.error("Turn %d failed after all retries.", current_turn)break# 若未获得 ,尝试从最后一条 assistant 消息提取if answer is None:if messages and messages[-1]['role'] == 'assistant':last_content = messages[-1]['content']else:last_content = last_responseanswer_match = re.search(r'(.*?) ', last_content, re.DOTALL)if answer_match:answer = answer_match.group(1).strip()else:# 若根本没有 ,则返回全部最后回复作为后备answer = last_content.strip() if last_content else ""# 清理临时片段for f in segment_files:if os.path.exists(f):os.unlink(f)return answer@app.route('/')def index():return render_template('index.html')@app.route('/transcribe', methods=['POST'])def transcribe():if 'video' not in request.files:return jsonify({'error': 'No video/audio file provided'}), 400video_file = request.files['video']if video_file.filename == '':return jsonify({'error': 'Empty filename'}), 400filename = secure_filename(video_file.filename)input_tmp = Nonewav_path = Nonetry:# 1. 保存上传文件suffix = os.path.splitext(filename)[1] or '.tmp'input_tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)video_file.save(input_tmp.name)input_tmp.close()# 2. 创建 WAV 临时文件(16kHz 单声道)wav_tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.wav')wav_path = wav_tmp.namewav_tmp.close()# 3. ffmpeg 转码cmd = ['ffmpeg','-i', input_tmp.name,'-vn','-acodec', 'pcm_s16le','-ar', '16000','-ac', '1','-y',wav_path]subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)# 4. 创建临时目录存放可能的音频片段tmp_dir = tempfile.mkdtemp()# 5. 调用 Speaker-Reasoner 多轮推理try:raw_answer = call_speaker_reasoner(wav_path, tmp_dir)finally:# 清理临时目录if os.path.exists(tmp_dir):for f in os.listdir(tmp_dir):fp = os.path.join(tmp_dir, f)try:os.unlink(fp)except Exception:passos.rmdir(tmp_dir)segments = parse_transcript(raw_answer)return jsonify({'success': True,'segments': segments,'raw_text': raw_answer})except subprocess.CalledProcessError as e:error_msg = e.stderr.decode() if e.stderr else 'ffmpeg failed'return jsonify({'error': f'Audio conversion failed: {error_msg}'}), 500except Exception as e:logger.exception("Transcription error")return jsonify({'error': str(e)}), 500finally:# 清理临时文件if input_tmp and os.path.exists(input_tmp.name):os.unlink(input_tmp.name)if wav_path and os.path.exists(wav_path):os.unlink(wav_path)if __name__ == '__main__':app.run(host='0.0.0.0', port=5000, debug=True) templates/index.html代码如下:
超长离线音视频说话人角色确认转录系统(强推理版) 🎤 超长离线音视频说话人角色确认转录系统(强推理版)
点击选择音视频文件 或拖拽到这里
支持WAV / MP3 / MP4 / MOV / AVI 等格式📁 请上传视频文件以开始转录3.3模型下载与准备
Speaker-Reasoner-4194h(面向带时间戳说话人归属自动语音识别的交互轮次与推理模式扩展多模态大模型):
下载链接:https://modelscope.cn/models/ASLP-lab/Speaker-Reasoner-4194h/files
3.4模型启动参数
Speaker-Reasoner-4194h启动参数:
cat SoulX-Transcriber.sh vllm serve "/home/models/Speaker-Reasoner-4194h" --port 8082 --served-model-name Speaker-Reasoner-4194h --trust-remote-code --tensor-parallel-size 2 --dtype bfloat16 --max-model-len 65536 --gpu-memory-utilization 0.95 --allowed-local-media-path / --chat-template/home/models/qwen3-omni_vllm-main/Speaker-Reasoner-web/chat_template.jinjacat /home/models/qwen3-omni_vllm-main/Speaker-Reasoner-web/chat_template.jinja{% for message in messages %}{{ '<|im_start|>' + message['role'] + 'n' }}{% if message['content'] is string %}{{ message['content'] }}{% else %}{% for item in message['content'] %}{% if item['type'] == 'text' %}{{ item['text'] }}{% elif item['type'] == 'audio' %}{{ '<|audio_start|><|audio_pad|><|audio_end|>' }}{% elif item['type'] == 'image' %}{{ '<|vision_start|><|image_pad|><|vision_end|>' }}{% elif item['type'] == 'video' %}{{ '<|vision_start|><|video_pad|><|vision_end|>' }}{% endif %}{% endfor %}{% endif %}{{ '<|im_end|>n' }}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistantn' }}{% endif %}四、运行测试
1、启动Speaker-Reasoner-4194h
docker exec -it Speaker-Reasoner-4194h bashcd /home/models/nohup ./Speaker-Reasoner-4194h.sh &2、启动超长离线音视频说话人角色确认转录系统自编程序
python Speaker-Reasoner-web-2.py* Serving Flask app 'Speaker-Reasoner-web-2' * Debug mode: onINFO:werkzeug:WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. * Running on all addresses (0.0.0.0) * Running on http://127.0.0.1:5000 * Running on http://192.168.222.65:5000INFO:werkzeug:Press CTRL+C to quitINFO:werkzeug: * Restarting with statWARNING:werkzeug: * Debugger is active!INFO:werkzeug: * Debugger PIN: 105-818-0563、访问超长离线音视频说话人角色确认转录系统自编程序
用谷歌浏览器访问




转录完成后,点击右边有时间段的文本记录,播放器会自动跳转到指定时间位置进行播放