yuanflow-cli 0.1.11 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/yuanflow-skill/README.md +5 -0
- package/skills/yuanflow-skill/SKILL.md +11 -0
- package/skills/yuanflow-skill//346/234/254/345/234/260/351/237/263/350/247/206/351/242/221/350/275/254/346/226/207/345/255/227/SKILL.md +148 -0
- package/skills/yuanflow-skill//346/234/254/345/234/260/351/237/263/350/247/206/351/242/221/350/275/254/346/226/207/345/255/227/scripts/common/__init__.py +1 -0
- package/skills/yuanflow-skill//346/234/254/345/234/260/351/237/263/350/247/206/351/242/221/350/275/254/346/226/207/345/255/227/scripts/common/media.py +46 -0
- package/skills/yuanflow-skill//346/234/254/345/234/260/351/237/263/350/247/206/351/242/221/350/275/254/346/226/207/345/255/227/scripts/common/sensevoice.py +82 -0
- package/skills/yuanflow-skill//346/234/254/345/234/260/351/237/263/350/247/206/351/242/221/350/275/254/346/226/207/345/255/227/scripts/common/utils.py +62 -0
- package/skills/yuanflow-skill//346/234/254/345/234/260/351/237/263/350/247/206/351/242/221/350/275/254/346/226/207/345/255/227/scripts/requirements-transcribe.txt +2 -0
- package/skills/yuanflow-skill//346/234/254/345/234/260/351/237/263/350/247/206/351/242/221/350/275/254/346/226/207/345/255/227/scripts/transcribe_media.py +126 -0
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ YuanFlow Skill 是 `yuanflow-cli` 的 Agent Skill 仓库,用于把社媒平台
|
|
|
10
10
|
- `OSS文件中转工具/`:OSS 临时上传、签名链接、对象复制 Skill,走 `yuanflow-cli oss`。
|
|
11
11
|
- `生图技能/`:图片生成与编辑 Skill,优先走 YuanFlow 内置 `yuanflow_image_request`。
|
|
12
12
|
- `HTML报告生成/`:单页 HTML 报告生成 Skill,内置 9 种米色留白报告模板。
|
|
13
|
+
- `本地音视频转文字/`:本地 SenseVoice 音视频转文字 Skill,首次明确使用时按需下载模型。
|
|
13
14
|
|
|
14
15
|
## 适用场景
|
|
15
16
|
|
|
@@ -20,6 +21,7 @@ YuanFlow Skill 是 `yuanflow-cli` 的 Agent Skill 仓库,用于把社媒平台
|
|
|
20
21
|
- 上传文件到临时 OSS、生成签名链接或复制 OSS 对象。
|
|
21
22
|
- 生成图片、编辑图片,并缓存返回 URL 或 base64 图片。生成图片必填 `prompt`,可选 `size / quality / style / n / response_format`;编辑图片必须通过 multipart 上传本地图片。
|
|
22
23
|
- 把自媒体分析、数据复盘、文案方案、账号监控、知识梳理和执行计划生成可直接打开的单页 HTML 报告。
|
|
24
|
+
- 在用户明确要求本地转写时,把本地音频或视频转成文字;视频会先抽取音频,模型和缓存都保存在 Skill 自己目录下。
|
|
23
25
|
|
|
24
26
|
## 双环境使用方式
|
|
25
27
|
|
|
@@ -85,6 +87,9 @@ yuanflow-skill list-skills
|
|
|
85
87
|
│ ├─ SKILL.md
|
|
86
88
|
│ ├─ templates/
|
|
87
89
|
│ └─ references/
|
|
90
|
+
├─ 本地音视频转文字
|
|
91
|
+
│ ├─ SKILL.md
|
|
92
|
+
│ └─ scripts/
|
|
88
93
|
└─ OSS文件中转工具
|
|
89
94
|
└─ SKILL.md
|
|
90
95
|
```
|
|
@@ -22,6 +22,7 @@ description: Use when the user asks about social-media API workflows, platform d
|
|
|
22
22
|
- `OSS文件中转工具/`
|
|
23
23
|
- `生图技能/`
|
|
24
24
|
- `HTML报告生成/`
|
|
25
|
+
- `本地音视频转文字/`
|
|
25
26
|
|
|
26
27
|
## 环境判断
|
|
27
28
|
|
|
@@ -165,6 +166,16 @@ description: Use when the user asks about social-media API workflows, platform d
|
|
|
165
166
|
|
|
166
167
|
- `HTML报告生成`
|
|
167
168
|
|
|
169
|
+
### 11. 走 `本地音视频转文字`
|
|
170
|
+
|
|
171
|
+
只有当用户明确要求使用本地音视频转文字、本地转写、本地 ASR、离线转写或本地模型把音频/视频转成文字时,才进入这个子 Skill。
|
|
172
|
+
|
|
173
|
+
不要因为用户普通提到音频、视频、口播、文案或总结就自动使用。
|
|
174
|
+
|
|
175
|
+
子 Skill 名称:
|
|
176
|
+
|
|
177
|
+
- `本地音视频转文字`
|
|
178
|
+
|
|
168
179
|
## 多需求时怎么处理
|
|
169
180
|
|
|
170
181
|
如果用户一次提了多段流程,不要强行塞进一个子 Skill,按阶段拆开:
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 本地音视频转文字
|
|
3
|
+
description: 仅当用户明确要求使用本地音视频转文字、本地转写、本地 ASR、离线/本地模型把音频或视频转成文字时使用;不要因为用户普通提到音频、视频、文案或总结就自动使用。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 本地音视频转文字
|
|
7
|
+
|
|
8
|
+
本 Skill 用本地 SenseVoice 模型把音频或视频转成文字。它只在用户明确提出“使用本地音视频转文字”“本地转写”“用本地模型转文字”“离线转写”等需求时使用。
|
|
9
|
+
|
|
10
|
+
## 触发边界
|
|
11
|
+
|
|
12
|
+
只有满足下面条件之一时才使用:
|
|
13
|
+
|
|
14
|
+
- 用户明确要求使用“本地音视频转文字”这个技能。
|
|
15
|
+
- 用户明确要求本地转写、本地 ASR、离线转写、本地模型转文字。
|
|
16
|
+
- 用户提供音频或视频文件,并明确说要用本地能力把它转成文字。
|
|
17
|
+
|
|
18
|
+
不要在下面情况自动使用:
|
|
19
|
+
|
|
20
|
+
- 用户只是让你总结一段已有文字。
|
|
21
|
+
- 用户只是提到音频、视频、文案、脚本,但没有要求本地转写。
|
|
22
|
+
- 用户要求在线识别、云端转写或其它指定服务。
|
|
23
|
+
|
|
24
|
+
## 固定目录
|
|
25
|
+
|
|
26
|
+
本 Skill 的脚本目录为当前 Skill 目录下的 `scripts/`。
|
|
27
|
+
|
|
28
|
+
默认目录:
|
|
29
|
+
|
|
30
|
+
- 模型保存目录:`scripts/models`
|
|
31
|
+
- 任务缓存目录:`scripts/cache`
|
|
32
|
+
- 抽取音频目录:`scripts/cache/audio`
|
|
33
|
+
- 转写文本目录:`scripts/cache/transcripts`
|
|
34
|
+
|
|
35
|
+
在 YuanFlow 程序内置环境中,`skill_read` 返回的 `config.managed_skill_dir` 是当前 Skill 的真实目录。执行脚本时优先以这个目录为基准:
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
cd "<config.managed_skill_dir>\scripts"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
不要把模型下载到用户桌面、项目根目录或系统临时目录。不要把模型文件打包进 Skill 或 npm 包。
|
|
42
|
+
|
|
43
|
+
## 首次使用模型下载规则
|
|
44
|
+
|
|
45
|
+
开始转写前先检查模型目录是否已经存在:
|
|
46
|
+
|
|
47
|
+
- `scripts/models/SenseVoiceSmall`
|
|
48
|
+
- `scripts/models/fsmn-vad`
|
|
49
|
+
|
|
50
|
+
如果这两个目录都存在且不为空,直接执行后续任务。
|
|
51
|
+
|
|
52
|
+
如果首次使用没有模型,运行脚本时允许自动下载:
|
|
53
|
+
|
|
54
|
+
- SenseVoice:`iic/SenseVoiceSmall`
|
|
55
|
+
- VAD:`iic/speech_fsmn_vad_zh-cn-16k-common-pytorch`
|
|
56
|
+
|
|
57
|
+
下载由 `modelscope.snapshot_download()` 完成,保存到 `scripts/models`。下载完成后继续转写。
|
|
58
|
+
|
|
59
|
+
## 执行流程
|
|
60
|
+
|
|
61
|
+
1. 确认用户明确要求本地转写。
|
|
62
|
+
2. 读取本 Skill 完整说明。
|
|
63
|
+
3. 定位 `config.managed_skill_dir` 或当前 Skill 目录。
|
|
64
|
+
4. 检查 Python 虚拟环境和依赖;不要全局安装依赖。
|
|
65
|
+
5. 如果用户提供的是视频,先转为音频,再把音频转成文字。
|
|
66
|
+
6. 如果用户提供的是音频,直接转成文字。
|
|
67
|
+
7. 把输出的 `.txt` 文件路径和核心转写结果报告给用户。
|
|
68
|
+
|
|
69
|
+
## 环境准备
|
|
70
|
+
|
|
71
|
+
在 `scripts/` 目录下创建虚拟环境并安装依赖:
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
python -m venv .venv
|
|
75
|
+
.\.venv\Scripts\python.exe -m pip install -r requirements-transcribe.txt
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
视频转音频需要本机可用 `ffmpeg`。如果用户给的是视频且系统找不到 `ffmpeg`,先明确报告缺少 ffmpeg,不要伪造转写结果。
|
|
79
|
+
|
|
80
|
+
## 推荐调用方式
|
|
81
|
+
|
|
82
|
+
统一入口脚本:
|
|
83
|
+
|
|
84
|
+
```powershell
|
|
85
|
+
cd "<Skill目录>\scripts"
|
|
86
|
+
.\.venv\Scripts\python.exe .\transcribe_media.py "C:\path\to\input.mp3"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
视频示例:
|
|
90
|
+
|
|
91
|
+
```powershell
|
|
92
|
+
cd "<Skill目录>\scripts"
|
|
93
|
+
.\.venv\Scripts\python.exe .\transcribe_media.py "C:\path\to\input.mp4"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
常用参数:
|
|
97
|
+
|
|
98
|
+
| 参数 | 说明 |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| `input_path` | 音频文件、视频文件或目录。 |
|
|
101
|
+
| `--cache-root` | 缓存目录,默认 `scripts/cache`。 |
|
|
102
|
+
| `--models-root` | 模型目录,默认 `scripts/models`。 |
|
|
103
|
+
| `--recursive` | 输入为目录时递归扫描。 |
|
|
104
|
+
| `--device` | `auto`、`cpu`、`cuda:0` 等,默认 `auto`。 |
|
|
105
|
+
| `--language` | `zh`、`en`、`yue`、`ja`、`ko`、`auto`,默认 `auto`。 |
|
|
106
|
+
| `--batch-size-s` | 动态 batch 秒数,默认 `60`。设备性能差时调小。 |
|
|
107
|
+
| `--overwrite` | 允许覆盖已存在的音频缓存和文本结果。 |
|
|
108
|
+
|
|
109
|
+
## 低性能设备处理
|
|
110
|
+
|
|
111
|
+
如果用户设备性能较差,或执行期间出现 CPU、内存不足、进程被系统杀掉、模型加载失败等情况,不要一次性硬跑完整任务。
|
|
112
|
+
|
|
113
|
+
处理方式:
|
|
114
|
+
|
|
115
|
+
- 优先把 `--batch-size-s` 调小,例如 `20` 或 `10`。
|
|
116
|
+
- 明确告诉用户当前设备资源不足,正在改用更小批次。
|
|
117
|
+
- 如果仍然失败,建议先用 ffmpeg 把长音频切分成多个片段后逐段转写。
|
|
118
|
+
- 每段完成后保留对应 `.txt` 文件,并在最终结果中说明哪些片段已完成、哪些片段失败、失败原因是什么。
|
|
119
|
+
|
|
120
|
+
不要只回复“失败”或“网络错误”。要报告:
|
|
121
|
+
|
|
122
|
+
- 模型目录是否已存在。
|
|
123
|
+
- 是否发生了首次模型下载。
|
|
124
|
+
- 输入文件类型是音频还是视频。
|
|
125
|
+
- 视频是否已抽取音频。
|
|
126
|
+
- 输出文本文件路径。
|
|
127
|
+
- 如果分段执行,列出每段结果文件。
|
|
128
|
+
|
|
129
|
+
## 清理规则
|
|
130
|
+
|
|
131
|
+
只有用户明确要求删除缓存或模型文件时,才可以删除:
|
|
132
|
+
|
|
133
|
+
- 缓存目录:`scripts/cache`
|
|
134
|
+
- 模型目录:`scripts/models`
|
|
135
|
+
|
|
136
|
+
删除前必须确认目标路径位于当前 Skill 的 `scripts/` 目录下,不能删除其它项目目录、用户桌面目录或系统目录。
|
|
137
|
+
|
|
138
|
+
## 输出要求
|
|
139
|
+
|
|
140
|
+
成功后回复必须包含:
|
|
141
|
+
|
|
142
|
+
- 转写是否成功。
|
|
143
|
+
- 是否首次下载模型,或复用了已有模型。
|
|
144
|
+
- 如果输入是视频,说明已先抽取音频。
|
|
145
|
+
- `.txt` 结果文件的绝对路径。
|
|
146
|
+
- 转写文本的核心内容,长文本可以先给摘要并提示文件里有完整文本。
|
|
147
|
+
|
|
148
|
+
不要编造没有生成的文件路径。不要暴露任何 token 或认证信息。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .utils import ensure_parent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def extract_audio(
|
|
11
|
+
video_path: Path,
|
|
12
|
+
output_path: Path,
|
|
13
|
+
*,
|
|
14
|
+
ffmpeg_bin: str = "ffmpeg",
|
|
15
|
+
sample_rate: int = 16000,
|
|
16
|
+
channels: int = 1,
|
|
17
|
+
overwrite: bool = False,
|
|
18
|
+
) -> Path:
|
|
19
|
+
if not shutil.which(ffmpeg_bin):
|
|
20
|
+
raise FileNotFoundError(f"未找到 ffmpeg:{ffmpeg_bin}")
|
|
21
|
+
if output_path.exists() and not overwrite:
|
|
22
|
+
return output_path
|
|
23
|
+
|
|
24
|
+
ensure_parent(output_path)
|
|
25
|
+
command = [
|
|
26
|
+
ffmpeg_bin,
|
|
27
|
+
"-y" if overwrite else "-n",
|
|
28
|
+
"-i",
|
|
29
|
+
str(video_path),
|
|
30
|
+
"-vn",
|
|
31
|
+
"-ac",
|
|
32
|
+
str(channels),
|
|
33
|
+
"-ar",
|
|
34
|
+
str(sample_rate),
|
|
35
|
+
str(output_path),
|
|
36
|
+
]
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
command,
|
|
39
|
+
capture_output=True,
|
|
40
|
+
text=True,
|
|
41
|
+
encoding="utf-8",
|
|
42
|
+
errors="replace",
|
|
43
|
+
)
|
|
44
|
+
if result.returncode != 0:
|
|
45
|
+
raise RuntimeError(result.stderr.strip() or "ffmpeg 抽音频失败")
|
|
46
|
+
return output_path
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .utils import ensure_dir
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SENSEVOICE_REPO = "iic/SenseVoiceSmall"
|
|
10
|
+
VAD_REPO = "iic/speech_fsmn_vad_zh-cn-16k-common-pytorch"
|
|
11
|
+
TOKEN_RE = re.compile(r"<\|[^>]+\|>")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def import_snapshot_download():
|
|
15
|
+
try:
|
|
16
|
+
from modelscope import snapshot_download # type: ignore
|
|
17
|
+
except ImportError:
|
|
18
|
+
from modelscope.hub.snapshot_download import snapshot_download # type: ignore
|
|
19
|
+
return snapshot_download
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def model_dir_ready(model_dir: Path) -> bool:
|
|
23
|
+
return model_dir.exists() and any(model_dir.iterdir())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ensure_model_dir(model_dir: Path, model_id: str) -> tuple[Path, bool]:
|
|
27
|
+
if model_dir_ready(model_dir):
|
|
28
|
+
return model_dir, False
|
|
29
|
+
snapshot_download = import_snapshot_download()
|
|
30
|
+
ensure_dir(model_dir)
|
|
31
|
+
try:
|
|
32
|
+
snapshot_download(
|
|
33
|
+
model_id=model_id,
|
|
34
|
+
local_dir=str(model_dir),
|
|
35
|
+
local_dir_use_symlinks=False,
|
|
36
|
+
)
|
|
37
|
+
except TypeError:
|
|
38
|
+
snapshot_download(
|
|
39
|
+
model_id=model_id,
|
|
40
|
+
local_dir=str(model_dir),
|
|
41
|
+
)
|
|
42
|
+
return model_dir, True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ensure_models(models_root: Path) -> tuple[Path, Path, bool]:
|
|
46
|
+
sensevoice_dir, downloaded_sensevoice = ensure_model_dir(
|
|
47
|
+
models_root / "SenseVoiceSmall",
|
|
48
|
+
SENSEVOICE_REPO,
|
|
49
|
+
)
|
|
50
|
+
vad_dir, downloaded_vad = ensure_model_dir(models_root / "fsmn-vad", VAD_REPO)
|
|
51
|
+
return sensevoice_dir, vad_dir, downloaded_sensevoice or downloaded_vad
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_device(device: str = "auto") -> str:
|
|
55
|
+
if device != "auto":
|
|
56
|
+
return device
|
|
57
|
+
try:
|
|
58
|
+
import torch # type: ignore
|
|
59
|
+
|
|
60
|
+
return "cuda:0" if torch.cuda.is_available() else "cpu"
|
|
61
|
+
except Exception:
|
|
62
|
+
return "cpu"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def clean_transcript(text: str) -> str:
|
|
66
|
+
cleaned = TOKEN_RE.sub(" ", text)
|
|
67
|
+
cleaned = re.sub(r"\s+", " ", cleaned)
|
|
68
|
+
return cleaned.strip()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_model(models_root: Path, device: str = "auto"):
|
|
72
|
+
from funasr import AutoModel # type: ignore
|
|
73
|
+
|
|
74
|
+
sensevoice_dir, vad_dir, downloaded = ensure_models(models_root)
|
|
75
|
+
model = AutoModel(
|
|
76
|
+
model=str(sensevoice_dir),
|
|
77
|
+
trust_remote_code=True,
|
|
78
|
+
vad_model=str(vad_dir),
|
|
79
|
+
vad_kwargs={"max_single_segment_time": 30000},
|
|
80
|
+
device=resolve_device(device),
|
|
81
|
+
)
|
|
82
|
+
return model, downloaded
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
VIDEO_SUFFIXES = {".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"}
|
|
10
|
+
AUDIO_SUFFIXES = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".opus"}
|
|
11
|
+
MEDIA_SUFFIXES = VIDEO_SUFFIXES | AUDIO_SUFFIXES
|
|
12
|
+
INVALID_FILENAME_RE = re.compile('[<>:"/\\\\|?*\\x00-\\x1F]')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def sanitize_filename(name: str, max_length: int = 120) -> str:
|
|
16
|
+
cleaned = INVALID_FILENAME_RE.sub("_", name).strip()
|
|
17
|
+
cleaned = re.sub(r"\s+", " ", cleaned)
|
|
18
|
+
cleaned = cleaned.rstrip(". ")
|
|
19
|
+
if not cleaned:
|
|
20
|
+
cleaned = "untitled"
|
|
21
|
+
return cleaned[:max_length].rstrip(". ")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ensure_parent(path: Path) -> Path:
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
return path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def ensure_dir(path: Path) -> Path:
|
|
30
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
return path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_json(path: Path, data: object) -> Path:
|
|
35
|
+
ensure_parent(path)
|
|
36
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_text(path: Path, text: str) -> Path:
|
|
41
|
+
ensure_parent(path)
|
|
42
|
+
path.write_text(text, encoding="utf-8")
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def iter_media_files(path: Path, recursive: bool = False) -> Iterable[Path]:
|
|
47
|
+
if path.is_file():
|
|
48
|
+
if path.suffix.lower() in MEDIA_SUFFIXES:
|
|
49
|
+
yield path
|
|
50
|
+
return
|
|
51
|
+
iterator = path.rglob("*") if recursive else path.glob("*")
|
|
52
|
+
for candidate in iterator:
|
|
53
|
+
if candidate.is_file() and candidate.suffix.lower() in MEDIA_SUFFIXES:
|
|
54
|
+
yield candidate
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_video_file(path: Path) -> bool:
|
|
58
|
+
return path.suffix.lower() in VIDEO_SUFFIXES
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_audio_file(path: Path) -> bool:
|
|
62
|
+
return path.suffix.lower() in AUDIO_SUFFIXES
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from common.media import extract_audio
|
|
7
|
+
from common.sensevoice import build_model
|
|
8
|
+
from common.sensevoice import clean_transcript
|
|
9
|
+
from common.utils import ensure_dir
|
|
10
|
+
from common.utils import is_audio_file
|
|
11
|
+
from common.utils import is_video_file
|
|
12
|
+
from common.utils import iter_media_files
|
|
13
|
+
from common.utils import sanitize_filename
|
|
14
|
+
from common.utils import write_text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
18
|
+
DEFAULT_CACHE_ROOT = SCRIPT_DIR / "cache"
|
|
19
|
+
DEFAULT_MODELS_ROOT = SCRIPT_DIR / "models"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def prepare_audio(
|
|
23
|
+
source: Path,
|
|
24
|
+
*,
|
|
25
|
+
audio_cache_dir: Path,
|
|
26
|
+
ffmpeg_bin: str,
|
|
27
|
+
overwrite: bool,
|
|
28
|
+
) -> tuple[Path, str]:
|
|
29
|
+
if is_audio_file(source):
|
|
30
|
+
return source, "audio"
|
|
31
|
+
if not is_video_file(source):
|
|
32
|
+
raise ValueError(f"不支持的媒体文件类型:{source}")
|
|
33
|
+
output_path = audio_cache_dir / f"{sanitize_filename(source.stem)}.wav"
|
|
34
|
+
extract_audio(
|
|
35
|
+
source,
|
|
36
|
+
output_path,
|
|
37
|
+
ffmpeg_bin=ffmpeg_bin,
|
|
38
|
+
overwrite=overwrite,
|
|
39
|
+
)
|
|
40
|
+
return output_path, "video"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def transcribe_file(
|
|
44
|
+
model,
|
|
45
|
+
audio_file: Path,
|
|
46
|
+
*,
|
|
47
|
+
output_dir: Path,
|
|
48
|
+
language: str,
|
|
49
|
+
batch_size_s: int,
|
|
50
|
+
overwrite: bool,
|
|
51
|
+
) -> Path:
|
|
52
|
+
output_path = output_dir / f"{sanitize_filename(audio_file.stem)}.txt"
|
|
53
|
+
if output_path.exists() and not overwrite:
|
|
54
|
+
return output_path
|
|
55
|
+
result = model.generate(
|
|
56
|
+
input=str(audio_file),
|
|
57
|
+
cache={},
|
|
58
|
+
language=language,
|
|
59
|
+
use_itn=True,
|
|
60
|
+
batch_size_s=batch_size_s,
|
|
61
|
+
merge_vad=True,
|
|
62
|
+
merge_length_s=15,
|
|
63
|
+
ban_emo_unk=True,
|
|
64
|
+
)
|
|
65
|
+
raw_text = ""
|
|
66
|
+
if result and isinstance(result, list):
|
|
67
|
+
raw_text = str(result[0].get("text", ""))
|
|
68
|
+
return write_text(output_path, clean_transcript(raw_text))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main() -> None:
|
|
72
|
+
parser = argparse.ArgumentParser(description="本地音视频转文字:视频先抽音频,音频直接转写。")
|
|
73
|
+
parser.add_argument("input_path", type=Path, help="音频文件、视频文件或目录")
|
|
74
|
+
parser.add_argument("--cache-root", type=Path, default=DEFAULT_CACHE_ROOT, help="缓存目录")
|
|
75
|
+
parser.add_argument("--models-root", type=Path, default=DEFAULT_MODELS_ROOT, help="模型目录")
|
|
76
|
+
parser.add_argument("--recursive", action="store_true", help="目录模式下递归扫描")
|
|
77
|
+
parser.add_argument("--overwrite", action="store_true", help="覆盖已有缓存和文本结果")
|
|
78
|
+
parser.add_argument("--ffmpeg-bin", default="ffmpeg", help="ffmpeg 可执行文件名或路径")
|
|
79
|
+
parser.add_argument("--device", default="auto", help="auto、cpu、cuda:0 等")
|
|
80
|
+
parser.add_argument("--language", default="auto", help="zh、en、yue、ja、ko、auto")
|
|
81
|
+
parser.add_argument("--batch-size-s", type=int, default=60, help="动态 batch 秒数")
|
|
82
|
+
args = parser.parse_args()
|
|
83
|
+
|
|
84
|
+
cache_root = args.cache_root.resolve()
|
|
85
|
+
models_root = args.models_root.resolve()
|
|
86
|
+
audio_cache_dir = ensure_dir(cache_root / "audio")
|
|
87
|
+
transcript_dir = ensure_dir(cache_root / "transcripts")
|
|
88
|
+
|
|
89
|
+
media_files = list(iter_media_files(args.input_path.resolve(), recursive=args.recursive))
|
|
90
|
+
if not media_files:
|
|
91
|
+
raise SystemExit("没有找到可处理的音频或视频文件。")
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
model, downloaded_models = build_model(models_root, device=args.device)
|
|
95
|
+
except ImportError as exc:
|
|
96
|
+
raise SystemExit(
|
|
97
|
+
"缺少 FunASR 或 ModelScope 依赖。请先在 scripts/.venv 中安装 requirements-transcribe.txt。"
|
|
98
|
+
) from exc
|
|
99
|
+
|
|
100
|
+
print(f"模型目录: {models_root}")
|
|
101
|
+
print(f"缓存目录: {cache_root}")
|
|
102
|
+
print(f"模型首次下载: {'是' if downloaded_models else '否'}")
|
|
103
|
+
|
|
104
|
+
for media_file in media_files:
|
|
105
|
+
audio_file, source_type = prepare_audio(
|
|
106
|
+
media_file,
|
|
107
|
+
audio_cache_dir=audio_cache_dir,
|
|
108
|
+
ffmpeg_bin=args.ffmpeg_bin,
|
|
109
|
+
overwrite=args.overwrite,
|
|
110
|
+
)
|
|
111
|
+
text_path = transcribe_file(
|
|
112
|
+
model,
|
|
113
|
+
audio_file,
|
|
114
|
+
output_dir=transcript_dir,
|
|
115
|
+
language=args.language,
|
|
116
|
+
batch_size_s=max(1, args.batch_size_s),
|
|
117
|
+
overwrite=args.overwrite,
|
|
118
|
+
)
|
|
119
|
+
print(f"输入文件: {media_file}")
|
|
120
|
+
print(f"输入类型: {'视频,已先抽取音频' if source_type == 'video' else '音频'}")
|
|
121
|
+
print(f"音频文件: {audio_file}")
|
|
122
|
+
print(f"转写结果: {text_path}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
main()
|