This repository has been archived on 2026-08-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
openclaw/skills/kb-whisper-diarization/whisper_diarization.py
T
2026-02-15 00:36:24 +08:00

340 lines
11 KiB
Python

#!/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")
# 新版 pyannote 需要先登录
from huggingface_hub import login
print("登录 HuggingFace...")
login(token=hf_token)
print("加载 pyannote 流水线...")
pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization")
# 设置设备
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()