Files
freedakgmail b43e3725ee feat: 添加浅色主题支持
- 配置浅色主题配色方案和 CSS 变量
- 修复组件在浅色模式下的样式适配
- 统一文字颜色类名使用 CSS 变量
- 优化玻璃效果在浅色主题下的显示
2026-07-09 22:10:49 +08:00

406 lines
12 KiB
Python

"""
会议音频处理器 - 基于 FunASR
支持: 音频上传转录、实时录音转录、说话人分离
"""
import os
import uuid
import hashlib
import asyncio
import tempfile
from datetime import datetime
from typing import List, Dict, Optional, Tuple, AsyncGenerator
from dataclasses import dataclass, asdict
from concurrent.futures import ThreadPoolExecutor
import numpy as np
import soundfile as sf
from funasr import AutoModel
from funasr.utils.postprocess_utils import rich_transcription_postprocess
@dataclass
class AudioSegment:
"""音频片段"""
start: float # 秒
end: float # 秒
speaker: int # 说话人ID
text: str # 转录文本
emotion: str = "NEUTRAL" # 情感标签
@dataclass
class MeetingRecord:
"""会议记录"""
meeting_id: str
title: str
date: str
duration: float
audio_path: str
transcript_path: Optional[str]
segments: List[AudioSegment]
speaker_count: int
raw_result: Dict
def to_dict(self) -> Dict:
return {
"meeting_id": self.meeting_id,
"title": self.title,
"date": self.date,
"duration": self.duration,
"audio_path": self.audio_path,
"transcript_path": self.transcript_path,
"speaker_count": self.speaker_count,
"segments": [
{
"start": s.start,
"end": s.end,
"speaker": s.speaker,
"text": s.text,
"emotion": s.emotion
} for s in self.segments
]
}
class RealtimeTranscriber:
"""实时转写器"""
def __init__(self, processor):
self.processor = processor
self.model = processor.model
self._reset()
def _reset(self):
"""重置状态"""
self.segments = []
self.speakers = set()
self.total_duration = 0.0
def process_audio_data(self, audio_data: bytes, sample_rate: int = 16000) -> Optional[AudioSegment]:
"""处理一个音频块"""
try:
# 将 bytes 转换为 numpy 数组
import wave
import io
# 解析 WAV 数据
with wave.open(io.BytesIO(audio_data), 'rb') as wav_file:
channels = wav_file.getnchannels()
sample_width = wav_file.getsampwidth()
framerate = wav_file.getframerate()
n_frames = wav_file.getnframes()
audio_bytes = wav_file.readframes(n_frames)
# 转换为 numpy 数组
audio_array = np.frombuffer(audio_bytes, dtype=np.int16)
# 如果是多声道,转换为单声道
if channels > 1:
audio_array = audio_array.reshape(-1, channels).mean(axis=1).astype(np.int16)
# 重采样为 16kHz(如果需要)
if framerate != 16000:
import scipy.signal as signal
num_samples = int(len(audio_array) * 16000 / framerate)
audio_array = signal.resample(audio_array, num_samples).astype(np.int16)
# 更新总时长
self.total_duration += len(audio_array) / sample_rate
# 保存为临时文件
temp_file = tempfile.NamedTemporaryFile(suffix='.wav', delete=False)
sf.write(temp_file.name, audio_array, sample_rate)
temp_path = temp_file.name
# 使用模型转录
result = self.model.generate(
input=temp_path,
batch_size_s=60,
return_raw_text=True,
sentence_timestamp=True
)
# 清理临时文件
os.unlink(temp_path)
if result and len(result) > 0:
segment = self._parse_single_result(result[0])
if segment:
self.speakers.add(segment.speaker)
self.segments.append(segment)
return segment
except Exception as e:
print(f"处理音频块失败: {e}")
return None
def _parse_single_result(self, result) -> Optional[AudioSegment]:
"""解析单个转录结果"""
try:
text = result.get("text", "")
if not text:
return None
text = rich_transcription_postprocess(text)
# 获取时间戳
sentence_info = result.get("sentence_info", [])
if sentence_info:
seg = sentence_info[0]
start = seg.get("start", 0) / 1000
end = seg.get("end", 0) / 1000
speaker = seg.get("spk", 0)
else:
start = self.total_duration - len(text) / 10 # 估算
end = self.total_duration
speaker = 0
return AudioSegment(
start=start,
end=end,
speaker=speaker,
text=text,
emotion="NEUTRAL"
)
except Exception as e:
print(f"解析结果失败: {e}")
return None
def get_result(self) -> Dict:
"""获取当前转写结果"""
return {
"segments": [
{
"start": s.start,
"end": s.end,
"speaker": s.speaker,
"text": s.text,
"emotion": s.emotion
} for s in self.segments
],
"speaker_count": len(self.speakers),
"duration": self.total_duration
}
class MeetingProcessor:
"""会议处理器"""
def __init__(self, device: str = "cpu"):
"""
初始化处理器
Args:
device: "cpu""cuda"
"""
self.device = device
self.model = None
self.model_name = "iic/SenseVoiceSmall"
self.realtime_transcriber: Optional[RealtimeTranscriber] = None
self._init_model()
def _init_model(self):
"""初始化 FunASR 模型"""
print(f"🔄 初始化模型: {self.model_name} (device={self.device})")
self.model = AutoModel(
model=self.model_name,
vad_model="fsmn-vad",
spk_model="cam++",
device=self.device,
disable_update=True
)
print("✅ 模型加载完成")
def create_realtime_transcriber(self) -> RealtimeTranscriber:
"""创建实时转写器实例"""
self.realtime_transcriber = RealtimeTranscriber(self)
return self.realtime_transcriber
def _download_audio(self, url: str) -> Tuple[str, float]:
"""下载远程音频"""
import urllib.request
suffix = ".wav"
if ".mp3" in url:
suffix = ".mp3"
elif ".m4a" in url:
suffix = ".m4a"
temp_file = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
urllib.request.urlretrieve(url, temp_file.name)
info = sf.info(temp_file.name)
duration = info.duration
return temp_file.name, duration
def process_audio(
self,
audio_path: str,
title: Optional[str] = None,
is_url: bool = False
) -> MeetingRecord:
"""
处理音频文件
Args:
audio_path: 音频路径或URL
title: 会议标题
is_url: 是否为URL
Returns:
MeetingRecord: 会议记录
"""
# 生成会议ID
meeting_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
# 处理音频路径
if is_url:
actual_path, duration = self._download_audio(audio_path)
else:
actual_path = audio_path
info = sf.info(actual_path)
duration = info.duration
# 使用标题或默认标题
if not title:
title = f"会议记录_{datetime.now().strftime('%Y-%m-%d %H:%M')}"
print(f"🎙️ 开始处理: {title}")
print(f"📁 音频: {actual_path}")
# 执行转录
result = self.model.generate(
input=actual_path,
batch_size_s=300,
return_raw_text=True,
is_final=True,
sentence_timestamp=True
)
# 解析结果
segments = self._parse_result(result)
# 统计说话人数量
speakers = set(s.speaker for s in segments)
speaker_count = len(speakers)
# 创建记录
record = MeetingRecord(
meeting_id=meeting_id,
title=title,
date=datetime.now().isoformat(),
duration=duration,
audio_path=actual_path,
transcript_path=None,
segments=segments,
speaker_count=speaker_count,
raw_result=result[0] if result else {}
)
print(f"✅ 处理完成: {len(segments)} 个片段, {speaker_count} 位说话人")
# 清理临时文件
if is_url and os.path.exists(actual_path):
os.unlink(actual_path)
return record
def _parse_result(self, result: List) -> List[AudioSegment]:
"""解析 FunASR 返回结果"""
segments = []
if not result or len(result) == 0:
return segments
raw = result[0]
# 尝试获取 sentence_info
sentence_info = raw.get("sentence_info", [])
if not sentence_info and isinstance(raw, dict):
# 兼容不同格式
text = raw.get("text", "")
if text:
segments.append(AudioSegment(
start=0,
end=0,
speaker=0,
text=rich_transcription_postprocess(text)
))
return segments
for seg in sentence_info:
start = seg.get("start", 0) / 1000 # 毫秒转秒
end = seg.get("end", 0) / 1000
speaker = seg.get("spk", 0)
text = seg.get("sentence", "")
# 后处理:标点、格式
text = rich_transcription_postprocess(text)
# 提取情感标签
emotion = self._extract_emotion(text)
segments.append(AudioSegment(
start=start,
end=end,
speaker=speaker,
text=text,
emotion=emotion
))
return segments
def _extract_emotion(self, text: str) -> str:
"""从文本中提取情感标签"""
emotions = ["NEUTRAL", "happy", "sad", "angry", "surprise"]
for emo in emotions:
if emo in text:
return emo
return "NEUTRAL"
def transcribe_segment(self, audio_path: str, start: float, end: float) -> str:
"""转录指定时间段的音频"""
data, sr = sf.read(audio_path, start=int(start * sr), stop=int(end * sr))
# 临时保存片段
temp_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
sf.write(temp_file.name, data, sr)
# 转录
result = self.model.generate(input=temp_file.name)
# 清理
os.unlink(temp_file.name)
if result:
return rich_transcription_postprocess(result[0].get("text", ""))
return ""
def get_device() -> str:
"""自动检测可用设备"""
try:
import torch
if torch.cuda.is_available():
return "cuda"
except ImportError:
pass
return "cpu"
if __name__ == "__main__":
# 测试
processor = MeetingProcessor(device=get_device())
# 测试示例音频
test_url = "https://isv-data.oss-cn-hangzhou.aliyuncs.com/ics/MaaS/ASR/test_audio/asr_example_zh.wav"
result = processor.process_audio(test_url, title="测试会议", is_url=True)
print("\n=== 转录结果 ===")
for seg in result.segments:
print(f"[{seg.start:.1f}s] 说话人{seg.speaker}: {seg.text}")