feat(kb-whisper): 新增说话者分离功能
- 新增 kb-whisper-diarization 技能 - 使用 pyannote.audio 实现说话者分离 - 支持区分多个说话者(如访谈节目) - 输出带 [SPEAKER_00] 标签的字幕
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# KB Whisper Diarization 环境变量
|
||||
|
||||
# Whisper.cpp 安装路径
|
||||
WHISPER_CPP_PATH=~/whisper.cpp
|
||||
|
||||
# HuggingFace Token(必需,用于下载 pyannote 模型)
|
||||
# 获取方式: https://huggingface.co/settings/tokens
|
||||
# 注意: 需要先申请 pyannote/speaker-diarization 模型访问权限
|
||||
HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
@@ -0,0 +1,127 @@
|
||||
# KB Whisper Diarization Skill
|
||||
|
||||
**kb-whisper-diarization** - 使用 Whisper + pyannote.audio 实现语音识别和说话者分离。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
当用户需要:
|
||||
- 区分多个说话者(如访谈、对话)
|
||||
- 获取带说话者标签的字幕
|
||||
- 分析对话中谁说了什么
|
||||
|
||||
## 环境变量
|
||||
|
||||
在使用之前,必须检查环境变量是否已经设置。
|
||||
|
||||
| 变量名 | 用途 | 示例 |
|
||||
|--------|------|------|
|
||||
| `WHISPER_CPP_PATH` | whisper.cpp 安装路径 | `~/whisper.cpp` |
|
||||
| `HF_TOKEN` | HuggingFace Token | `hf_xxx...` |
|
||||
|
||||
### 获取 HuggingFace Token
|
||||
|
||||
pyannote.audio 需要从 HuggingFace 下载模型,需要先:
|
||||
1. 注册 HuggingFace 账号:https://huggingface.co
|
||||
2. 申请模型访问权限:https://huggingface.co/pyannote/speaker-diarization
|
||||
3. 创建 Token:https://huggingface.co/settings/tokens
|
||||
|
||||
## 使用方法
|
||||
|
||||
```bash
|
||||
# 基本用法(带说话者分离)
|
||||
python whisper_diarization.py --input video.mp4
|
||||
|
||||
# 说话者分离
|
||||
python whisper_diarization.py --input interview.mp4 --diarize
|
||||
|
||||
# 指定说话者数量
|
||||
python whisper_diarization.py --input meeting.mp4 --diarize --num-speakers 2
|
||||
|
||||
# 指定语言
|
||||
python whisper_diarization.py --input video.mp4 --language zh
|
||||
```
|
||||
|
||||
## 命令参数
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `--input` | 输入音视频文件路径 | 必需 |
|
||||
| `--output` | 输出目录 | 文件所在目录 |
|
||||
| `--diarize` | 启用说话者分离 | False |
|
||||
| `--num-speakers` | 估计说话者数量 | None(自动检测) |
|
||||
| `--language` | 语言 | 自动检测 |
|
||||
| `--model` | Whisper 模型 | base |
|
||||
| `--background` | 后台异步执行 | False |
|
||||
|
||||
## 模型选项
|
||||
|
||||
- **Whisper 模型**: tiny, base, small, medium, large
|
||||
- **说话者分离**: pyannote/speaker-diarization (需申请权限)
|
||||
|
||||
## 输出文件
|
||||
|
||||
对于输入 `interview.mp4`,将生成:
|
||||
|
||||
- `interview.mp4.json` - 完整结果(包含说话者标签)
|
||||
- `interview.mp4.txt` - 纯文本
|
||||
- `interview.mp4.srt` - 字幕格式(带说话者标签)
|
||||
|
||||
## 输出格式示例
|
||||
|
||||
### JSON 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"input_file": "interview.mp4",
|
||||
"diarization": true,
|
||||
"speakers": ["SPEAKER_00", "SPEAKER_01"],
|
||||
"segments": [
|
||||
{
|
||||
"id": 0,
|
||||
"speaker": "SPEAKER_00",
|
||||
"start": 0.0,
|
||||
"end": 5.2,
|
||||
"text": "大家好,欢迎来到访谈节目"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "SPEAKER_01",
|
||||
"start": 5.2,
|
||||
"end": 10.8,
|
||||
"text": "谢谢邀请,我很荣幸"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### SRT 字幕格式
|
||||
|
||||
```
|
||||
1
|
||||
00:00:00,000 --> 00:00:05,200
|
||||
[SPEAKER_00] 大家好,欢迎来到访谈节目
|
||||
|
||||
2
|
||||
00:00:05,200 --> 00:00:10,800
|
||||
[SPEAKER_01] 谢谢邀请,我很荣幸
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
pip install pyannote.audio torch
|
||||
|
||||
# 2. 设置 HuggingFace Token
|
||||
export HF_TOKEN="hf_xxx..."
|
||||
|
||||
# 3. 首次运行会自动下载模型
|
||||
python whisper_diarization.py --input test.mp4
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **说话者分离需要申请权限**: https://huggingface.co/pyannote/speaker-diarization
|
||||
- **处理时间较长**: 说话者分离会增加处理时间
|
||||
- **对立体声效果更好**: 多声道音频的分离效果更佳
|
||||
@@ -0,0 +1,8 @@
|
||||
# KB Whisper Diarization 依赖
|
||||
|
||||
# 核心依赖
|
||||
torch>=2.0.0
|
||||
pyannote.audio>=1.0.0
|
||||
|
||||
# 可选: GPU 加速
|
||||
# torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
KB Whisper Diarization - 语音识别 + 说话者分离
|
||||
使用 Whisper 进行语音识别,pyannote.audio 进行说话者分离
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
"""解析命令行参数"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='KB Whisper Diarization - 语音识别 + 说话者分离',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
python whisper_diarization.py --input video.mp4
|
||||
python whisper_diarization.py --input interview.mp4 --diarize
|
||||
python whisper_diarization.py --input meeting.mp4 --diarize --num-speakers 2
|
||||
"""
|
||||
)
|
||||
parser.add_argument('--input', required=True, help='输入音视频文件路径')
|
||||
parser.add_argument('--output', help='输出目录 (默认: 文件所在目录)')
|
||||
parser.add_argument('--diarize', action='store_true', help='启用说话者分离')
|
||||
parser.add_argument('--num-speakers', type=int, help='估计说话者数量')
|
||||
parser.add_argument('--language', help='指定语言 (默认: 自动检测)')
|
||||
parser.add_argument('--model', default='base', choices=['tiny', 'base', 'small', 'medium', 'large'],
|
||||
help='Whisper 模型 (默认: base)')
|
||||
parser.add_argument('--background', action='store_true', help='后台异步执行')
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def get_whisper_path():
|
||||
"""获取 whisper.cpp 路径"""
|
||||
path = os.environ.get('WHISPER_CPP_PATH')
|
||||
if path:
|
||||
path = os.path.expanduser(path)
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
return os.path.expanduser('~/whisper.cpp')
|
||||
|
||||
|
||||
def check_whisper_model(whisper_path, model):
|
||||
"""检查 Whisper 模型"""
|
||||
model_path = os.path.join(whisper_path, 'models', f'ggml-{model}.bin')
|
||||
if not os.path.exists(model_path):
|
||||
raise FileNotFoundError(f"Whisper 模型不存在: {model_path}")
|
||||
return model_path
|
||||
|
||||
|
||||
def convert_to_wav(input_path, output_dir):
|
||||
"""转换为 16kHz WAV"""
|
||||
if input_path.lower().endswith('.wav'):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ffprobe', '-v', 'error', '-show_entries', 'stream=sample_rate',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1', input_path],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0 and int(result.stdout.strip()) == 16000:
|
||||
return input_path
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
output_path = os.path.join(output_dir, f"{Path(input_path).stem}_audio.wav")
|
||||
print(f"转换音频: {input_path} -> {output_path}")
|
||||
|
||||
subprocess.run([
|
||||
'ffmpeg', '-y', '-i', input_path,
|
||||
'-acodec', 'pcm_s16le', '-ar', '16000', '-ac', '1',
|
||||
'-loglevel', 'error', output_path
|
||||
], check=True, capture_output=True)
|
||||
|
||||
print("✓ 音频转换完成")
|
||||
return output_path
|
||||
|
||||
|
||||
def run_whisper(whisper_path, audio_path, model, output_dir, language=None):
|
||||
"""运行 Whisper 识别"""
|
||||
basename = Path(audio_path).stem.replace('_audio', '')
|
||||
output_file = os.path.join(output_dir, basename)
|
||||
|
||||
cmd = [
|
||||
os.path.join(whisper_path, 'whisper-cli'),
|
||||
'-m', os.path.join(whisper_path, 'models', f'ggml-{model}.bin'),
|
||||
'-f', audio_path,
|
||||
'-of', output_file,
|
||||
'-l', language or 'auto',
|
||||
'--output-txt', '--output-srt', '--output-json',
|
||||
]
|
||||
|
||||
print(f"执行 Whisper: {' '.join(cmd[:6])} ...")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, cwd=whisper_path, timeout=3600
|
||||
)
|
||||
|
||||
if result.returncode != 0 and not os.path.exists(f"{output_file}.txt"):
|
||||
raise RuntimeError(f"Whisper 失败: {result.stderr}")
|
||||
|
||||
return basename
|
||||
|
||||
|
||||
def run_diarization(audio_path, num_speakers=None):
|
||||
"""运行 pyannote.audio 说话者分离"""
|
||||
print("\n🎙️ 步骤2: 说话者分离...")
|
||||
|
||||
try:
|
||||
import torch
|
||||
from pyannote.audio import Pipeline
|
||||
except ImportError as e:
|
||||
print(f"✗ 缺少依赖: {e}")
|
||||
print("请安装: pip install pyannote.audio torch")
|
||||
raise
|
||||
|
||||
# 检查 HuggingFace Token
|
||||
hf_token = os.environ.get('HF_TOKEN')
|
||||
if not hf_token:
|
||||
raise RuntimeError("需要设置 HF_TOKEN 环境变量\n"
|
||||
"获取方式: https://huggingface.co/settings/tokens")
|
||||
|
||||
print("加载 pyannote 流水线...")
|
||||
pipeline = Pipeline.from_pretrained(
|
||||
"pyannote/speaker-diarization",
|
||||
use_auth_token=hf_token
|
||||
)
|
||||
|
||||
# 设置设备
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
print(f"使用设备: {device}")
|
||||
pipeline.to(device)
|
||||
|
||||
# 运行分离
|
||||
print("执行说话者分离...")
|
||||
diarization = pipeline(
|
||||
audio_path,
|
||||
num_speakers=num_speakers
|
||||
)
|
||||
|
||||
# 解析结果
|
||||
speakers = {}
|
||||
for turn, _, speaker in diarization.itertracks(yield_label=True):
|
||||
if speaker not in speakers:
|
||||
speakers[speaker] = []
|
||||
speakers[speaker].append({
|
||||
"start": round(turn.start, 2),
|
||||
"end": round(turn.end, 2)
|
||||
})
|
||||
|
||||
print(f"✓ 检测到 {len(speakers)} 个说话者: {list(speakers.keys())}")
|
||||
return speakers
|
||||
|
||||
|
||||
def merge_results(whisper_txt, whisper_srt, diarization, output_dir, basename):
|
||||
"""合并 Whisper 和说话者分离结果"""
|
||||
print("\n📝 步骤3: 合并结果...")
|
||||
|
||||
# 读取 Whisper 文本
|
||||
text_segments = []
|
||||
if os.path.exists(whisper_txt):
|
||||
with open(whisper_txt, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 简单按行分段
|
||||
for i, line in enumerate(content.strip().split('\n')):
|
||||
if line.strip():
|
||||
text_segments.append({
|
||||
"id": i,
|
||||
"text": line.strip(),
|
||||
"start": i * 3.0, # 估算
|
||||
"end": (i + 1) * 3.0
|
||||
})
|
||||
|
||||
# 读取 SRT 获取精确时间
|
||||
srt_segments = []
|
||||
if os.path.exists(whisper_srt):
|
||||
with open(whisper_srt, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 解析 SRT
|
||||
pattern = r'(\d+)\n(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})\n(.+?)(?=\n\n|\Z)'
|
||||
matches = re.findall(pattern, content, re.DOTALL)
|
||||
|
||||
def parse_time(t):
|
||||
h, m, s_ms = t.split(':')
|
||||
s, ms = s_ms.split(',')
|
||||
return float(h) * 3600 + float(m) * 60 + float(s) + float(ms) / 1000
|
||||
|
||||
for idx, num, start, end, text in matches:
|
||||
srt_segments.append({
|
||||
"id": int(idx),
|
||||
"start": parse_time(start),
|
||||
"end": parse_time(end),
|
||||
"text": text.strip()
|
||||
})
|
||||
|
||||
# 为每个 Whisper 段落分配说话者
|
||||
merged_segments = []
|
||||
for seg in srt_segments:
|
||||
speaker = assign_speaker(seg['start'], seg['end'], diarization)
|
||||
merged_segments.append({
|
||||
"id": len(merged_segments),
|
||||
"speaker": speaker,
|
||||
"start": seg['start'],
|
||||
"end": seg['end'],
|
||||
"text": seg['text']
|
||||
})
|
||||
|
||||
# 生成带说话者的 SRT
|
||||
output_srt = os.path.join(output_dir, f"{basename}_diarized.srt")
|
||||
with open(output_srt, 'w', encoding='utf-8') as f:
|
||||
for seg in merged_segments:
|
||||
start = format_time(seg['start'])
|
||||
end = format_time(seg['end'])
|
||||
f.write(f"{seg['id'] + 1}\n")
|
||||
f.write(f"{start} --> {end}\n")
|
||||
f.write(f"[{seg['speaker']}] {seg['text']}\n\n")
|
||||
|
||||
# 生成合并的 TXT
|
||||
output_txt = os.path.join(output_dir, f"{basename}_diarized.txt")
|
||||
with open(output_txt, 'w', encoding='utf-8') as f:
|
||||
for seg in merged_segments:
|
||||
f.write(f"[{seg['speaker']}] {seg['text']}\n")
|
||||
|
||||
# 生成 JSON
|
||||
output_json = os.path.join(output_dir, f"{basename}_diarized.json")
|
||||
json_data = {
|
||||
"success": True,
|
||||
"tool": "whisper-diarization",
|
||||
"diarization": True,
|
||||
"speakers": list(diarization.keys()),
|
||||
"input_file": basename,
|
||||
"output_files": {
|
||||
"txt": f"{basename}_diarized.txt",
|
||||
"srt": f"{basename}_diarized.srt",
|
||||
"json": f"{basename}_diarized.json"
|
||||
},
|
||||
"segments": merged_segments
|
||||
}
|
||||
with open(output_json, 'w', encoding='utf-8') as f:
|
||||
json.dump(json_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"✓ 生成文件:")
|
||||
print(f" - {output_srt}")
|
||||
print(f" - {output_txt}")
|
||||
print(f" - {output_json}")
|
||||
|
||||
return json_data
|
||||
|
||||
|
||||
def assign_speaker(start, end, diarization):
|
||||
"""将时间段分配给说话者"""
|
||||
best_speaker = "UNKNOWN"
|
||||
max_overlap = 0
|
||||
|
||||
for speaker, segments in diarization.items():
|
||||
for seg in segments:
|
||||
overlap = min(end, seg['end']) - max(start, seg['start'])
|
||||
if overlap > max_overlap:
|
||||
max_overlap = overlap
|
||||
best_speaker = speaker
|
||||
|
||||
return best_speaker
|
||||
|
||||
|
||||
def format_time(seconds):
|
||||
"""秒数转换为 SRT 时间格式"""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = int(seconds % 60)
|
||||
ms = int((seconds % 1) * 1000)
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:02d},{ms:03d}"
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_arguments()
|
||||
|
||||
input_file = os.path.abspath(args.input)
|
||||
if not os.path.exists(input_file):
|
||||
print(f"✗ 文件不存在: {input_file}")
|
||||
sys.exit(1)
|
||||
|
||||
output_dir = os.path.abspath(args.output) if args.output else os.path.dirname(input_file)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
basename = Path(input_file).stem
|
||||
|
||||
print(f"\n📁 输入: {input_file}")
|
||||
print(f"📁 输出: {output_dir}")
|
||||
print(f"🎙️ 说话者分离: {'是' if args.diarize else '否'}")
|
||||
|
||||
try:
|
||||
# 步骤1: Whisper 识别
|
||||
print(f"\n🎙️ 步骤1: 语音识别...")
|
||||
whisper_path = get_whisper_path()
|
||||
check_whisper_model(whisper_path, args.model)
|
||||
|
||||
audio_path = convert_to_wav(input_file, output_dir)
|
||||
run_whisper(whisper_path, audio_path, args.model, output_dir, args.language)
|
||||
|
||||
# 步骤2: 说话者分离
|
||||
if args.diarize:
|
||||
diarization = run_diarization(audio_path, args.num_speakers)
|
||||
else:
|
||||
diarization = {}
|
||||
|
||||
# 步骤3: 合并结果
|
||||
whisper_txt = os.path.join(output_dir, f"{basename}.txt")
|
||||
whisper_srt = os.path.join(output_dir, f"{basename}.srt")
|
||||
result = merge_results(whisper_txt, whisper_srt, diarization, output_dir, basename)
|
||||
|
||||
# 清理临时音频
|
||||
if audio_path != input_path and audio_path.endswith('_audio.wav'):
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"\n✅ 处理完成!")
|
||||
print(f" 说话者数量: {len(diarization) if args.diarize else 1}")
|
||||
print(f" 段落数量: {len(result['segments'])}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ 错误: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user