
Funasr Transcribe
- 211 installs
- 543 repo stars
- Updated August 5, 2026
- cat-xierluo/legal-skills
Transcribe Douyin, meeting, or deposition audio via FunASR into timestamped text for search, subtitles, legal review, and agent summarization pipelines.
About
Wraps FunASR automatic speech recognition to convert Chinese and multilingual audio into accurate, timestamped transcripts within legal-skills projects. Covers input normalization, model selection, batch jobs, and export formats so content, compliance, and AI agents can search, quote, and summarize spoken material reliably.
- FunASR inference setup
- Timestamped transcripts
- Batch audio processing
- Legal workflow alignment
- Agent-ready text output
Funasr Transcribe by the numbers
- 211 all-time installs (skills.sh)
- Ranked #607 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cat-xierluo/legal-skills --skill funasr-transcribeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 211 |
|---|---|
| repo stars | ★ 543 |
| Last updated | August 5, 2026 |
| Repository | cat-xierluo/legal-skills ↗ |
What it does
Transcribe Douyin, meeting, or deposition audio via FunASR into timestamped text for search, subtitles, legal review, and agent summarization pipelines.
Files
FunASR 语音转文字
本 skill 提供本地语音识别服务,将音频或视频文件转换为结构化的 Markdown 文档。
功能概述
- 支持多种音视频格式(mp4、mov、mp3、wav、m4a、flac 等)
- 自动生成时间戳
- 支持说话人分离(diarization,默认启用)
- ONNX 加速模式:支持
paraformer-onnx与实验性的SenseVoice-Small ONNX - 单人快速模式:
--fast/"fast": true关闭 diarization,默认仍走paraformer - Paraformer ONNX 后处理优化:
paraformer-onnx单人/多人路径都会先 VAD 分段,再清理文本输出、恢复标点并输出句子级时间戳;单人路径使用全局标点恢复,多人路径使用逐段标点以保留 speaker 对齐 - 视频关键帧截图提取:自动检测并提取 PPT 幻灯片,插入到转录稿对应位置(视频文件自动启用)
- 转录后自动附带 AI 总结提示词,Agent 可一步完成总结
- 输出 Markdown 格式,便于阅读和编辑
依赖
系统依赖
| 依赖 | 安装方式 |
|---|---|
| Python 3.8+ | macOS: brew install python@3.14 |
| curl | macOS 通常自带;如缺失可执行 brew install curl |
Python 包
| 包名 | 用途 | 安装命令 |
|---|---|---|
funasr | FunASR 原生推理与 CAM++ diarization | pip install -r assets/requirements.txt |
funasr-onnx | Paraformer / SenseVoice ONNX 加速 | pip install -r assets/requirements.txt |
scenedetect[opencv]、imagehash | 视频关键帧提取 | pip install -r assets/requirements.txt |
首次需要运行 ONNX 模式时,直接执行:
python3 scripts/setup.py即可同时安装 funasr-onnx 及其依赖;SenseVoiceSmall 仅在显式指定 model=sensevoice 时按需下载。
ONNX 质量调参
paraformer-onnx 默认使用质量优先的参数组合;单人路径会复用多人路径的 ONNX VAD 分段 ASR,但不执行 CAM++ 说话人聚类:
| 参数 | 默认值 | 说明 |
|---|---|---|
FUNASR_ONNX_TEXT_SOURCE | preds | 使用清理后的 ONNX preds 文本;如遇到异常可设为 raw_tokens 回退 |
FUNASR_SERVER_ONNX_THREADS | 4 | ONNX Runtime 推理线程数,主要影响速度,不直接改善识别质量 |
FUNASR_ONNX_COMPAT_CACHE | ~/.cache/funasr-onnx-compat | ONNX 兼容导出缓存目录;兼容导出会复制模型目录,可删除该缓存后重新生成 |
单人 paraformer-onnx 会将各 VAD 片段的识别文本先拼接,再做一次全局标点恢复;这样比逐片段恢复标点更接近原生 paraformer,也能减少重复调用标点模型的耗时。
ONNX 句子级时间戳是根据字符位置和 token 时间戳做的近似映射,适合定位段落和发言轮次,不应视为逐字强对齐结果。
已验证不建议作为默认的调参方向:
- 调大 VAD 静音阈值会减少切段并提速,但 90 秒多人样本上文本相似度下降明显。
- 合并相邻 VAD 段或整段转录更容易出现错字、重复和长音频塌缩,因此单人和多人 ONNX 都不再默认整段转录。
- 给 VAD 片段额外 padding 会引入边界重复,整体质量不如默认切段。
Agent 默认工作流(转录 + 自动总结)
当用户请求转录音频/视频时,应遵循以下流程,一次性完成转录和 AI 总结:
前置步骤(必须第一个执行):设置 PATH。 某些执行环境(如 agent-executor headless 模式)的 PATH 被限制为只有插件目录,curl、python3 等系统命令找不到。必须先执行:
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"之后所有 bash 命令都必须在同一命令块中跟在 export PATH=... 后面,或在每个命令块开头都加上这行。步骤 0:环境检测(自动)
在执行转录前,检查 assets/skill-env.json 是否存在。如果不存在,先运行环境检测:
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" && cd <skill目录> && python3 scripts/init_env.py如果检测失败(退出码非0),按提示运行安装脚本:
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" && cd <skill目录> && python3 scripts/setup.py安装完成后会自动重新检测并生成 skill-env.json。
步骤 1:启动/检查服务
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" && curl -s http://127.0.0.1:8765/health如果服务未运行,后台启动:
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" && cd <skill目录> && python3 scripts/server.py --idle-timeout 600 &等待服务就绪(轮询 /health 直到返回 200)。
步骤 2:转录文件
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" && curl -s -X POST http://127.0.0.1:8765/transcribe \
-H "Content-Type: application/json" \
-d '{"file_path": "/path/to/audio.aac"}'注意:diarize默认为true,无需显式传入。如需禁用,传"diarize": false。
视频文件(mp4、mov 等)会自动启用关键帧截图提取(extract_slides),无需手动传入。如需禁用,显式传"extract_slides": false。
单人讲课/语音可传"fast": true关闭说话人分离,默认仍使用paraformer;"model": "sensevoice"仅作为实验性显式选项。
响应中包含以下关键字段:
output_path: 转录输出的 Markdown 文件路径text: 转录全文summary_prompt: AI 总结提示词(已自动附带,无需额外调用/summary)text_preview: 转录文本前 500 字预览
步骤 3:生成 AI 总结
根据 summary_prompt(或直接根据 text 内容),Agent 生成结构化 JSON 总结:
{
"full_summary": "至少400字,分成2-3段,交代背景、问题、关键事实、数据、风险与行动建议",
"speaker_summary": [
{
"speaker_order": "发言人1",
"speaker_name": "如能识别请写姓名,否则写未知",
"summary": "至少180字,涵盖该发言人的观点、依据、数据、态度与潜在影响"
}
],
"highlights": ["6-10条重点,每条60-100字"],
"keywords": ["5-8个关键词"]
}步骤 4:注入总结到文件
重要:不要只描述注入操作,必须实际执行以下命令。
将步骤 3 生成的 JSON 写入临时文件,然后调用脚本注入(比 curl 注入更可靠,无需 JSON 转义):
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" && cat > /tmp/summary_<文件名>.json << 'JSONEOF'
{步骤3生成的JSON内容}
JSONEOF
python3 <skill目录>/scripts/summary.py inject "<output_path>" /tmp/summary_<文件名>.json脚本会自动:
- 解析 JSON 并格式化为 Markdown
- 注入到 Markdown 文件的正确位置
- 添加
<!-- AI-SUMMARY:START -->/<!-- AI-SUMMARY:END -->标记
步骤 5:验证注入结果(必须执行)
注入后必须执行验证,确认摘要确实写入文件。如果验证失败,必须重试步骤 4。
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" && python3 <skill目录>/scripts/summary.py verify "<output_path>"- 如果输出
✅ 摘要已存在→ 成功,向用户报告完成 - 如果输出
❌ 摘要不存在→ 失败,回到步骤 4 重试
完整流程示例
用户:转录这个音频
↓
Agent:
1. 检查/启动服务
2. POST /transcribe {"file_path": "xxx.aac"} ← 一次调用拿到转录+提示词
3. 根据转录内容直接生成总结 JSON
4. 写 JSON 到临时文件 → python3 summary.py inject 注入
5. python3 summary.py verify 验证 → 失败则重试步骤 4
↓
用户:收到带 AI 总结的 Markdown 文件使用流程
首次使用:环境检测与依赖安装
重要:首次使用前必须先检测环境是否满足要求。
运行环境检测:
python3 scripts/check_env.py检测脚本会检查以下环境要求:
| 必需项 | 要求 | 检测命令 |
|---|---|---|
| Python | >= 3.8,python3 命令可用 | python3 --version |
| curl | HTTP 客户端(用于 API 调用) | curl --version |
| 基本命令 | ls, ps, grep | shell 内置 |
如果环境检测失败:
1. Python3 命令不可用:
# macOS 使用 homebrew 安装 Python
brew install python@3.142. curl 不可用:
# macOS 确保 curl 已安装
brew install curl3. 验证环境修复后,重新运行检测:
python3 scripts/check_env.py首次使用:安装依赖和下载模型
运行安装脚本完成环境配置:
python3 scripts/setup.py安装脚本会自动:
1. 检查 Python 版本(需要 >= 3.8) 2. 安装依赖包(FastAPI、Uvicorn、FunASR、funasr-onnx、PyTorch) 3. 下载 ASR 模型到 ~/.cache/modelscope/hub/models/
验证安装状态:
python3 scripts/setup.py --verify启动转录服务
python3 scripts/server.py如需默认开启 ONNX 加速与 INT8 量化,使用:
python3 scripts/server-onnx.py --preload服务默认运行在 http://127.0.0.1:8765
智能特性:
- 自动启动:首次请求时自动加载模型
- 空闲关闭:默认 10 分钟无活动后自动关闭以节约资源
- 可配置超时:使用
--idle-timeout参数自定义空闲超时时间(秒)
服务生命周期:
1. 启动后进入空闲监控状态 2. 接收到请求时自动加载模型并执行转录 3. 每次请求都会重置空闲计时器 4. 连续 10 分钟无请求时自动关闭 5. 下次请求时重新启动
重要提示:
- ⚠️ 请勿手动关闭服务 - 转录完成后让服务继续运行,它会自动在 10 分钟无活动后关闭
- 这样可以连续转录多个文件,无需重复启动服务
- 如需立即关闭服务,按
Ctrl+C或等待 10 分钟空闲超时
示例:自定义 30 分钟空闲超时
python3 scripts/server.py --idle-timeout 1800执行转录
使用客户端脚本转录文件:
# 转录单个文件
python3 scripts/transcribe.py /path/to/audio.mp3
# 指定输出路径
python3 scripts/transcribe.py /path/to/video.mp4 -o transcript.md
# 启用说话人分离
python3 scripts/transcribe.py /path/to/meeting.m4a --diarize
# Paraformer ONNX(更快;默认仍支持 diarization)
python3 scripts/transcribe.py /path/to/meeting.m4a --model paraformer-onnx
# Paraformer ONNX 单人路径(VAD 分段 ASR,不做说话人聚类)
python3 scripts/transcribe.py /path/to/course.m4a --model paraformer-onnx --no-diarize
# 单人讲课快速模式(关闭说话人分离,保留默认 Paraformer)
python3 scripts/transcribe.py /path/to/course.m4a --fast
# 批量转录目录
python3 scripts/transcribe.py /path/to/media_folder/
# 提取视频关键帧截图(PPT幻灯片)
python3 scripts/transcribe.py /path/to/video.mp4 --slides
# 自定义场景检测阈值(值越低越灵敏,默认20.0)
python3 scripts/transcribe.py /path/to/video.mp4 --slides --slide-threshold 15.0AI 智能总结(Claude Code 环境)
转录完成后,可以生成 AI 智能总结,充分利用 Claude Code 的原生 AI 能力。
自动模式(推荐):
使用 --auto-summary 参数,转录完成后自动生成并注入总结:
# 转录并自动生成总结(Claude Code 原生环境,无需配置 API Key)
python3 scripts/transcribe.py /path/to/audio.m4a --auto-summary
# 完整流程:说话人分离 + 自动总结
python3 scripts/transcribe.py /path/to/meeting.m4a --diarize --auto-summary工作原理:
- 脚本输出结构化总结请求(
AI_SUMMARY_REQUEST) - Claude Code 自动识别并利用内置 AI 能力生成总结
- 无需任何外部 API Key 配置
手动模式:
1. 执行转录后,脚本会自动准备总结提示词 2. 将提示词发送给 Claude AI 生成结构化总结 3. 将 Claude 返回的 JSON 结果粘贴回脚本 4. 自动将总结注入到 Markdown 文件
# 转录单个文件(输出提示词供手动调用)
python3 scripts/transcribe.py /path/to/audio.mp3
# 禁用自动总结(只输出提示词)
python3 scripts/transcribe.py /path/to/audio.m4a --no-summary总结内容结构:
- 全文总结 - 400+ 字,包含背景、问题、关键事实
- 发言人总结 - 每个发言人的观点、态度和贡献
- 重点内容 - 6-10 条核心要点
- 关键词 - 5-8 个关键术语
提示词特点:
- 专门针对中文口语化对话优化
- 保留发言人上下文和对话流程
- 结构化 JSON 输出便于解析和格式化
详细文档请查看:<references/api-reference.md>
通过 HTTP API 调用
检查服务状态:
curl http://127.0.0.1:8765/health使用 curl 直接调用 API:
curl -X POST http://127.0.0.1:8765/transcribe \
-H "Content-Type: application/json" \
-d '{"file_path": "/path/to/audio.mp3"}'
# 单人快速模式(关闭说话人分离,保留默认 Paraformer)
curl -X POST http://127.0.0.1:8765/transcribe \
-H "Content-Type: application/json" \
-d '{"file_path": "/path/to/course.m4a", "fast": true}'
# 指定 Paraformer ONNX(默认启用 diarization)
curl -X POST http://127.0.0.1:8765/transcribe \
-H "Content-Type: application/json" \
-d '{"file_path": "/path/to/meeting.m4a", "model": "paraformer-onnx"}'
# Paraformer ONNX 单人路径(VAD 分段 ASR,不做说话人聚类)
curl -X POST http://127.0.0.1:8765/transcribe \
-H "Content-Type: application/json" \
-d '{"file_path": "/path/to/course.m4a", "model": "paraformer-onnx", "diarize": false}'
# 提取视频关键帧截图
curl -X POST http://127.0.0.1:8765/transcribe \
-H "Content-Type: application/json" \
-d '{"file_path": "/path/to/video.mp4", "extract_slides": true}'API 文档(Swagger UI):
FastAPI 自动生成交互式 API 文档,访问:http://127.0.0.1:8765/docs
可在此页面中:
- 查看所有 API 端点
- 在线测试 API(不需要 curl)
- 查看请求/响应格式
- 查看详细参数说明
响应示例(健康检查):
{
"status": "ok",
"service": "FunASR Transcribe",
"uptime": 300,
"idle_time": 120
}返回字段说明:
uptime:服务运行时间(秒)idle_time:当前空闲时间(秒)
完整 API 文档
详细的 API 参考文档请查看:<references/api-reference.md>
包含:
- 所有 API 端点的完整规范
- 请求/响应格式详解
- 参数说明和示例
- 完整的 curl 命令示例
脚本说明
| 脚本 | 用途 |
|---|---|
scripts/init_env.py | 环境检测 + 生成 skill-env.json |
scripts/check_env.py | 环境检测(简化版) |
scripts/setup.py | 一键安装依赖和下载模型 |
scripts/server.py | 启动 HTTP API 服务 |
scripts/server-onnx.py | 启动默认 ONNX 加速服务 |
scripts/transcribe.py | 命令行客户端 |
scripts/auto_transcribe.py | 自动化转录脚本(推荐) |
---
自动转录 + 总结流程
本 skill 支持在任意 Agent 平台中自动完成转录 + 总结全流程。
方式一:使用自动化脚本(推荐)
# 自动转录 + 获取总结提示词(说话人分离默认启用)
python3 scripts/auto_transcribe.py /path/to/audio.aac
# 禁用说话人分离
python3 scripts/auto_transcribe.py /path/to/audio.aac --no-diarize
# 单人快速模式(关闭说话人分离,保留默认 Paraformer)
python3 scripts/auto_transcribe.py /path/to/course.m4a --fast
# 只获取总结提示词,不生成总结
python3 scripts/auto_transcribe.py /path/to/audio.aac --prompt-only方式二:HTTP API 调用
1. 转录音频(响应中已自动附带总结提示词)
curl -X POST http://127.0.0.1:8765/transcribe \
-H "Content-Type: application/json" \
-d '{"file_path": "/path/to/audio.aac"}'响应中包含 summary_prompt 字段,可直接用于生成总结,无需额外调用 /summary。
2. 注入 AI 总结
生成总结后,调用:
curl -X POST http://127.0.0.1:8765/inject_summary \
-H "Content-Type: application/json" \
-d '{
"md_path": "/path/to/audio.md",
"summary_content": "## AI 摘要\n\n### 全文总结\n...\n\n### 重点内容\n- ...\n\n### 关键词\n..."
}'---
API 端点汇总
| 端点 | 方法 | 功能 |
|---|---|---|
/health | GET | 健康检查 |
/transcribe | POST | 转录音频/视频 |
/batch_transcribe | POST | 批量转录目录 |
/summary | POST | 生成 AI 总结提示词 |
/inject_summary | POST | 将总结注入 Markdown 文件 |
/verify_summary | POST | 验证摘要是否已注入 |
配置文件
| 文件 | 说明 |
|---|---|
assets/models.json | ASR 模型配置清单 |
assets/requirements.txt | Python 依赖清单 |
输出格式
转录结果保存为 Markdown 文件,包含:
1. 标题 - 文件名(无转录时间戳) 2. 转录内容 - 格式:发言人N HH:MM:SS 换行 内容 3. AI 摘要(可选)- 包含全文总结、发言人总结、重点内容、关键词
示例格式(视频含截图):
# 转录:视频.mp4
## 转录内容
发言人1 00:02:49

各位好,今天我们来讲...
发言人1 00:03:30

这是第二段的内容...模型信息
模型存储在 ModelScope 默认缓存目录 ~/.cache/modelscope/hub/models/:
- ASR 主模型 (Paraformer) - 867MB
- SenseVoice-Small(实验性单人 ONNX 路径)- 显式指定时按需下载
- VAD 模型 - 4MB
- 标点模型 - 283MB
- 说话人分离模型 - 28MB
STT 转录优先级(重要)
正确顺序:FunASR(优先)→ Whisper CLI(fallback)
- FunASR 是主选:中文识别质量更高,支持时间戳、说话人分离、视频关键帧
- Whisper CLI 是 fallback:仅在 FunASR 服务不可用时使用(例如 funasr-onnx 安装失败、服务报错 500)
- 绝对不要:在没有先尝试 FunASR 的情况下直接用 Whisper
FunASR 失败时的排查步骤
1. 运行 python3 scripts/setup.py --verify 检查 funasr-onnx 是否可用 2. 查看服务进程日志:process_log 查看 proc_<session_id> 3. 如果 funasr-onnx 装不上,用 Whisper CLI 作为临时 fallback(见下方)
Whisper CLI Fallback(仅在 FunASR 不可用时)
# 提取音频(16kHz 单声道)
ffmpeg -i "/path/to/video.mp4" -vn -acodec pcm_s16le -ar 16000 -ac 1 -y "/tmp/audio.wav"
# Whisper 转录(tiny 模型最快,medium 质量更好)
/opt/homebrew/bin/whisper "/tmp/audio.wav" \
--model tiny \
--language Chinese \
--output_dir /tmp/transcript \
--output_format all性能参考:19 分钟音频,tiny 模型约 3-5 分钟(Mac CPU)。
故障排除
cv2 / opencv 导入失败
症状:POST /transcribe 返回 {"detail":"No module named 'cv2'"},但 pip show opencv-python-headless 显示已安装。
根因:服务进程使用的 Python 环境与 pip 安装目标不同。常见于 macOS Homebrew Python 3.14 环境,pip 安装到了系统 site-packages,但服务进程加载的是 Homebrew 路径。
排查步骤: 1. 在终端验证 cv2 是否可导入:python3 -c "import cv2; print('ok')" 2. 如果导入失败,执行:python3 -m pip install opencv-python-headless --break-system-packages 3. 确认服务进程的 Python 路径:lsof -p <server_pid> | grep python
正确启动流程:
# 确认 cv2 可用后再启动服务
python3 -c "import cv2; print('cv2 ok')"
# 如服务已在运行,先杀掉再重启
lsof -ti:8765 | xargs kill -9 2>/dev/null; sleep 1
# 重启服务
python3 scripts/server.py --idle-timeout 600 &服务端口被占用
症状:Address already in use(Errno 48)
# 杀掉占用端口的进程
lsof -ti:8765 | xargs kill -9 2>/dev/null
sleep 1FunASR 服务无响应 / 模型加载慢
首次转录需要下载模型(约 1-2GB),耐心等待。后续请求模型已缓存,速度会快很多。
视频截图功能:
视频文件(mp4、mov、avi、mkv、wmv、webm)转录时会自动启用关键帧提取。 依赖 scenedetect[opencv] 和 imagehash 已包含在 requirements.txt 中,setup.py 安装时会一并安装。 如未安装这些依赖,服务端会输出提示但不影响普通转录功能。
服务启动失败时,运行验证命令检查安装状态:
python3 scripts/setup.py --verify重新下载模型:
python3 scripts/setup.py --skip-deps{
"models": [
{
"id": "iic/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-pytorch",
"name": "ASR 主模型 (Paraformer)",
"description": "语音识别核心模型,支持中文",
"required": true
},
{
"id": "iic/speech_fsmn_vad_zh-cn-16k-common-pytorch",
"name": "VAD 模型",
"description": "语音活动检测,用于分段",
"required": true
},
{
"id": "iic/punc_ct-transformer_zh-cn-common-vocab272727-pytorch",
"name": "标点模型",
"description": "自动恢复标点符号",
"required": true
},
{
"id": "damo/speech_campplus_speaker-diarization_common",
"name": "说话人分离模型 (CAM++)",
"description": "识别不同说话人",
"required": false
},
{
"id": "iic/SenseVoiceSmall",
"name": "SenseVoice-Small(单人快速模式)",
"description": "单人讲课/语音笔记的快速模型,首次使用 ONNX 模式时按需下载",
"required": false
}
],
"default_model": "iic/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-pytorch"
}
# Core dependencies
fastapi>=0.100.0
uvicorn[standard]>=0.20.0
funasr>=1.3.0
funasr-onnx>=0.4.1
modelscope>=1.33.0
torch>=2.0.0
torchaudio>=2.0.0
httpx>=0.20.0
# Speaker diarization requires scikit-learn (indirect dependency)
# Must pin numpy<2 to avoid binary incompatibility with older pandas/sklearn builds
numpy>=1.20,<2
pandas>=1.3,<3
scikit-learn>=1.0,<2
# Video slide extraction
scenedetect[opencv]>=0.6.4
imagehash>=4.3.1
{
"env": {
"PATH": "/Users/maoking/.nvm/versions/node/v24.11.0/bin:/Users/maoking/.nvm/versions/node/v24.11.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin://Applications/Topaz Photo AI.app/Contents/Resources/bin:/Library/Apple/usr/bin:/Applications/Little Snitch.app/Contents/Components:/usr/local/share/dotnet:~/.dotnet/tools:/Users/maoking/.local/bin:/opt/homebrew/opt/python@3.14/Frameworks/Python.framework/Versions/3.14/bin:/Users/maoking/.bun/bin:/Users/maoking/.pixi/bin:/Users/maoking/.opencode/bin:/Users/maoking/.antigravity/antigravity/bin:/Users/maoking/Library/Python/3.11/bin:/Users/maoking/.nvm/versions/node/v24.11.0/bin:/Users/maoking/.cargo/bin:/Users/maoking/.orbstack/bin:/Users/maoking/.cache/lm-studio/bin:/Users/maoking/.claude/plugins/cache/claude-plugins-official/agent-sdk-dev/8e9bf4929af5/bin:/Users/maoking/.claude/plugins/cache/thedotmack/claude-mem/9.1.1/bin:/Users/maoking/.claude/plugins/cache/claude-plugins-official/code-simplifier/1.0.0/bin:/Users/maoking/.claude/plugins/cache/claude-plugins-official/context7/8e9bf4929af5/bin:/Users/maoking/.claude/plugins/cache/claude-plugins-official/frontend-design/8e9bf4929af5/bin:/Users/maoking/.claude/plugins/cache/claude-plugins-official/playground/8e9bf4929af5/bin:/Users/maoking/.claude/plugins/cache/claude-plugins-official/playwright/8e9bf4929af5/bin:/Users/maoking/.claude/plugins/cache/claude-plugins-official/ralph-loop/1.0.0/bin:/Users/maoking/.claude/plugins/cache/claude-plugins-official/skill-creator/8e9bf4929af5/bin:/Users/maoking/.claude/plugins/cache/superpowers-marketplace/superpowers/4.0.3/bin:/Users/maoking/.orbstack/bin",
"FUNASR_PYTHON": "/opt/homebrew/bin/python3"
},
"detected": {
"python3": "/opt/homebrew/bin/python3",
"curl": "/usr/bin/curl",
"ffmpeg": "/opt/homebrew/bin/ffmpeg",
"ffprobe": "/opt/homebrew/bin/ffprobe",
"python_version": "3.14.3",
"python_deps": {
"funasr": "1.3.1",
"torch": "2.11.0",
"fastapi": "0.135.3",
"uvicorn": "0.44.0"
},
"platform": "Darwin",
"machine": "arm64",
"detected_at": "2026-04-11T11:25:20.858937"
}
}
变更日志
本项目的所有重要变更都将记录在此文件。
[1.9.4] - 2026-04-19
修复
- ONNX 导出依赖延迟加载 — 移除
torch与funasr.utils.export_utils的模块级导入,仅在 ONNX 兼容导出时按需加载,避免非 ONNX 路径受 FunASR 内部导出模块变更影响 - ONNX 导出猴子补丁显式失败 — 对
funasr.utils.export_utils._onnx增加存在性检查;当 FunASR 内部 API 变化时直接报错,而不是静默回退到不兼容导出路径
改进
- 模型下载与兼容缓存提示 — 模型缺失时打印下载提示,ONNX 兼容导出缓存增加
compat_export_version过期检查,并提示缓存目录可删除后重建 - ONNX 后处理兼容说明 — 为 funasr-onnx VAD 兼容补丁、raw token 归一化和句子级时间戳近似映射补充注释与文档说明
- 中文标点切句增强 —
split_text_by_punctuation()增补顿号和冒号,减少长句合并
[1.9.3] - 2026-04-19
改进
- 单人 Paraformer ONNX 全局标点恢复 —
paraformer-onnx关闭 diarization 时,先拼接 VAD 分段 ASR 文本,再统一做一次标点恢复,减少逐段标点带来的边界断裂 - 单人 ONNX 速度进一步提升 — 5 分钟讲课样本中,单人 ONNX 分段路径的服务常驻第二次耗时从约
17.508s降至约11.751s - 单人 ONNX 文本格式更接近原生 Paraformer — 同一样本中,原始文本相似度从约
0.9607提升至约0.9733,去除标点/空白后的相似度约0.9829
技术优化
- 保留 VAD 默认阈值 — 对比
max_end_sil=600/800/1000/1200及相邻段合并策略后,确认默认800ms在单人 5 分钟样本上质量最佳,暂不引入新的 VAD 合并参数
[1.9.2] - 2026-04-19
修复
- 单人 Paraformer ONNX 整段推理质量差 —
paraformer-onnx在关闭 diarization 时不再直接整段调用 ONNX ASR,改为复用多人路径的 ONNX VAD 分段 ASR、文本清理、标点恢复和句子级时间戳映射
改进
- 单人 ONNX 稳态速度提升 — 5 分钟讲课样本中,旧单人 ONNX 整段推理耗时约
37.489s;改为 VAD 分段后,服务常驻同进程第二次耗时约17.508s - 单人 ONNX 质量恢复 — 同一样本中,旧整段 ONNX 相对原生
paraformer的文本相似度约0.6079;改为 VAD 分段后,去除标点/空白后的相似度约0.9829 - ONNX 路由一致性 —
paraformer-onnx单人和多人现在共享同一套 VAD 分段 ASR 后处理,多人路径仅额外执行 CAM++ 说话人聚类
[1.9.1] - 2026-04-19
修复
- ONNX 兼容导出失败 — 为 Python 3.14 / PyTorch 2.11 环境补充 FunASR ONNX 兼容导出层,强制使用
dynamo=False与 opset 18,并将产物缓存到~/.cache/funasr-onnx-compat - ONNX VAD `feats_len` 兼容问题 — 修复
funasr_onnx当前版本将数组长度当作标量处理导致的 VAD 调用失败 - auto_transcribe 自动启动服务失败 — 修复自动拉起服务后健康检查未传入
api_url的问题,并按传入 API 地址设置 host/port
改进
- 多人 ONNX 文本质量优化 —
paraformer-onnx + diarize会清理 ONNX 文本输出,修复逐字空格问题,并补做标点恢复 - ONNX 文本源调参 — 默认文本源从
raw_tokens调整为清理后的preds,并保留FUNASR_ONNX_TEXT_SOURCE=raw_tokens回退开关;90 秒样本相对原生paraformer的文本相似度从约0.9911提升到0.9974 - 多人 ONNX 时间戳优化 — 按补完标点后的句子重新映射时间戳,避免整段 VAD 片段只输出一个粗时间点
- 中文输出拼接优化 — 合并同一说话人的句子时使用中文友好的拼接逻辑,减少无意义空格
- fast 路由回归 Paraformer —
fast仅关闭 diarization,不再自动切到 SenseVoice;sensevoice保留为显式实验选项
验证
- 使用 18 分 07 秒多人微信通话样本验证:修复后的
paraformer-onnx + diarize耗时约272.443s,约3.99x realtime - 文本源调参后同一完整样本耗时约
291.332s,约3.73x realtime - 与此前同样本原生
paraformer + diarize基线551.59s相比,最终默认配置仍保留约1.89x速度优势
[1.9.0] - 2026-04-16
新增
- SenseVoice-Small ONNX 单人快速模式 —
/transcribe、transcribe.py、auto_transcribe.py支持fast快速路径与sensevoice/sensevoice-onnx模型别名,用于单人讲课、语音笔记等不需要 diarization 的场景 - ONNX 加速服务入口 — 新增
scripts/server-onnx.py,默认预设paraformer-onnx和 INT8 量化,便于直接启动快速服务 - 运行时解析字段 —
/transcribe响应新增resolved_model、resolved_runtime、warnings,方便客户端确认最终路由结果 - 技能级协作文档 — 为
funasr-transcribe新增TASKS.md与DECISIONS.md,补齐 issue 驱动开发的上下文传递文档
改进
- Paraformer ONNX 路由 — 服务端新增
paraformer-onnx逻辑模型,支持通过 API / CLI 显式指定更快的 ONNX 路径 - ONNX diarization 组合路径 —
paraformer-onnx + diarize采用ONNX VAD + ONNX Paraformer + CAM++ 聚类的组合实现,保留多人对话场景的说话人分离能力 - 依赖与环境检测同步 —
requirements.txt、setup.py、init_env.py、check_env.py增补funasr-onnx与server-onnx.py检测逻辑 - 技能文档更新 —
SKILL.md与references/api-reference.md补充 ONNX、--model、--fast、server-onnx.py的使用说明
技术优化
- 服务端模型路由层 —
server.py新增逻辑模型映射、自动参数解析与按需预加载能力,统一处理torch/onnx运行时 - 按需下载 SenseVoice —
assets/models.json增加可选SenseVoiceSmall条目,首次使用快速模式时自动拉取,不阻塞默认安装流程
[1.8.0] - 2026-04-16
改进
- 视频文件自动启用关键帧提取 — 转录 mp4/mov/avi/mkv/wmv/webm 等视频文件时,自动启用
extract_slides,无需手动传入参数。显式传"extract_slides": false可禁用 - slide 依赖升级为正式依赖 —
scenedetect[opencv]和imagehash不再标记为 optional,随 setup.py 一并安装
新增
- 文件级摘要注入 CLI —
summary.py新增inject和verify子命令 python3 summary.py inject <md_path> <summary_file>— 从 JSON/文本文件读取总结并注入,自动解析 JSON 格式化python3 summary.py verify <md_path>— 验证 Markdown 文件中是否存在 AI 摘要及章节完整性python3 summary.py prompt <md_path>— 生成总结提示词- 摘要验证端点 — server.py 新增
POST /verify_summary端点,返回摘要存在状态、字符数、缺失章节 - 摘要验证函数 — summary.py 新增
verify_summary_in_file()和inject_from_file()函数
修复
- 摘要注入失败问题 — Agent(MiniMax)在多步骤工具调用中不可靠,声称完成注入但实际未执行。改为文件注入方式(写 JSON 到临时文件 → 调用 Python 脚本注入),避免 curl JSON 转义问题
- 强制验证步骤 — 注入后必须运行
summary.py verify验证,失败则重试,防止 Agent "声称完成但实际未执行" - agent-executor 环境下命令找不到 — headless 模式 PATH 被限制为只有插件目录,
curl/python3找不到。SKILL.md 所有 bash 命令前加export PATH=...确保系统命令可用
[1.6.0] - 2026-04-11
新增
- 环境自动检测与配置 — 新增
scripts/init_env.py脚本 - 通过 login shell 获取完整 PATH,解决受限环境(如 Raycast Electron 沙箱)下命令不可用的问题
- 自动检测 python3、curl、ffmpeg 等工具的实际路径
- 检测 Python 版本和 funasr、torch 等关键依赖的安装状态
- 生成
skill-env.json供 agent-executor 读取并注入执行环境 - 支持
--check(只检测不写文件)、--force(强制重新检测)参数
改进
- setup.py 集成环境配置 — 安装和验证完成后自动调用
init_env.py python3 scripts/setup.py安装完成后自动生成skill-env.jsonpython3 scripts/setup.py --verify验证时刷新环境配置- SKILL.md Agent 工作流优化 — 新增步骤 0 环境检测
- Agent 执行转录前自动检查
skill-env.json是否存在 - 不存在则先运行环境检测,确保依赖就绪
技术变更
- 新增
scripts/init_env.py环境检测与配置生成脚本 scripts/setup.py安装/验证后自动调用init_env.py --force
[1.5.1] - 2026-04-09
改进
- 移除外部 API Key 依赖 — 简化
--auto-summary实现 - 不再依赖
ANTHROPIC_API_KEY或OPENAI_API_KEY - 直接利用 Claude Code 原生 AI 能力生成总结
- 脚本输出结构化总结请求,Claude Code 自动处理
技术变更
summary.py移除_call_claude_api()和_call_openai_api()外部 API 调用summary.py移除_is_claude_code_environment()检测函数generate_summary_via_api()改为输出结构化总结请求transcribe.py修复自动模式下的逻辑错误(成功时不再重复输出提示词)- 移除
anthropic和openai依赖
文档更新
- SKILL.md 更新
--auto-summary说明,明确无需外部 API Key
[1.5.0] - 2026-04-09
新增
- 自动 AI 总结生成 — 新增
--auto-summary参数,转录后自动调用 LLM API 生成并注入总结 - 支持
ANTHROPIC_API_KEY(Claude)或OPENAI_API_KEY自动调用 - 无需手动复制提示词到 LLM,彻底自动化
- 使用方式:
python scripts/transcribe.py audio.m4a --auto-summary - summary.py 新增 `generate_summary_via_api()` 函数 — 直接调用 LLM API 生成总结并注入文件
依赖更新
- 新增
anthropic>=0.18.0— Claude API 支持 - 新增
openai>=1.0.0— OpenAI API 支持(备选)
[1.4.1] - 2026-04-08
修复
- 修复说话人分离功能不可用 — 解决
ClusterBackend导入失败的问题 - 原因:numpy 2.x 与用旧版本编译的 pandas/sklearn 二进制不兼容
- 修复:添加
numpy>=1.20,<2、pandas>=1.3,<3、scikit-learn>=1.0,<2版本约束 setup.py --verify现在会检测 sklearn 导入状态和 numpy 版本兼容性
改进
- requirements.txt 依赖声明完善 — 明确声明说话人分离所需的间接依赖
- 新增
numpy>=1.20,<2— 避免与 pandas/sklearn 的二进制兼容问题 - 新增
pandas>=1.3,<3— FunASR CAM++ speaker model 的传递依赖 - 新增
scikit-learn>=1.0,<2— 说话人分离核心依赖 - setup.py 验证增强 — 验证安装时会检查:
- scikit-learn 是否能正常导入
- numpy 版本是否与依赖兼容
[1.4.0] - 2026-04-05
改进
- 说话人分离默认启用 —
diarize参数默认值从false改为true - 两方以上对话是常态,默认启用更符合实际使用场景
- CLI 新增
--no-diarize参数用于显式禁用 - 转录后自动附带总结提示词 —
/transcribe响应新增summary_prompt和text_preview字段 - Agent 一次调用即可拿到转录结果 + 总结提示词,无需额外请求
/summary - 直接生成总结 JSON 后调用
/inject_summary即可完成全流程 - SKILL.md 新增默认工作流 — 平台无关的 Agent 工作流章节,任何 Agent 平台均可遵循
- 移除环境检测 — 删除
detect_agent_environment()函数,总结由 Agent 自行完成,server 无需感知运行平台
清理
- 移除
detect_agent_environment()环境检测函数,server 无需感知运行平台 - 移除 SKILL.md 中对特定平台的绑定描述(OpenClaw、Claude Code 等)
- 移除 Nano/E2E 模型死代码(
get_model_type()函数、init_model()中的 E2E 分支、--model参数) - 移除
models.json中不可用的 Nano 模型条目 - 移除
--claude-code参数(功能已被/transcribe返回summary_prompt取代) - 移除未使用的全局变量
model/model_with_spk和inject_summary_to_file导入 - 修复 API 文档字符串中
diarize默认值描述(false → true) - 简化
transcribe.py帮助文本,移除 Nano 模型示例
[1.3.0] - 2026-04-05
新增
- 视频关键帧(PPT 幻灯片)自动提取 — 转录视频时可同时提取画面变化截图
- 四层过滤流水线:场景检测+兜底采样 → pHash 去重 → 空白回查补帧 → 最终过滤
- PySceneDetect 检测画面变化 + 每 3 分钟兜底采样防止空白
- 5 分钟以上无变化区域自动回查补帧
- 截图插入转录文本对应时间戳位置
- 通过
--slides参数启用 - 转录归档(Archive)机制 — 每次转录自动归档完整记录
- 归档目录:
archive/YYYYMMDD_HHMMSS_文件名/ - 包含:Markdown 副本、截图副本(如有)、
transcription_meta.json元数据 - API 响应新增
archive_path字段
改进
result_to_markdown()支持在转录段落间插入截图引用/transcribe端点新增extract_slides、slide_threshold参数auto_transcribe.py新增--slides、--slide-threshold命令行参数assets/requirements.txt新增scenedetect[opencv]、imagehash依赖
依赖
scenedetect[opencv]>=0.6.4— 视频场景检测imagehash>=4.3.1— 感知哈希去重
[1.2.0] - 2026-02-14
修复
- 时间戳分段输出 - 修复非说话人分离模式下转录结果为整段文本的问题
- 之前:FunASR 返回
timestamp字段而非sentence_info,导致代码 fallback 到整段输出 - 现在:正确处理
timestamp字段,按句子(。!?)分割文本并分配时间戳 - 新增
split_text_by_sentences()函数,支持中文句子分割
技术变更
result_to_markdown()函数重构,新增timestamp字段处理分支- 根据字符位置比例计算每个句子对应的时间戳索引
- 支持处理没有结束符的剩余文本段落
效果对比
- 修复前:1 个大段落,时间戳固定为 00:00
- 修复后:按句子分成 74 个段落,每个段落有对应的准确时间戳
[1.1.1] - 2025-01-07
改进
- 代码精简 - summary.py 从 475 行精简到 285 行(-40%)
- 移除所有外部 API 集成代码(OpenAI, SiliconFlow)
- 移除环境变量加载和配置文件处理
- 专注 Claude Code 环境原生能力
功能优化
- 默认启用总结 - 转录完成后自动显示总结提示词
- 简化参数 - 移除
--summary,新增--no-summary禁用选项 - 移除配置 - 删除
config/summarization.env(无需外部 API 配置) - 优化交互 - 移除 input() 交互,直接输出提示词供 Claude 使用
技术变更
- summary.py 专注 Claude Code 环境功能
- transcribe.py 默认启用总结流程
- 清理所有外部 API 依赖代码
[1.1.0] - 2025-01-07
新增
- AI 智能总结功能 - 转录完成后可自动生成结构化会议纪要
- Claude Code 环境原生支持 - 使用 Claude Code 内置 AI 能力生成总结,无需外部 API
- 结构化总结输出 - 包含全文总结、发言人总结、重点内容、关键词等模块
- 说话人视角识别 - 自动识别发言人顺序并保留对话上下文
- 总结注入功能 - 自动将生成的总结注入到 Markdown 文件的对应位置
- 交互式总结流程 - 转录完成后自动提示是否需要生成 AI 总结
技术实现
- summary.py - AI 总结工具模块
summarize_file_for_claude()- 为 Claude Code 环境准备总结提示词inject_summary_to_file()- 将总结注入到 Markdown 文件get_transcription_text()- 提取纯文本转录内容create_summary_prompt()- 生成结构化总结提示词_extract_speaker_orders()- 智能识别发言人顺序_build_summary_markdown()- 构建 Markdown 格式总结- 中文对话优化 - 专门针对中文口语化对话的提示词模板
- JSON 结构化输出 - 支持解析和格式化 AI 生成的总结结果
总结内容
- 全文总结 - 400+ 字,包含背景、问题、关键事实
- 发言人总结 - 每个发言人的观点、态度和贡献
- 重点内容 - 6-10 条核心要点
- 关键词 - 5-8 个关键术语
使用改进
- 转录完成后自动提示是否生成总结
- 支持命令行参数
--summary自动启用总结 - 交互式输入 AI 生成的总结结果
- 自动解析 JSON 或纯文本格式总结
依赖更新
openai- AI 总结 API 支持(保留用于向后兼容)httpx- 异步 HTTP 客户端
[1.0.0] - 2025-01-07
初始功能
- FunASR 语音转文字技能初始版本
- 支持多种音视频格式(mp4、mov、mp3、wav、m4a、flac、aac、opus、wma、caf)
- 自动生成带时间戳的 Markdown 转录结果
- 说话人分离(diarization)功能
- 单文件转录 API
- 批量目录转录 API
- 健康检查 API
- 一键安装脚本(自动检测系统环境)
- 自动下载和配置 ASR 模型
核心技术
- 基于 FunASR 和 ModelScope 的本地 ASR 服务
- FastAPI HTTP API 服务器(替代 Flask)
- Uvicorn ASGI 服务器
- VAD + ASR + Punctuation + Speaker Diarization 完整流程
- PyTorch 和 torchaudio 深度学习框架
- 自动模型缓存系统(~/.cache/modelscope/hub/models/)
智能特性
- 自动启动:首次请求时自动加载模型
- 空闲关闭:默认 10 分钟无活动后自动关闭以节约资源
- 可配置超时:支持自定义空闲超时时间(--idle-timeout 参数)
- 后台监控:独立的空闲监控线程
- 优雅关闭:支持 SIGTERM/SIGINT 信号处理
文档
- 完整的 SKILL.md 使用指南
- 详细的 API 参考文档(references/api-reference.md)
- 交互式 Swagger UI 文档(/docs)
- 系统环境检测和故障排除指南
- 服务生命周期管理说明
服务架构
- RESTful API 设计
- Pydantic 数据模型验证
- HTTP 中件间自动活动时间跟踪
- 线程安全的模型管理
- 跨平台支持(Windows、macOS、Linux)
MIT License
Copyright (c) 2025 FunASR Transcribe Skill Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## Third-Party Dependencies
This Skill utilizes the following third-party services:
- **FunASR Service** (https://github.com/modelscope/FunASR) - Speech recognition service
- Separate terms of service may apply
- Requires local FunASR server for operation
This license applies only to the Skill code and does not extend to the
underlying FunASR service or its usage terms.
API 参考文档
端点列表
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /health | 健康检查 |
| POST | /transcribe | 转录单个文件 |
| POST | /batch_transcribe | 批量转录目录 |
1. 健康检查
检查服务状态和运行信息。
请求
GET /health响应示例
{
"status": "ok",
"service": "FunASR Transcribe",
"uptime": 300,
"idle_time": 120
}响应字段
| 字段 | 类型 | 描述 |
|---|---|---|
status | string | 服务状态,"ok" 表示正常运行 |
service | string | 服务名称 |
uptime | integer | 服务运行时间(秒) |
idle_time | integer | 当前空闲时间(秒) |
2. 转录单个文件
将音频或视频文件转录为 Markdown 文档。
请求
POST /transcribe
Content-Type: application/json
{
"file_path": "/path/to/audio.mp3",
"output_path": "/path/to/output.md",
"diarize": true,
"model": "paraformer-onnx",
"fast": false
}请求参数
| 参数 | 类型 | 必需 | 描述 |
|---|---|---|---|
file_path | string | 是 | 要转录的文件绝对路径 |
output_path | string | 否 | 输出 Markdown 文件路径(默认:原文件同目录下的 .md 文件) |
diarize | boolean | 否 | 是否启用说话人分离(默认:true) |
model | string | 否 | 逻辑模型名:paraformer、paraformer-onnx、sensevoice、sensevoice-onnx |
model_id | string | 否 | 自定义底层模型 ID |
fast | boolean | 否 | 单人快速模式;关闭 diarization,默认保留 paraformer |
quantize | boolean | 否 | ONNX 模式是否启用 INT8 量化 |
paraformer-onnx单人和多人路径都会先使用 ONNX VAD 分段,再补做 ONNX 文本清理、标点恢复和句子级时间戳映射;diarize=false时使用全局标点恢复,diarize=true时使用逐段标点并额外执行 CAM++ 说话人聚类。质量优先时仍建议使用原生paraformer。
默认文本源为清理后的preds;如需回退到raw_tokens,可在启动服务前设置FUNASR_ONNX_TEXT_SOURCE=raw_tokens。
ONNX 句子级时间戳通过字符比例近似映射 token 时间戳,适合段落级定位,不代表逐字强对齐。
支持的格式
- 视频:mp4, avi, mov, mkv, wmv, webm
- 音频:mp3, wav, m4a, flac, aac, opus, wma, caf
响应示例(成功)
{
"success": true,
"output_path": "/path/to/audio.md",
"text": "这是转录的文本内容...",
"sentence_count": 25,
"resolved_model": "paraformer-onnx",
"resolved_runtime": "onnx",
"warnings": []
}响应字段
| 字段 | 类型 | 描述 |
|---|---|---|
success | boolean | 转录是否成功 |
output_path | string | 生成的 Markdown 文件路径 |
text | string | 转录的纯文本内容 |
sentence_count | integer | 转录句子数量 |
resolved_model | string | 最终生效的逻辑模型 |
resolved_runtime | string | 最终运行时(torch / onnx) |
warnings | array | 自动路由或兼容性提示 |
error | string | 错误信息(仅失败时返回) |
响应示例(失败)
{
"success": false,
"error": "文件不存在: /path/to/audio.mp3"
}完整示例
# 基础转录
curl -X POST http://127.0.0.1:8765/transcribe \
-H "Content-Type: application/json" \
-d '{"file_path": "/path/to/audio.mp3"}'
# 指定输出路径
curl -X POST http://127.0.0.1:8765/transcribe \
-H "Content-Type: application/json" \
-d '{"file_path": "/path/to/video.mp4", "output_path": "/path/to/transcript.md"}'
# 启用说话人分离
curl -X POST http://127.0.0.1:8765/transcribe \
-H "Content-Type: application/json" \
-d '{"file_path": "/path/to/meeting.m4a", "diarize": true}'
# Paraformer ONNX + diarization
curl -X POST http://127.0.0.1:8765/transcribe \
-H "Content-Type: application/json" \
-d '{"file_path": "/path/to/meeting.m4a", "model": "paraformer-onnx", "diarize": true}'
# Paraformer ONNX 单人路径(VAD 分段 ASR,不做说话人聚类)
curl -X POST http://127.0.0.1:8765/transcribe \
-H "Content-Type: application/json" \
-d '{"file_path": "/path/to/course.m4a", "model": "paraformer-onnx", "diarize": false}'
# 单人快速模式(关闭说话人分离,保留默认 Paraformer)
curl -X POST http://127.0.0.1:8765/transcribe \
-H "Content-Type: application/json" \
-d '{"file_path": "/path/to/course.m4a", "fast": true}'3. 批量转录
转录目录中的所有支持文件。
请求
POST /batch_transcribe
Content-Type: application/json
{
"directory": "/path/to/media_folder",
"output_dir": "/path/to/output_folder",
"diarize": true,
"model": "paraformer"
}请求参数
| 参数 | 类型 | 必需 | 描述 |
|---|---|---|---|
directory | string | 是 | 要转录的目录绝对路径 |
output_dir | string | 否 | 输出目录(默认:同输入目录) |
diarize | boolean | 否 | 是否启用说话人分离(默认:true) |
model | string | 否 | 逻辑模型名 |
fast | boolean | 否 | 单人快速模式 |
响应示例(成功)
{
"success": true,
"total": 3,
"results": [
{
"file": "/path/to/audio1.mp3",
"output": "/path/to/output/audio1.md",
"success": true
},
{
"file": "/path/to/audio2.wav",
"output": "/path/to/output/audio2.md",
"success": true
},
{
"file": "/path/to/video.mp4",
"output": "/path/to/output/video.md",
"success": false,
"error": "文件格式不支持"
}
]
}响应字段
| 字段 | 类型 | 描述 |
|---|---|---|
success | boolean | 批量操作是否成功 |
total | integer | 要转录的文件总数 |
results | array | 每个文件的转录结果 |
results[].file | string | 原始文件路径 |
results[].output | string | 输出文件路径(仅成功时) |
results[].success | boolean | 单文件转录是否成功 |
results[].error | string | 错误信息(仅失败时) |
error | string | 批量操作错误信息(仅失败时返回) |
完整示例
# 批量转录目录
curl -X POST http://127.0.0.1:8765/batch_transcribe \
-H "Content-Type: application/json" \
-d '{"directory": "/path/to/media_folder"}'
# 指定输出目录
curl -X POST http://127.0.0.1:8765/batch_transcribe \
-H "Content-Type: application/json" \
-d '{"directory": "/path/to/media_folder", "output_dir": "/path/to/output"}'
# 启用说话人分离
curl -X POST http://127.0.0.1:8765/batch_transcribe \
-H "Content-Type: application/json" \
-d '{"directory": "/path/to/meetings", "diarize": true}'
# 批量单人快速模式(关闭说话人分离,保留默认 Paraformer)
curl -X POST http://127.0.0.1:8765/batch_transcribe \
-H "Content-Type: application/json" \
-d '{"directory": "/path/to/courses", "fast": true}'4. AI 总结功能(Claude Code 环境)
转录完成后,可以使用 AI 总结功能对转录内容进行智能分析和总结。
注意:AI 总结功能专为 Claude Code 环境设计,使用 Claude 的原生 AI 能力,无需配置外部 API。
4.1 工作流程
1. 执行转录命令 2. 转录完成后自动生成总结提示词 3. 将提示词发送给 Claude AI 生成结构化总结 4. Claude 返回 JSON 格式的总结结果 5. 将总结注入到 Markdown 文件
4.2 使用方法
默认模式(推荐)
# 转录单个文件(自动启用总结)
python scripts/transcribe.py /path/to/audio.mp3
# 启用说话人分离并生成总结
python scripts/transcribe.py /path/to/meeting.m4a --diarize转录完成后会自动显示总结提示词。
禁用总结
# 转录但不生成总结
python scripts/transcribe.py /path/to/audio.mp3 --no-summary4.3 总结内容结构
AI 总结功能会生成:
1. 全文总结 - 至少 400 字,分成 2-3 段,包含背景、问题、关键事实、数据、风险与行动建议 2. 发言人总结 - 每个发言人的观点、依据、数据、态度与潜在影响(至少 180 字/人) 3. 重点内容 - 6-10 条重点,每条 60-100 字,明确事实/数据/结论/行动 4. 关键词 - 5-8 个关键词
总结结果会直接插入到转录的 Markdown 文件中,使用 <!-- AI-SUMMARY:START --> 和 <!-- AI-SUMMARY:END --> 标记。
4.4 提示词特点
- 专门针对中文口语化对话优化
- 保留发言人上下文和对话流程
- 自动识别发言人顺序(speaker_0, speaker_1 等)
- 结构化 JSON 输出便于解析和格式化
4.5 示例
转录输出示例:
✅ 转录完成
📄 输出: /path/to/audio.md
📝 句子数: 25
🤖 正在准备 AI 总结...
============================================================
📋 请将以下提示词发送给 Claude AI 以生成总结:
============================================================
你是一位擅长处理口语化中文对话的专业纪要分析师。请从非结构化逐字稿中提炼事件脉络、各方观点、关键数据和行动建议,保持客观,不捏造信息。
请阅读以下逐字稿,输出 JSON 结果,其结构必须为:
{
"full_summary": "至少400字,分成2-3段,交代背景、问题、关键事实、数据、风险与行动建议",
"speaker_summary": [
{
"speaker_order": "发言人1",
"speaker_name": "如能识别请写姓名,否则写未知",
"summary": "至少180字,涵盖该发言人的观点、依据、数据、态度与潜在影响"
}
],
"highlights": ["6-10条重点,每条60-100字,明确事实/数据/结论/行动"],
"keywords": ["5-8个关键词"]
}
请确保逐字稿中出现的每一位发言人(发言人1、发言人2……)都提供总结,不得遗漏或虚构。
以下是完整文本:
[转录文本内容...]
请输出 JSON 格式的总结。
============================================================#!/usr/bin/env python3
# -*- encoding: utf-8 -*-
"""
FunASR 自动转录 + 总结脚本
此脚本用于 OpenClaw / Claude Code 等 Agent 环境中,自动完成:
1. 转录音频/视频文件
2. 生成 AI 总结提示词
3. 输出总结内容(供 Agent 调用 LLM 生成总结)
4. 将总结注入 Markdown 文件
用法:
python auto_transcribe.py <音频文件路径> [选项]
选项:
--output PATH 输出 Markdown 文件路径(默认与音频同目录)
--diarize 启用说话人分离
--model MODEL 指定模型(paraformer / paraformer-onnx / sensevoice / sensevoice-onnx)
--fast 单人快速模式(自动关闭 diarization,保留当前模型路径)
--no-summary 跳过总结步骤
--prompt-only 只返回总结提示词,不生成总结
--api URL API 地址(默认 http://127.0.0.1:8765)
示例:
python auto_transcribe.py /path/to/audio.aac
python auto_transcribe.py /path/to/audio.mp4 --diarize
python auto_transcribe.py /path/to/course.m4a --model paraformer-onnx --no-diarize
python auto_transcribe.py /path/to/audio.m4a --prompt-only
"""
import argparse
import json
import os
import sys
import requests
from pathlib import Path
from urllib.parse import urlparse
def check_server(api_url: str) -> bool:
"""检查服务是否运行"""
try:
resp = requests.get(f"{api_url}/health", timeout=5)
if resp.status_code == 200:
return True
except Exception:
pass
return False
def start_server(api_url: str):
"""尝试启动服务"""
print("FunASR 服务未运行,尝试启动...")
import subprocess
script_dir = Path(__file__).parent.absolute()
parsed = urlparse(api_url)
host = parsed.hostname or "127.0.0.1"
port = str(parsed.port or 8765)
subprocess.Popen(
[sys.executable, str(script_dir / "server.py"), "--host", host, "--port", port],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
# 等待服务启动
import time
for _ in range(30):
time.sleep(1)
if check_server(api_url):
print("✅ FunASR 服务已启动")
return True
print("❌ 无法启动 FunASR 服务")
return False
def transcribe(file_path: str, output_path: str = None, diarize: bool = False,
model: str = None, fast: bool = False,
api_url: str = "http://127.0.0.1:8765",
extract_slides: bool = False, slide_threshold: float = 27.0) -> dict:
"""转录音频文件"""
print(f"📝 转录中: {file_path}")
payload = {
"file_path": file_path,
"diarize": diarize,
"fast": fast,
"extract_slides": extract_slides,
"slide_threshold": slide_threshold
}
if output_path:
payload["output_path"] = output_path
if model:
payload["model"] = model
resp = requests.post(f"{api_url}/transcribe", json=payload, timeout=600)
resp.raise_for_status()
result = resp.json()
if result.get("success"):
print(f"✅ 转录完成: {result.get('output_path')}")
else:
print(f"❌ 转录失败: {result.get('error')}")
sys.exit(1)
return result
def get_summary_prompt(md_path: str, api_url: str = "http://127.0.0.1:8765") -> dict:
"""获取总结提示词"""
print(f"📋 生成总结提示词...")
resp = requests.post(
f"{api_url}/summary",
json={"md_path": md_path},
timeout=30
)
resp.raise_for_status()
result = resp.json()
if result.get("success"):
print(f"✅ 提示词已生成")
else:
print(f"❌ 获取提示词失败: {result.get('error')}")
sys.exit(1)
return result
def inject_summary(md_path: str, summary_content: str, api_url: str = "http://127.0.0.1:8765") -> dict:
"""注入总结到 Markdown 文件"""
print(f"📝 注入总结到文件...")
resp = requests.post(
f"{api_url}/inject_summary",
json={
"md_path": md_path,
"summary_content": summary_content
},
timeout=30
)
resp.raise_for_status()
result = resp.json()
if result.get("success"):
print(f"✅ 总结已注入: {result.get('output_path')}")
else:
print(f"❌ 注入失败: {result.get('error')}")
sys.exit(1)
return result
def main():
parser = argparse.ArgumentParser(description="FunASR 自动转录 + 总结")
parser.add_argument("file", help="音频/视频文件路径")
parser.add_argument("--output", "-o", help="输出 Markdown 文件路径")
parser.add_argument("--diarize", action="store_true", default=True, help="启用说话人分离(默认启用)")
parser.add_argument("--no-diarize", action="store_false", dest="diarize", help="禁用说话人分离")
parser.add_argument("--model", choices=["paraformer", "paraformer-onnx", "sensevoice", "sensevoice-onnx"], help="指定模型")
parser.add_argument("--fast", action="store_true", help="单人快速模式:关闭 diarization,保留当前模型路径")
parser.add_argument("--no-summary", action="store_true", help="跳过总结步骤")
parser.add_argument("--prompt-only", action="store_true", help="只返回总结提示词,不生成总结")
parser.add_argument("--api", default="http://127.0.0.1:8765", help="API 地址")
parser.add_argument("--slides", action="store_true", help="提取视频关键帧截图(PPT幻灯片)")
parser.add_argument("--slide-threshold", type=float, default=27.0, help="场景检测阈值(默认27.0,值越低越灵敏)")
args = parser.parse_args()
api_url = args.api
if args.fast and args.diarize:
args.diarize = False
print("⚡ fast 模式已自动关闭说话人分离")
# 检查服务
if not check_server(api_url):
if not start_server(api_url):
print("错误: FunASR 服务未运行且无法启动")
sys.exit(1)
# 确定输出路径
file_path = Path(args.file).absolute()
if args.output:
output_path = str(Path(args.output).absolute())
else:
output_path = str(file_path.with_suffix(".md"))
# 步骤 1: 转录
transcribe_result = transcribe(
str(file_path),
output_path=output_path,
diarize=args.diarize,
model=args.model,
fast=args.fast,
api_url=api_url,
extract_slides=args.slides,
slide_threshold=args.slide_threshold,
)
md_path = transcribe_result.get("output_path", output_path)
# 步骤 2: 获取总结提示词
if not args.no_summary:
summary_result = get_summary_prompt(md_path, api_url)
if args.prompt_only:
# 只输出提示词(供 Agent 使用)
print("\n" + "="*60)
print("总结提示词(供 Agent 调用 LLM 生成总结):")
print("="*60)
print(summary_result.get("summary_prompt", ""))
print("="*60)
print(f"\n📄 Markdown 文件: {md_path}")
print("\n下一步: 使用上面的提示词调用 LLM 生成总结,")
print("然后使用 inject_summary 端点将总结注入文件。")
else:
# 输出完整信息
print("\n" + "="*60)
print("📋 总结提示词:")
print("="*60)
print(summary_result.get("summary_prompt", ""))
print("="*60)
print(f"\n📄 Markdown 文件: {md_path}")
print("\n💡 提示: 使用上述提示词调用 LLM 生成总结,")
print(" 然后调用 /inject_summary 将总结注入文件。")
print(" 或使用本脚本的完整流程自动完成。")
else:
print(f"\n✅ 转录完成: {md_path}")
print(" (已跳过总结步骤)")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# -*- encoding: utf-8 -*-
"""
FunASR Skill 环境检测脚本
检测当前环境是否满足 funasr-transcribe skill 的运行要求。
首次使用前必须运行此脚本进行环境检测。
用法:
python3 scripts/check_env.py
退出码:
0 - 所有检测通过
1 - 检测失败(环境不满足要求)
"""
import sys
import shutil
import subprocess
from pathlib import Path
# 检测结果
issues = []
warnings = []
def check_python():
"""检测 Python 环境"""
print("=" * 60)
print("检测 Python3 环境...")
print("-" * 60)
# 1. 检查 python3 命令是否可用
python3_path = shutil.which("python3")
if python3_path:
print(f" ✅ python3 命令可用: {python3_path}")
else:
print(" ❌ python3 命令不可用")
print(" 💡 建议: 使用 homebrew 安装 python@3.14")
print(" brew install python@3.14")
issues.append("python3 命令不可用")
# 2. 检查 Python 版本
try:
result = subprocess.run(
["python3", "--version"],
capture_output=True,
text=True,
timeout=5
)
version_output = result.stdout.strip() or result.stderr.strip()
print(f" ℹ️ {version_output}")
# 解析版本号
version_str = version_output.replace("Python ", "")
major, minor, _ = version_str.split(".")[:3]
if int(major) >= 3 and int(minor) >= 8:
print(" ✅ Python 版本满足要求 (>= 3.8)")
else:
issues.append(f"Python 版本过低: {version_str} (需要 >= 3.8)")
print(f" ❌ Python 版本过低 (需要 >= 3.8)")
except FileNotFoundError:
print(" ❌ 无法执行 python3 命令")
except subprocess.TimeoutExpired:
print(" ❌ python3 命令执行超时")
except Exception as e:
print(f" ❌ 检查 Python 版本时出错: {e}")
print()
def check_curl():
"""检测 curl"""
print("=" * 60)
print("检测 curl...")
print("-" * 60)
curl_path = shutil.which("curl")
if curl_path:
print(f" ✅ curl 命令可用: {curl_path}")
try:
result = subprocess.run(
["curl", "--version"],
capture_output=True,
text=True,
timeout=5
)
version_line = result.stdout.split("\n")[0]
print(f" ℹ️ {version_line}")
except:
pass
else:
print(" ❌ curl 命令不可用")
print(" 💡 建议: macOS 通常自带 curl,如果不可用请检查 PATH")
issues.append("curl 命令不可用")
print()
def check_basic_commands():
"""检测基本命令"""
print("=" * 60)
print("检测基本命令 (ls, ps, grep)...")
print("-" * 60)
basic_commands = ["ls", "ps", "grep"]
all_ok = True
for cmd in basic_commands:
path = shutil.which(cmd)
if path:
print(f" ✅ {cmd}: {path}")
else:
print(f" ❌ {cmd}: 不可用")
all_ok = False
if not all_ok:
issues.append("部分基本命令不可用")
print()
def check_skill_dirs():
"""检测 skill 目录结构"""
print("=" * 60)
print("检测 Skill 目录结构...")
print("-" * 60)
script_dir = Path(__file__).parent.absolute()
skill_dir = script_dir.parent
required_files = [
"SKILL.md",
"scripts/server.py",
"scripts/server-onnx.py",
"scripts/transcribe.py",
"scripts/auto_transcribe.py",
"scripts/setup.py",
]
all_ok = True
for file_path in required_files:
full_path = skill_dir / file_path
if full_path.exists():
print(f" ✅ {file_path}")
else:
print(f" ❌ {file_path} (缺失)")
all_ok = False
if not all_ok:
issues.append("Skill 文件缺失")
print()
return all_ok
def main():
print()
print("=" * 60)
print(" FunASR Skill - 环境检测")
print("=" * 60)
print()
print("首次使用 funasr-transcribe skill 前,请先检测环境是否满足要求。")
print()
check_python()
check_curl()
check_basic_commands()
skill_ok = check_skill_dirs()
# 汇总结果
print("=" * 60)
print(" 检测结果汇总")
print("=" * 60)
if not issues:
print()
print(" ✅ 所有检测通过!环境满足要求。")
print()
print(" 下一步:")
print(" 1. 运行安装脚本: python3 scripts/setup.py")
print(" 2. 启动服务: python3 scripts/server.py")
print(" 或 ONNX 加速服务: python3 scripts/server-onnx.py --preload")
print(" 3. 开始转录!")
print()
return 0
else:
print()
print(f" ❌ 检测到 {len(issues)} 个问题,环境不满足要求:")
print()
for i, issue in enumerate(issues, 1):
print(f" {i}. {issue}")
print()
print(" 请修复上述问题后重新运行检测:")
print(" python3 scripts/check_env.py")
print()
return 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
# -*- encoding: utf-8 -*-
"""
FunASR Skill 环境检测与配置生成脚本
检测当前环境的工具路径和依赖,生成 skill-env.json 供执行器读取。
适用于 Claude Code CLI(诊断用)和 Raycast agent-executor(环境注入用)。
用法:
python3 scripts/init_env.py # 检测环境并生成 skill-env.json
python3 scripts/init_env.py --check # 只检测,不写文件
python3 scripts/init_env.py --force # 强制重新检测(覆盖已有文件)
退出码:
0 - 检测通过,skill-env.json 已生成
1 - 检测失败(缺少必要工具)
"""
import os
import sys
import json
import shutil
import platform
import subprocess
import argparse
from pathlib import Path
from datetime import datetime
# skill 根目录
SKILL_DIR = Path(__file__).resolve().parent.parent
ENV_FILE = SKILL_DIR / "assets" / "skill-env.json"
# 必需工具
REQUIRED_TOOLS = ["python3", "curl"]
# 可选但建议的工具
OPTIONAL_TOOLS = ["ffmpeg", "ffprobe"]
# Python 依赖(检测是否已安装)
PYTHON_DEPS = ["funasr", "funasr_onnx", "torch", "fastapi", "uvicorn"]
def get_login_shell_path():
"""通过 login shell 获取完整的 PATH 环境变量"""
system = platform.system()
if system == "Darwin" or system == "Linux":
# 尝试通过 login shell 获取 PATH
shell = os.environ.get("SHELL", "/bin/zsh")
try:
result = subprocess.run(
[shell, "-l", "-c", "echo $PATH"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
# 备选:尝试 path_helper(macOS)
if system == "Darwin":
try:
result = subprocess.run(
["/usr/libexec/path_helper", "-s"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
# path_helper 输出格式: PATH="..."; export PATH;
path_line = result.stdout.strip()
if 'PATH="' in path_line:
path_val = path_line.split('PATH="')[1].split('"')[0]
return path_val
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
# 最终回退:使用当前 PATH
return os.environ.get("PATH", "")
def detect_tool(name, search_path=None):
"""检测工具的实际路径"""
return shutil.which(name, path=search_path)
def detect_python_version(python_path):
"""检测 Python 版本"""
if not python_path:
return None
try:
result = subprocess.run(
[python_path, "--version"],
capture_output=True, text=True, timeout=5
)
output = result.stdout.strip() or result.stderr.strip()
return output.replace("Python ", "")
except Exception:
return None
def check_python_deps():
"""检查 Python 依赖是否已安装"""
installed = {}
for dep in PYTHON_DEPS:
try:
result = subprocess.run(
[sys.executable, "-c", f"import {dep}; print({dep}.__version__)"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
installed[dep] = result.stdout.strip()
else:
installed[dep] = None
except Exception:
installed[dep] = None
return installed
def run_detection():
"""执行完整的环境检测"""
issues = []
warnings = []
# 1. 获取完整 PATH
full_path = get_login_shell_path()
if not full_path:
issues.append("无法获取 PATH 环境变量")
# 2. 检测必需工具
detected_tools = {}
for tool in REQUIRED_TOOLS:
path = detect_tool(tool, full_path)
if path:
detected_tools[tool] = path
else:
# 也尝试在默认 PATH 中查找
path = detect_tool(tool)
if path:
detected_tools[tool] = path
else:
issues.append(f"必需工具 {tool} 未找到")
# 3. 检测可选工具
for tool in OPTIONAL_TOOLS:
path = detect_tool(tool, full_path) or detect_tool(tool)
if path:
detected_tools[tool] = path
else:
if tool == "ffmpeg":
warnings.append(f"可选工具 {tool} 未安装(视频关键帧提取需要)")
# 4. Python 版本
python_version = None
if "python3" in detected_tools:
python_version = detect_python_version(detected_tools["python3"])
if python_version:
parts = python_version.split(".")
if len(parts) >= 2 and (int(parts[0]) < 3 or int(parts[1]) < 8):
issues.append(f"Python 版本过低: {python_version}(需要 >= 3.8)")
# 5. Python 依赖
python_deps = check_python_deps()
return {
"full_path": full_path,
"tools": detected_tools,
"python_version": python_version,
"python_deps": python_deps,
"issues": issues,
"warnings": warnings,
}
def generate_env_json(detection):
"""生成 skill-env.json 内容"""
env_data = {
"env": {
"PATH": detection["full_path"],
},
"detected": {
**detection["tools"],
"python_version": detection["python_version"],
"python_deps": detection["python_deps"],
"platform": platform.system(),
"machine": platform.machine(),
"detected_at": datetime.now().isoformat(),
},
}
# 添加自定义环境变量
if "python3" in detection["tools"]:
env_data["env"]["FUNASR_PYTHON"] = detection["tools"]["python3"]
return env_data
def print_results(detection):
"""打印检测结果"""
print()
print("=" * 60)
print(" FunASR Skill - 环境检测")
print("=" * 60)
print()
# PATH
print(f" PATH: {detection['full_path'][:80]}...")
print()
# 工具
print(" 工具检测:")
for tool in REQUIRED_TOOLS:
path = detection["tools"].get(tool)
status = f"✅ {path}" if path else "❌ 未找到"
print(f" {tool}: {status}")
for tool in OPTIONAL_TOOLS:
path = detection["tools"].get(tool)
status = f"✅ {path}" if path else "⚠️ 未安装(可选)"
print(f" {tool}: {status}")
print()
# Python
if detection["python_version"]:
print(f" Python 版本: {detection['python_version']}")
print()
# 依赖
print(" Python 依赖:")
for dep, ver in detection["python_deps"].items():
status = f"✅ {ver}" if ver else "❌ 未安装"
print(f" {dep}: {status}")
print()
# 问题
if detection["issues"]:
print(f" ❌ 发现 {len(detection['issues'])} 个问题:")
for issue in detection["issues"]:
print(f" - {issue}")
print()
if detection["warnings"]:
for w in detection["warnings"]:
print(f" ⚠️ {w}")
print()
def main():
parser = argparse.ArgumentParser(description="FunASR Skill 环境检测与配置生成")
parser.add_argument("--check", action="store_true", help="只检测,不写文件")
parser.add_argument("--force", action="store_true", help="强制重新检测")
args = parser.parse_args()
# 如果已有 skill-env.json 且不是强制模式,跳过
if ENV_FILE.exists() and not args.force and not args.check:
print(f"✅ skill-env.json 已存在: {ENV_FILE}")
print(" 使用 --force 强制重新检测")
return 0
# 执行检测
detection = run_detection()
print_results(detection)
# 如果有严重问题,报错退出
if detection["issues"]:
print("=" * 60)
print(" ❌ 环境检测未通过,请修复上述问题后重试")
print(" 运行 python3 scripts/setup.py 安装依赖")
print("=" * 60)
return 1
# 生成 skill-env.json
if not args.check:
env_data = generate_env_json(detection)
ENV_FILE.write_text(
json.dumps(env_data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(f" ✅ 已生成: {ENV_FILE}")
print()
print("=" * 60)
print(" ✅ 环境检测通过")
print("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
# -*- encoding: utf-8 -*-
"""
FunASR ONNX 加速服务入口。
默认行为:
1. 将默认模型切到 `paraformer-onnx`
2. 打开 ONNX INT8 量化
3. 其余参数复用 server.py
"""
import os
os.environ.setdefault("FUNASR_SERVER_DEFAULT_MODEL", "paraformer-onnx")
os.environ.setdefault("FUNASR_SERVER_DEFAULT_QUANTIZE", "1")
from server import main
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# -*- encoding: utf-8 -*-
"""
FunASR 语音转文字 - 一键安装脚本
自动安装依赖和下载模型,支持 Windows/macOS/Linux
"""
import os
import sys
import json
import platform
import subprocess
import argparse
import shutil
from pathlib import Path
# 获取脚本所在目录和 skill 根目录
SCRIPT_DIR = Path(__file__).parent.absolute()
SKILL_DIR = SCRIPT_DIR.parent
REQUIREMENTS_FILE = SKILL_DIR / "assets" / "requirements.txt"
MODELS_CONFIG = SKILL_DIR / "assets" / "models.json"
# 最低系统要求
MIN_MEMORY_GB = 4 # 最低内存要求
MIN_DISK_GB = 5 # 最低磁盘空间要求(模型约 1.2GB + 依赖)
MIN_PYTHON_VERSION = (3, 8)
def print_step(msg: str):
"""打印步骤信息"""
print(f"\n{'='*60}")
print(f" {msg}")
print(f"{'='*60}\n")
def print_success(msg: str):
print(f"✅ {msg}")
def print_error(msg: str):
print(f"❌ {msg}")
def print_warning(msg: str):
print(f"⚠️ {msg}")
def print_info(msg: str):
print(f"ℹ️ {msg}")
def get_system_info():
"""获取系统信息"""
info = {
'os': platform.system(),
'os_version': platform.version(),
'machine': platform.machine(),
'python_version': sys.version_info,
'memory_gb': None,
'disk_free_gb': None,
'gpu': None,
}
# 获取内存信息
try:
if info['os'] == 'Darwin': # macOS
import subprocess
result = subprocess.run(['sysctl', '-n', 'hw.memsize'], capture_output=True, text=True)
info['memory_gb'] = int(result.stdout.strip()) / (1024**3)
elif info['os'] == 'Windows':
import ctypes
kernel32 = ctypes.windll.kernel32
c_ulong = ctypes.c_ulong
class MEMORYSTATUS(ctypes.Structure):
_fields_ = [
('dwLength', c_ulong),
('dwMemoryLoad', c_ulong),
('dwTotalPhys', c_ulong),
('dwAvailPhys', c_ulong),
('dwTotalPageFile', c_ulong),
('dwAvailPageFile', c_ulong),
('dwTotalVirtual', c_ulong),
('dwAvailVirtual', c_ulong),
]
memstatus = MEMORYSTATUS()
memstatus.dwLength = ctypes.sizeof(MEMORYSTATUS)
kernel32.GlobalMemoryStatus(ctypes.byref(memstatus))
info['memory_gb'] = memstatus.dwTotalPhys / (1024**3)
else: # Linux
with open('/proc/meminfo', 'r') as f:
for line in f:
if line.startswith('MemTotal:'):
info['memory_gb'] = int(line.split()[1]) / (1024**2)
break
except:
pass
# 获取磁盘空间
try:
cache_dir = get_model_cache_dir().parent
cache_dir.mkdir(parents=True, exist_ok=True)
_, _, free = shutil.disk_usage(cache_dir)
info['disk_free_gb'] = free / (1024**3)
except:
pass
return info
def check_system_requirements():
"""检查系统环境是否满足要求"""
print_step("检查系统环境")
info = get_system_info()
warnings = []
errors = []
# 操作系统
os_name = info['os']
if os_name == 'Darwin':
print_success(f"操作系统: macOS ({info['os_version']})")
print_info(f"架构: {info['machine']}")
if info['machine'] == 'arm64':
print_info("Apple Silicon 检测到,将使用 MPS 加速")
elif os_name == 'Windows':
print_success(f"操作系统: Windows ({info['os_version']})")
print_info(f"架构: {info['machine']}")
elif os_name == 'Linux':
print_success(f"操作系统: Linux ({info['os_version']})")
else:
warnings.append(f"未测试的操作系统: {os_name}")
# Python 版本
py_version = info['python_version']
print(f"Python 版本: {py_version.major}.{py_version.minor}.{py_version.micro}")
if py_version < MIN_PYTHON_VERSION:
errors.append(f"需要 Python {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]} 或更高版本")
else:
print_success("Python 版本符合要求")
# 内存检查
if info['memory_gb']:
print(f"系统内存: {info['memory_gb']:.1f} GB")
if info['memory_gb'] < MIN_MEMORY_GB:
errors.append(f"内存不足,需要至少 {MIN_MEMORY_GB} GB(当前 {info['memory_gb']:.1f} GB)")
elif info['memory_gb'] < 8:
warnings.append(f"内存较低({info['memory_gb']:.1f} GB),大文件转录可能较慢")
print_warning(f"建议 8GB 以上内存以获得更好性能")
else:
print_success("内存充足")
else:
warnings.append("无法检测内存大小")
# 磁盘空间检查
if info['disk_free_gb']:
print(f"可用磁盘空间: {info['disk_free_gb']:.1f} GB")
if info['disk_free_gb'] < MIN_DISK_GB:
errors.append(f"磁盘空间不足,需要至少 {MIN_DISK_GB} GB(当前 {info['disk_free_gb']:.1f} GB)")
else:
print_success("磁盘空间充足")
else:
warnings.append("无法检测磁盘空间")
# GPU 检测(预检测,不需要 PyTorch)
gpu_info = detect_gpu_without_torch()
if gpu_info:
print_info(f"检测到 GPU: {gpu_info}")
else:
print_info("未检测到 GPU,将使用 CPU 推理(速度较慢)")
# 输出警告
if warnings:
print("\n注意事项:")
for w in warnings:
print_warning(w)
# 输出错误
if errors:
print("\n发现以下问题:")
for e in errors:
print_error(e)
return False
print_success("\n系统环境检查通过")
return True
def detect_gpu_without_torch():
"""在不依赖 PyTorch 的情况下检测 GPU"""
system = platform.system()
try:
if system == 'Darwin':
# macOS: 检查是否有 Apple Silicon
if platform.machine() == 'arm64':
return "Apple Silicon (MPS)"
return None
elif system == 'Windows':
# Windows: 尝试使用 nvidia-smi
result = subprocess.run(
['nvidia-smi', '--query-gpu=name', '--format=csv,noheader'],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().split('\n')[0]
return None
elif system == 'Linux':
# Linux: 尝试使用 nvidia-smi
result = subprocess.run(
['nvidia-smi', '--query-gpu=name', '--format=csv,noheader'],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().split('\n')[0]
return None
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
pass
return None
def check_python_version():
"""检查 Python 版本"""
version = sys.version_info
if version < MIN_PYTHON_VERSION:
print_error(f"需要 Python {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]} 或更高版本")
return False
return True
def is_externally_managed_env():
"""检测是否为外部管理的 Python 环境(如 Homebrew)"""
try:
import sysconfig
stdlib_path = Path(sysconfig.get_path('stdlib'))
if (stdlib_path / 'EXTERNALLY-MANAGED').exists():
return True
# 尝试 dry-run 检测
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "--dry-run", "pip"],
capture_output=True, text=True, timeout=10
)
return "externally-managed-environment" in result.stderr
except:
return False
def install_dependencies():
"""安装 pip 依赖"""
print_step("安装依赖包")
if not REQUIREMENTS_FILE.exists():
print_error(f"找不到 requirements.txt: {REQUIREMENTS_FILE}")
return False
print(f"从 {REQUIREMENTS_FILE} 安装依赖...")
print_info("这可能需要几分钟,请耐心等待...")
# 构建安装命令
cmd = [sys.executable, "-m", "pip", "install", "-r", str(REQUIREMENTS_FILE)]
# 检测是否为外部管理环境(如 Homebrew Python)
if is_externally_managed_env():
print_info("检测到外部管理的 Python 环境(如 Homebrew)")
print_info("使用 --break-system-packages 标志安装")
cmd.append("--break-system-packages")
try:
subprocess.check_call(cmd)
print_success("依赖安装完成")
return True
except subprocess.CalledProcessError as e:
print_error(f"依赖安装失败: {e}")
return False
def load_models_config():
"""加载模型配置"""
if not MODELS_CONFIG.exists():
print_error(f"找不到模型配置文件: {MODELS_CONFIG}")
return None
with open(MODELS_CONFIG, 'r', encoding='utf-8') as f:
return json.load(f)
def get_model_cache_dir():
"""获取 ModelScope 模型缓存目录"""
cache_dir = os.environ.get('MODELSCOPE_CACHE', os.path.expanduser('~/.cache/modelscope/hub'))
return Path(cache_dir) / "models"
def check_model_exists(model_id: str) -> bool:
"""检查模型是否已下载"""
cache_dir = get_model_cache_dir()
model_path = cache_dir / model_id.replace('/', os.sep)
return model_path.exists() and any(model_path.iterdir())
def download_models():
"""下载所有需要的模型"""
print_step("下载 ASR 模型")
config = load_models_config()
if not config:
return False
models = config.get('models', [])
if not models:
print_info("没有需要下载的模型")
return True
# 检查 modelscope 是否可用
try:
from modelscope.hub.snapshot_download import snapshot_download
except ImportError:
print_error("modelscope 未安装,请先运行依赖安装")
return False
cache_dir = get_model_cache_dir()
print(f"模型缓存目录: {cache_dir}")
success_count = 0
total_count = len([m for m in models if m.get('required', True)])
for model in models:
model_id = model['id']
model_name = model.get('name', model_id)
required = model.get('required', True)
if not required:
continue
print(f"\n[{success_count + 1}/{total_count}] 处理: {model_name}")
print(f" 模型 ID: {model_id}")
# 检查是否已存在
if check_model_exists(model_id):
print_success(f"已存在,跳过下载")
success_count += 1
continue
# 下载模型
print(f" 正在下载...")
try:
snapshot_download(model_id)
print_success(f"下载完成")
success_count += 1
except Exception as e:
print_error(f"下载失败: {e}")
if required:
return False
print(f"\n模型下载完成: {success_count}/{total_count}")
return success_count == total_count
def verify_installation():
"""验证安装结果"""
print_step("验证安装")
errors = []
# 检查依赖
try:
import fastapi
print_success(f"FastAPI {fastapi.__version__}")
except ImportError:
errors.append("FastAPI 未安装")
try:
import uvicorn
print_success(f"Uvicorn {uvicorn.__version__}")
except ImportError:
errors.append("Uvicorn 未安装")
try:
import funasr
print_success(f"FunASR 已安装")
except ImportError:
errors.append("FunASR 未安装")
try:
import funasr_onnx
print_success(f"funasr-onnx {getattr(funasr_onnx, '__version__', '已安装')}")
except ImportError:
errors.append("funasr-onnx 未安装,ONNX 加速功能不可用")
try:
import torch
print_success(f"PyTorch {torch.__version__}")
# 检查设备
if torch.cuda.is_available():
print_success(f"CUDA 可用: {torch.cuda.get_device_name(0)}")
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
print_success("Apple MPS 可用")
else:
print_info("将使用 CPU 推理")
except ImportError:
errors.append("PyTorch 未安装")
# 检查说话人分离依赖(scikit-learn)
try:
import sklearn
print_success(f"scikit-learn {sklearn.__version__} (说话人分离需要)")
except ImportError:
errors.append("scikit-learn 未安装,说话人分离功能将不可用")
except Exception as e:
errors.append(f"scikit-learn 导入失败: {e},说话人分离功能将不可用")
# 检查 numpy 版本(避免不兼容问题)
try:
import numpy
print_success(f"numpy {numpy.__version__}")
if numpy.__version__.startswith('2.'):
print_warning("检测到 numpy 2.x,可能与部分依赖不兼容,建议使用 numpy<2")
except ImportError:
errors.append("numpy 未安装")
# 检查模型
config = load_models_config()
if config:
models = config.get('models', [])
for model in models:
if model.get('required', True):
if check_model_exists(model['id']):
print_success(f"模型: {model.get('name', model['id'])}")
else:
errors.append(f"模型未下载: {model['id']}")
if errors:
print("\n发现以下问题:")
for err in errors:
print_error(err)
return False
print_success("\n安装验证通过!")
return True
def main():
parser = argparse.ArgumentParser(
description='FunASR 语音转文字 - 一键安装脚本(支持 Windows/macOS/Linux)',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python setup.py # 完整安装(环境检查 + 依赖 + 模型)
python setup.py --skip-deps # 只下载模型
python setup.py --skip-models # 只安装依赖
python setup.py --verify # 只验证安装
python setup.py --check # 只检查系统环境
"""
)
parser.add_argument('--skip-deps', action='store_true', help='跳过依赖安装')
parser.add_argument('--skip-models', action='store_true', help='跳过模型下载')
parser.add_argument('--verify', action='store_true', help='只验证安装,不安装任何内容')
parser.add_argument('--check', action='store_true', help='只检查系统环境')
args = parser.parse_args()
print("\n" + "="*60)
print(" FunASR 语音转文字 - 一键安装")
print(" 支持: Windows / macOS / Linux")
print("="*60)
# 只验证安装
if args.verify:
success = verify_installation()
if success:
print_info("正在刷新环境配置 skill-env.json ...")
subprocess.run(
[sys.executable, str(SCRIPT_DIR / "init_env.py"), "--force"],
check=False,
)
sys.exit(0 if success else 1)
# 只检查环境
if args.check:
success = check_system_requirements()
sys.exit(0 if success else 1)
# 检查系统环境
if not check_system_requirements():
print_error("\n系统环境不满足要求,请解决上述问题后重试")
sys.exit(1)
# 安装依赖
if not args.skip_deps:
if not install_dependencies():
sys.exit(1)
else:
print_info("跳过依赖安装")
# 下载模型
if not args.skip_models:
if not download_models():
sys.exit(1)
else:
print_info("跳过模型下载")
# 验证
if not verify_installation():
print_error("\n安装可能不完整,请检查上述错误")
sys.exit(1)
print_step("安装完成!")
print("现在可以启动服务:")
print(f" python {SCRIPT_DIR / 'server.py'}")
print()
# 生成环境配置
print_info("正在生成环境配置 skill-env.json ...")
try:
subprocess.run(
[sys.executable, str(SCRIPT_DIR / "init_env.py"), "--force"],
check=False,
)
except Exception:
print_warning("生成 skill-env.json 失败,不影响正常使用")
if __name__ == '__main__':
main()
#!/usr/bin/env python3
# -*- encoding: utf-8 -*-
"""
视频关键帧(PPT 幻灯片)提取模块
四层过滤流水线:
视频 → 第1层:场景检测 + 定时兜底采样
→ 第2层:pHash 去重
→ 第3层:空白回查补帧
→ 第4层:最小间隔过滤 + 兜底帧清理
仅对视频文件生效,音频文件会被跳过。
依赖:
pip install scenedetect[opencv] imagehash Pillow
"""
import os
from dataclasses import dataclass
from pathlib import Path
# 视频文件扩展名
VIDEO_EXTENSIONS = {'.mp4', '.avi', '.mov', '.mkv', '.wmv', '.webm'}
@dataclass
class SlideFrame:
"""提取的关键帧"""
timestamp_ms: int # 毫秒时间戳,用于与转录文本对齐
image_path: str # 保存的图片绝对路径
time_label: str # 可读标签如 "02m15s"
relative_path: str = "" # 相对于 Markdown 文件的路径
description: str = "" # 截图描述(用于 alt 文本)
is_fallback: bool = False # 是否为兜底帧
class SlideExtractor:
"""视频关键帧提取器
四层过滤流水线:
视频 → 场景检测+兜底采样 → pHash 去重 → 空白回查补帧 → 最终过滤
Args:
threshold: ContentDetector 灵敏度阈值(默认 20.0,值越低越灵敏)
min_scene_len: 最小场景时长(秒),短于此间隔的切换会被过滤
hash_threshold: pHash 汉明距离阈值,低于此值视为相同画面(默认16)
fallback_interval: 兜底采样间隔(秒),保证每 N 秒至少有一帧(默认60)
gap_threshold: 空白回查阈值(秒),间隔超过此值时触发均匀补帧(默认180)
"""
def __init__(
self,
threshold: float = 20.0,
min_scene_len: float = 3.0,
hash_threshold: int = 16,
fallback_interval: int = 60,
gap_threshold: int = 180,
):
self.threshold = threshold
self.min_scene_len = min_scene_len
self.hash_threshold = hash_threshold
self.fallback_interval = fallback_interval
self.gap_threshold = gap_threshold
def extract(self, video_path: str, output_dir: str) -> list:
"""主入口:提取关键帧,返回按时间排序的 SlideFrame 列表"""
ext = Path(video_path).suffix.lower()
if ext not in VIDEO_EXTENSIONS:
print(f"[slide_extractor] 跳过非视频文件: {video_path}")
return []
os.makedirs(output_dir, exist_ok=True)
import cv2
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
print(f"[slide_extractor] 无法打开视频: {video_path}")
return []
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration_ms = int(total_frames / fps * 1000)
cap.release()
# 第 1 层:场景检测 + 定时兜底采样
raw_frames = self._detect_with_fallback(video_path, output_dir, fps, total_frames)
if not raw_frames:
print("[slide_extractor] 未检测到任何帧")
return []
print(f"[slide_extractor] 第1层(场景+兜底): {len(raw_frames)} 个候选帧")
# 第 2 层:pHash 去重
deduped = self._deduplicate(raw_frames)
print(f"[slide_extractor] 第2层(pHash去重): {len(deduped)} 个帧")
# 第 3 层:空白回查补帧
backfilled = self._backfill_gaps(video_path, output_dir, deduped, fps, duration_ms)
print(f"[slide_extractor] 第3层(空白回查): {len(backfilled)} 个帧")
# 第 4 层:最小间隔过滤 + 兜底帧清理
filtered = self._final_filter(backfilled)
print(f"[slide_extractor] 第4层(最终过滤): {len(filtered)} 个关键帧")
# 清理被过滤掉的图片文件
kept_paths = {f.image_path for f in filtered}
for f in raw_frames + backfilled:
if f.image_path not in kept_paths and os.path.exists(f.image_path):
os.remove(f.image_path)
return filtered
def _detect_with_fallback(self, video_path: str, output_dir: str,
fps: float, total_frames: int) -> list:
"""第 1 层:双通道合并(场景检测 + 定时兜底采样)"""
import cv2
frames = {} # key: timestamp_ms, value: SlideFrame
# --- 通道 A:PySceneDetect ---
scene_frames = self._detect_scenes(video_path, output_dir)
for f in scene_frames:
if f.timestamp_ms not in frames:
frames[f.timestamp_ms] = f
# --- 通道 B:定时兜底采样 ---
duration_ms = int(total_frames / fps * 1000)
fallback_ms = self.fallback_interval * 1000
cap = cv2.VideoCapture(video_path)
idx = 1
ts_ms = 0
while ts_ms <= duration_ms:
if ts_ms not in frames:
frame_pos = int(ts_ms / 1000.0 * fps)
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_pos)
ret, frame = cap.read()
if ret:
ts_s = ts_ms / 1000.0
time_label = self._format_time_label(ts_s)
img_filename = f"slide_fb_{idx:03d}_{time_label}.jpg"
img_path = os.path.join(output_dir, img_filename)
cv2.imwrite(img_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
frames[ts_ms] = SlideFrame(
timestamp_ms=ts_ms,
image_path=img_path,
time_label=time_label,
is_fallback=True,
)
idx += 1
ts_ms += fallback_ms
cap.release()
# 按时间排序
return sorted(frames.values(), key=lambda f: f.timestamp_ms)
def _detect_scenes(self, video_path: str, output_dir: str) -> list:
"""场景检测子函数"""
try:
from scenedetect import SceneManager, open_video
from scenedetect.detectors import ContentDetector
except ImportError:
print("[slide_extractor] 缺少依赖,请安装: pip install scenedetect[opencv]")
return []
try:
video = open_video(video_path)
except Exception as e:
print(f"[slide_extractor] 无法打开视频: {e}")
return []
scene_manager = SceneManager()
scene_manager.add_detector(
ContentDetector(
threshold=self.threshold,
min_scene_len=int(self.min_scene_len * video.frame_rate) if video.frame_rate else 90,
)
)
scene_manager.detect_scenes(video)
scene_list = scene_manager.get_scene_list()
if not scene_list:
return []
import cv2
frames = []
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
for i, (start, _end) in enumerate(scene_list):
frame_num = start.get_frames()
timestamp_sec = frame_num / fps
timestamp_ms = int(timestamp_sec * 1000)
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num)
ret, frame = cap.read()
if not ret:
continue
time_label = self._format_time_label(timestamp_sec)
img_filename = f"slide_{i + 1:03d}_{time_label}.jpg"
img_path = os.path.join(output_dir, img_filename)
cv2.imwrite(img_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
frames.append(SlideFrame(
timestamp_ms=timestamp_ms,
image_path=img_path,
time_label=time_label,
))
cap.release()
return frames
def _deduplicate(self, frames: list) -> list:
"""第 2 层:使用感知哈希去除视觉上相似的帧"""
try:
from PIL import Image
import imagehash
except ImportError:
print("[slide_extractor] 缺少依赖,请安装: pip install imagehash Pillow")
return frames
if not frames:
return frames
result = []
prev_hash = None
for frame in frames:
try:
img = Image.open(frame.image_path)
current_hash = imagehash.phash(img)
if prev_hash is not None:
distance = current_hash - prev_hash
if distance < self.hash_threshold:
# 视觉上相似,跳过
continue
prev_hash = current_hash
result.append(frame)
except Exception:
result.append(frame)
return result
def _backfill_gaps(self, video_path: str, output_dir: str,
frames: list, fps: float, duration_ms: int) -> list:
"""第 3 层:扫描空白区域,按间隔均匀补帧"""
if len(frames) < 2:
return frames
result = list(frames)
gaps_to_fill = []
# 找出所有大间隔
for i in range(len(result) - 1):
gap_ms = result[i + 1].timestamp_ms - result[i].timestamp_ms
if gap_ms > self.gap_threshold * 1000:
gaps_to_fill.append((result[i].timestamp_ms, result[i + 1].timestamp_ms))
# 也检查开头和结尾的空白
if result[0].timestamp_ms > self.gap_threshold * 1000:
gaps_to_fill.append((0, result[0].timestamp_ms))
if duration_ms - result[-1].timestamp_ms > self.gap_threshold * 1000:
gaps_to_fill.append((result[-1].timestamp_ms, duration_ms))
if not gaps_to_fill:
return result
print(f"[slide_extractor] 发现 {len(gaps_to_fill)} 个空白区域,开始回查...")
import cv2
cap = cv2.VideoCapture(video_path)
backfill_frames = []
idx = 1
for start_ms, end_ms in gaps_to_fill:
# 按 fallback_interval 均匀采样,而非只补中间1帧
gap_ms = end_ms - start_ms
interval_ms = self.fallback_interval * 1000
ts_ms = start_ms + interval_ms # 跳过起始位置(已有帧)
while ts_ms < end_ms:
frame_pos = int(ts_ms / 1000.0 * fps)
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_pos)
ret, frame = cap.read()
if not ret:
ts_ms += interval_ms
continue
ts_s = ts_ms / 1000.0
time_label = self._format_time_label(ts_s)
img_filename = f"slide_bf_{idx:03d}_{time_label}.jpg"
img_path = os.path.join(output_dir, img_filename)
cv2.imwrite(img_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
backfill_frames.append(SlideFrame(
timestamp_ms=ts_ms,
image_path=img_path,
time_label=time_label,
is_fallback=True,
))
idx += 1
ts_ms += interval_ms
cap.release()
# 合并并重新排序
all_frames = result + backfill_frames
all_frames.sort(key=lambda f: f.timestamp_ms)
# 对补帧做 pHash 去重(只检查补帧与邻近帧)
try:
from PIL import Image
import imagehash
except ImportError:
return all_frames
final = []
for frame in all_frames:
if not frame.is_fallback:
final.append(frame)
continue
# 补帧:检查与前一帧的 pHash 距离
if final:
try:
prev_img = Image.open(final[-1].image_path)
cur_img = Image.open(frame.image_path)
prev_h = imagehash.phash(prev_img)
cur_h = imagehash.phash(cur_img)
if cur_h - prev_h < self.hash_threshold:
# 视觉上确实没变化,删除补帧图片
if os.path.exists(frame.image_path):
os.remove(frame.image_path)
continue
except Exception:
pass
final.append(frame)
return final
def _final_filter(self, frames: list) -> list:
"""第 4 层:最小间隔过滤 + 兜底帧智能清理"""
if not frames:
return frames
min_ms = int(self.min_scene_len * 1000)
result = [frames[0]]
for frame in frames[1:]:
gap = frame.timestamp_ms - result[-1].timestamp_ms
if gap >= min_ms:
result.append(frame)
elif frame.is_fallback and gap < min_ms:
# 兜底帧距离前一帧太近,说明场景检测已覆盖,删除
if os.path.exists(frame.image_path):
os.remove(frame.image_path)
return result
@staticmethod
def _format_time_label(seconds: float) -> str:
"""将秒数格式化为可读标签,如 '02m15s'"""
m = int(seconds) // 60
s = int(seconds) % 60
if m > 0:
return f"{m:02d}m{s:02d}s"
return f"{s:02d}s"
@staticmethod
def is_video_file(file_path: str) -> bool:
"""判断是否为视频文件"""
return Path(file_path).suffix.lower() in VIDEO_EXTENSIONS
"""
FunASR 转录总结工具 - Claude Code / Agent 环境专用
支持:
- 从转录文件中提取文本并生成总结提示词
- 将总结注入到 Markdown 文件
- 验证文件中是否存在总结
- CLI 接口:python3 summary.py inject <md_path> <summary_file>
python3 summary.py verify <md_path>
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Any, Dict
SUMMARY_START = "<!-- AI-SUMMARY:START -->"
SUMMARY_END = "<!-- AI-SUMMARY:END -->"
DEFAULT_SYSTEM_PROMPT = (
"你是一位擅长处理口语化中文对话的专业纪要分析师。请从非结构化逐字稿中提炼事件脉络、各方观点、关键数据和行动建议,保持客观,不捏造信息。"
)
# ============================================================================
# 辅助函数
# ============================================================================
def _format_list(values: Any) -> list[str]:
"""格式化列表数据"""
if isinstance(values, list):
return [str(v).strip() for v in values if str(v).strip()]
if isinstance(values, str):
return [item.strip() for item in re.split(r"[、;;,,]\s*", values) if item.strip()]
return []
def _extract_speaker_orders(markdown_text: str) -> list[str]:
"""从 Markdown 文本中提取发言人编号列表"""
orders: list[str] = []
# 匹配 speaker_0, speaker_1 等格式
for match in re.finditer(r"^speaker_(\d+)", markdown_text, re.MULTILINE):
order = f"发言人{match.group(1)}"
if order not in orders:
orders.append(order)
return orders
def _format_full_summary(text: str) -> str:
"""格式化全文总结,添加段落分隔"""
cleaned = text.strip()
if "\n\n" in cleaned:
return cleaned
sentences = [s for s in re.split(r"(?<=[。!?])\s*", cleaned) if s]
if not sentences:
return cleaned
paragraphs: list[str] = []
current: list[str] = []
for sentence in sentences:
current.append(sentence)
if len(current) >= 2:
paragraphs.append("".join(current))
current = []
if current:
paragraphs.append("".join(current))
return "\n\n".join(paragraphs)
def _inject_summary(original: str, summary_block: str) -> str:
"""将总结注入到原始文本中"""
summary_block = summary_block.strip() + "\n\n"
# 如果已有总结标记,替换它
if SUMMARY_START in original and SUMMARY_END in original:
pattern = re.compile(
re.escape(SUMMARY_START) + r".*?" + re.escape(SUMMARY_END),
flags=re.DOTALL,
)
replaced = pattern.sub(summary_block.rstrip(), original, count=1)
return replaced if replaced != original else summary_block + original
# 在"## 转录内容"前插入
marker = "\n## 转录内容"
idx = original.find(marker)
if idx != -1:
before = original[:idx].rstrip()
after = original[idx:]
return before + "\n\n" + summary_block + after.lstrip("\n")
# 在标题后插入
header_match = re.search(r"^# .*$", original, re.MULTILINE)
if header_match:
end = header_match.end()
before = original[:end].rstrip()
after = original[end:]
after_body = after.lstrip("\n")
if "## 转录内容" not in after_body:
after_body = "## 转录内容\n\n" + after_body
return before + "\n\n" + summary_block + after_body
# 默认添加到开头
return summary_block + original.lstrip()
# ============================================================================
# 总结构建
# ============================================================================
def _build_summary_markdown(
data: Dict[str, Any],
expected_orders: list[str] | None = None,
) -> str:
"""
构建 Markdown 格式的总结
Args:
data: 总结数据 (full_summary, speaker_summary, highlights, keywords)
expected_orders: 期望的发言人顺序列表
"""
full_summary = str(data.get("full_summary", "")).strip()
highlights = _format_list(data.get("highlights") or data.get("key_points"))
keywords = _format_list(data.get("keywords"))
# 处理发言人总结
speaker_entries = data.get("speaker_summary") or data.get("speaker_summaries") or []
normalized: Dict[str, Dict[str, str]] = {}
if isinstance(speaker_entries, list):
for entry in speaker_entries:
if isinstance(entry, dict):
order = str(entry.get("speaker_order") or entry.get("speaker") or "").strip()
name = str(entry.get("speaker_name") or entry.get("name") or entry.get("speaker") or "").strip()
summary = str(entry.get("summary") or entry.get("content") or "").strip()
if order and summary:
normalized[order] = {"name": name or "未知", "summary": summary}
elif isinstance(entry, str) and entry.strip():
normalized[entry.strip()] = {"name": entry.strip(), "summary": entry.strip()}
# 格式化发言人列表
formatted_speakers: list[str] = []
orders = expected_orders or list(normalized.keys())
if not orders and normalized:
orders = list(normalized.keys())
for idx, order in enumerate(orders, start=1):
info = normalized.get(order) or {}
summary = info.get("summary") or "(摘要缺失,请补充。)"
label = order if order.startswith("发言人") else f"发言人{idx}"
formatted_speakers.append(f"- {label}:{summary}")
# 添加未包含在预期顺序中的发言人
for order, info in normalized.items():
if order in orders:
continue
summary = info.get("summary") or "(摘要缺失,请补充。)"
formatted_speakers.append(f"- {order}:{summary}")
# 构建完整的 Markdown
lines = [SUMMARY_START, "## AI 摘要"]
if full_summary:
lines.append("### 全文总结")
lines.append(_format_full_summary(full_summary))
if formatted_speakers:
lines.append("### 发言人总结")
lines.extend(formatted_speakers)
if highlights:
lines.append("### 重点内容")
for item in highlights:
lines.append(f"- {item}")
if keywords:
lines.append("### 关键词")
lines.append(", ".join(keywords))
lines.append(SUMMARY_END)
return "\n".join(lines).strip() + "\n\n"
# ============================================================================
# Claude Code 环境专用功能
# ============================================================================
def get_transcription_text(md_path: Path) -> str:
"""从转录文件中提取纯文本内容"""
text = md_path.read_text(encoding="utf-8")
# 提取"转录内容"部分,去除时间戳和说话人标记
marker = "\n## 转录内容"
idx = text.find(marker)
if idx != -1:
content_section = text[idx + len(marker):].strip()
lines = []
for line in content_section.split('\n'):
if line.strip():
# 移除时间戳和说话人标记(格式:发言人1 00:00:00)
line = re.sub(r'^发言人?\d+\s+\d{2}:\d{2}:\d{2}\s*', '', line)
# 移除旧格式的时间戳标记
line = re.sub(r'\*\*\[[0-9]{2}:[0-9]{2}:[0-9]{2} - [0-9]{2}:[0-9]{2}\].*?\*\*', '', line)
line = re.sub(r'\*\*\[.*?\]\*\*', '', line)
line = line.strip()
if line:
lines.append(line)
return '\n'.join(lines)
# 如果找不到"转录内容",返回整个文本
return text
def create_summary_prompt(text: str) -> str:
"""创建用于 Claude Code 的总结提示词"""
prompt = f"""你是一位擅长处理口语化中文对话的专业纪要分析师。请从非结构化逐字稿中提炼事件脉络、各方观点、关键数据和行动建议,保持客观,不捏造信息。
请阅读以下逐字稿,输出 JSON 结果,其结构必须为:
{{
"full_summary": "至少400字,分成2-3段,交代背景、问题、关键事实、数据、风险与行动建议",
"speaker_summary": [
{{
"speaker_order": "发言人1",
"speaker_name": "如能识别请写姓名,否则写未知",
"summary": "至少180字,涵盖该发言人的观点、依据、数据、态度与潜在影响"
}},
...
],
"highlights": ["6-10条重点,每条60-100字,明确事实/数据/结论/行动"],
"keywords": ["5-8个关键词"]
}}
请确保逐字稿中出现的每一位发言人(发言人1、发言人2……)都提供总结,不得遗漏或虚构。
以下是完整文本:
{text}
请输出 JSON 格式的总结。"""
return prompt
def inject_summary_to_file(md_path: Path, summary_text: str) -> None:
"""将总结注入到 Markdown 文件中"""
text = md_path.read_text(encoding="utf-8")
# 确保总结有标记
if not summary_text.strip().startswith('<!-- AI-SUMMARY:START -->'):
summary_text = f"<!-- AI-SUMMARY:START -->\n{summary_text}\n<!-- AI-SUMMARY:END -->"
# 检查是否已有总结
if SUMMARY_START in text and SUMMARY_END in text:
pattern = re.compile(
re.escape(SUMMARY_START) + r".*?" + re.escape(SUMMARY_END),
flags=re.DOTALL,
)
text = pattern.sub(summary_text.strip() + "\n\n", text, count=1)
else:
# 插入新总结
text = _inject_summary(text, summary_text)
md_path.write_text(text, encoding="utf-8")
def summarize_file_for_claude(md_path: Path) -> tuple[bool, str, str]:
"""
为 Claude Code 环境创建总结
Args:
md_path: Markdown 文件路径
Returns:
tuple: (是否成功, 提示信息/提示词, 提取的文本)
"""
try:
# 提取转录文本
text = get_transcription_text(md_path)
if not text:
return False, "转录文件为空", ""
# 创建提示词
prompt = create_summary_prompt(text)
return True, prompt, text
except Exception as e:
return False, f"提取转录文本失败: {str(e)}", ""
def generate_summary_via_api(md_path: Path) -> tuple[bool, str]:
"""
在 Claude Code 环境中生成总结
当检测到在 Claude Code 环境中时,输出结构化的总结请求,
Claude Code 会自动识别并处理这个请求,利用其原生的 AI 能力生成总结。
Args:
md_path: Markdown 文件路径
Returns:
tuple: (是否成功, 消息)
"""
try:
# 提取转录文本
text = get_transcription_text(md_path)
if not text:
return False, "转录文件为空"
# 创建提示词
prompt = create_summary_prompt(text)
# 输出结构化请求,Claude Code 会自动识别并处理
print("\n" + "=" * 70)
print("🤖 AI_SUMMARY_REQUEST")
print("=" * 70)
print(f"FILE: {md_path}")
print(f"LENGTH: {len(text)}")
print("=" * 70)
print("PROMPT_START")
print(prompt)
print("PROMPT_END")
print("=" * 70)
# 将提示词写入临时文件,供 Claude Code 读取并处理
prompt_file = md_path.parent / f".summary_prompt_{md_path.stem}.txt"
prompt_file.write_text(prompt, encoding="utf-8")
return True, f"总结请求已生成: {prompt_file}"
except Exception as e:
return False, f"生成总结请求失败: {str(e)}"
# ============================================================================
# 验证功能
# ============================================================================
def verify_summary_in_file(md_path: Path) -> Dict[str, Any]:
"""验证 Markdown 文件中是否已注入 AI 摘要
Args:
md_path: Markdown 文件路径
Returns:
dict: {
has_summary: bool, - 是否存在摘要
summary_length: int, - 摘要字符数
has_all_sections: bool, - 是否包含所有必需章节
missing_sections: list[str], - 缺失的章节名称
}
"""
if not md_path.exists():
return {
"has_summary": False,
"summary_length": 0,
"has_all_sections": False,
"missing_sections": ["文件不存在"],
}
text = md_path.read_text(encoding="utf-8")
# 检查标记
has_start = SUMMARY_START in text
has_end = SUMMARY_END in text
if not (has_start and has_end):
return {
"has_summary": False,
"summary_length": 0,
"has_all_sections": False,
"missing_sections": ["AI-SUMMARY 标记"],
}
# 提取摘要块
pattern = re.compile(
re.escape(SUMMARY_START) + r"(.*?)" + re.escape(SUMMARY_END),
flags=re.DOTALL,
)
match = pattern.search(text)
if not match:
return {
"has_summary": False,
"summary_length": 0,
"has_all_sections": False,
"missing_sections": ["摘要内容"],
}
summary_block = match.group(1)
# 检查必需章节
required_sections = ["全文总结", "发言人总结", "重点内容", "关键词"]
missing = [s for s in required_sections if s not in summary_block]
return {
"has_summary": True,
"summary_length": len(summary_block.strip()),
"has_all_sections": len(missing) == 0,
"missing_sections": missing,
}
def inject_from_file(md_path: Path, summary_file: Path) -> tuple[bool, str]:
"""从文件读取总结内容并注入到 Markdown 文件
支持 JSON 和纯文本格式:
- JSON: 解析为结构化数据后格式化为 Markdown
- 纯文本: 直接作为 Markdown 注入
Args:
md_path: 目标 Markdown 文件路径
summary_file: 总结内容文件路径
Returns:
tuple: (是否成功, 消息)
"""
if not md_path.exists():
return False, f"目标文件不存在: {md_path}"
if not summary_file.exists():
return False, f"总结文件不存在: {summary_file}"
raw = summary_file.read_text(encoding="utf-8").strip()
if not raw:
return False, "总结文件为空"
# 尝试解析为 JSON
content_to_inject = None
try:
# 去除可能的 markdown code fence
cleaned = re.sub(r"^```(?:json)?\s*\n?", "", raw)
cleaned = re.sub(r"\n?```\s*$", "", cleaned).strip()
# 尝试找到 JSON 对象
json_match = re.search(r"\{[\s\S]*\}", cleaned)
if json_match:
data = json.loads(json_match.group())
# 提取期望的发言人顺序
md_text = md_path.read_text(encoding="utf-8")
expected_orders = _extract_speaker_orders(md_text)
content_to_inject = _build_summary_markdown(data, expected_orders)
except (json.JSONDecodeError, KeyError, TypeError):
pass # 非 JSON 格式,当作纯文本处理
if content_to_inject is None:
# 纯文本格式,直接注入
content_to_inject = raw
inject_summary_to_file(md_path, content_to_inject)
# 验证注入结果
result = verify_summary_in_file(md_path)
if result["has_summary"]:
return True, f"总结已注入 ({result['summary_length']} 字符)"
else:
return False, "注入后验证失败,总结未写入文件"
# ============================================================================
# CLI 接口
# ============================================================================
def main():
"""CLI 入口点"""
if len(sys.argv) < 2:
print("用法:")
print(" python3 summary.py inject <md_path> <summary_file> - 从文件注入总结")
print(" python3 summary.py verify <md_path> - 验证总结是否存在")
print(" python3 summary.py prompt <md_path> - 生成总结提示词")
sys.exit(1)
command = sys.argv[1]
if command == "inject":
if len(sys.argv) < 4:
print("用法: python3 summary.py inject <md_path> <summary_file>")
sys.exit(1)
md_path = Path(sys.argv[2])
summary_file = Path(sys.argv[3])
success, msg = inject_from_file(md_path, summary_file)
if success:
print(f"✅ {msg}")
else:
print(f"❌ {msg}")
sys.exit(1)
elif command == "verify":
if len(sys.argv) < 3:
print("用法: python3 summary.py verify <md_path>")
sys.exit(1)
md_path = Path(sys.argv[2])
result = verify_summary_in_file(md_path)
if result["has_summary"]:
sections_status = "完整" if result["has_all_sections"] else f"缺少: {', '.join(result['missing_sections'])}"
print(f"✅ 摘要已存在 ({result['summary_length']} 字符, 章节{sections_status})")
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(f"❌ 摘要不存在 (缺少: {', '.join(result['missing_sections'])})")
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
elif command == "prompt":
if len(sys.argv) < 3:
print("用法: python3 summary.py prompt <md_path>")
sys.exit(1)
md_path = Path(sys.argv[2])
success, prompt, text = summarize_file_for_claude(md_path)
if success:
print(prompt)
else:
print(f"❌ {prompt}")
sys.exit(1)
else:
print(f"未知命令: {command}")
print("可用命令: inject, verify, prompt")
sys.exit(1)
if __name__ == "__main__":
main()