检查代码是否可运行,是否高效,是否可CPUimport sys
import os
import json
import time
import wave
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import soundfile as sf
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QLineEdit, QTextEdit, QFileDialog,
QProgressBar, QGroupBox, QComboBox, QCheckBox, QMessageBox)
from PyQt5.QtCore import QThread, pyqtSignal
from pydub import AudioSegment
from transformers import pipeline, AutoTokenizer, AutoModelForSequenceClassification
import whisper
from pyannote.audio import Pipeline
from docx import Document
from docx.shared import Inches
import librosa
import tempfile
from collections import defaultdict
import re
from concurrent.futures import ThreadPoolExecutor, as_completed
import torch
from torch.cuda import is_available as cuda_available
import logging
import gc
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# 全局模型缓存
MODEL_CACHE = {}
class AnalysisThread(QThread):
progress = pyqtSignal(int)
message = pyqtSignal(str)
analysis_complete = pyqtSignal(dict)
error = pyqtSignal(str)
def __init__(self, audio_files, keyword_file, whisper_model_path, pyannote_model_path, emotion_model_path):
super().__init__()
self.audio_files = audio_files
self.keyword_file = keyword_file
self.whisper_model_path = whisper_model_path
self.pyannote_model_path = pyannote_model_path
self.emotion_model_path = emotion_model_path
self.running = True
self.cached_models = {}
self.temp_files = [] # 用于管理临时文件
self.lock = torch.multiprocessing.Lock() # 用于模型加载的锁
def run(self):
try:
# 加载关键词
self.message.emit("正在加载关键词...")
keywords = self.load_keywords()
# 预加载模型
self.message.emit("正在预加载模型...")
self.preload_models()
results = []
total_files = len(self.audio_files)
for idx, audio_file in enumerate(self.audio_files):
if not self.running:
self.message.emit("分析已停止")
return
self.message.emit(f"正在处理文件: {os.path.basename(audio_file)} ({idx + 1}/{total_files})")
file_result = self.analyze_file(audio_file, keywords)
if file_result:
results.append(file_result)
# 定期清理内存
if idx % 5 == 0:
gc.collect()
torch.cuda.empty_cache() if cuda_available() else None
self.progress.emit(int((idx + 1) / total_files * 100))
self.analysis_complete.emit({"results": results, "keywords": keywords})
self.message.emit("分析完成!")
except Exception as e:
import traceback
error_msg = f"分析过程中发生错误: {str(e)}\n{traceback.format_exc()}"
self.error.emit(error_msg)
logger.error(error_msg)
finally:
# 清理临时文件
self.cleanup_temp_files()
def cleanup_temp_files(self):
"""清理所有临时文件"""
for temp_file in self.temp_files:
if os.path.exists(temp_file):
try:
os.unlink(temp_file)
except Exception as e:
logger.warning(f"删除临时文件失败: {temp_file}, 原因: {str(e)}")
def preload_models(self):
"""预加载所有模型到缓存(添加线程安全)"""
global MODEL_CACHE
# 使用锁确保线程安全
with self.lock:
# 检查全局缓存是否已加载模型
if 'whisper' in MODEL_CACHE and 'pyannote' in MODEL_CACHE and 'emotion_classifier' in MODEL_CACHE:
self.cached_models = MODEL_CACHE
self.message.emit("使用缓存的模型")
return
self.cached_models = {}
try:
# 加载语音识别模型
if 'whisper' not in MODEL_CACHE:
self.message.emit("正在加载语音识别模型...")
MODEL_CACHE['whisper'] = whisper.load_model(
self.whisper_model_path,
device="cuda" if cuda_available() else "cpu"
)
self.cached_models['whisper'] = MODEL_CACHE['whisper']
# 加载说话人分离模型
if 'pyannote' not in MODEL_CACHE:
self.message.emit("正在加载说话人分离模型...")
MODEL_CACHE['pyannote'] = Pipeline.from_pretrained(
self.pyannote_model_path,
use_auth_token=True
)
self.cached_models['pyannote'] = MODEL_CACHE['pyannote']
# 加载情感分析模型
if 'emotion_classifier' not in MODEL_CACHE:
self.message.emit("正在加载情感分析模型...")
device = 0 if cuda_available() else -1
tokenizer = AutoTokenizer.from_pretrained(self.emotion_model_path)
model = AutoModelForSequenceClassification.from_pretrained(self.emotion_model_path)
# 尝试使用半精度浮点数减少内存占用
try:
if device != -1:
model = model.half()
except Exception:
pass # 如果失败则继续使用全精度
MODEL_CACHE['emotion_classifier'] = pipeline(
"text-classification",
model=model,
tokenizer=tokenizer,
device=device
)
self.cached_models['emotion_classifier'] = MODEL_CACHE['emotion_classifier']
except Exception as e:
raise Exception(f"模型加载失败: {str(e)}")
def analyze_file(self, audio_file, keywords):
"""分析单个音频文件(优化内存使用)"""
try:
# 确保音频为WAV格式
wav_file, is_temp = self.convert_to_wav(audio_file)
if is_temp:
self.temp_files.append(wav_file)
# 获取音频信息
duration, sample_rate, channels = self.get_audio_info(wav_file)
# 说话人分离 - 使用较小的音频片段处理大文件
diarization = self.process_diarization(wav_file, duration)
# 识别客服和客户
agent_segments, customer_segments = self.identify_speakers(wav_file, diarization, keywords['opening'])
# 并行处理客服和客户音频
agent_result, customer_result = {}, {}
with ThreadPoolExecutor(max_workers=2) as executor:
agent_future = executor.submit(
self.process_speaker_audio,
wav_file, agent_segments, "客服"
)
customer_future = executor.submit(
self.process_speaker_audio,
wav_file, customer_segments, "客户"
)
agent_result = agent_future.result()
customer_result = customer_future.result()
# 情感分析 - 批处理提高效率
agent_emotion, customer_emotion = self.analyze_emotions(
[agent_result.get('text', ''), customer_result.get('text', '')]
)
# 服务规范检查
opening_check = self.check_opening(agent_result.get('text', ''), keywords['opening'])
closing_check = self.check_closing(agent_result.get('text', ''), keywords['closing'])
forbidden_check = self.check_forbidden(agent_result.get('text', ''), keywords['forbidden'])
# 沟通技巧分析
speech_rate = self.analyze_speech_rate(agent_result.get('segments', []))
volume_analysis = self.analyze_volume(wav_file, agent_segments, sample_rate)
# 问题解决率分析
resolution_rate = self.analyze_resolution(
agent_result.get('text', ''),
customer_result.get('text', ''),
keywords['resolution']
)
return {
"file_name": os.path.basename(audio_file),
"duration": duration,
"agent_text": agent_result.get('text', ''),
"customer_text": customer_result.get('text', ''),
"opening_check": opening_check,
"closing_check": closing_check,
"forbidden_check": forbidden_check,
"agent_emotion": agent_emotion,
"customer_emotion": customer_emotion,
"speech_rate": speech_rate,
"volume_mean": volume_analysis.get('mean', -60),
"volume_std": volume_analysis.get('std', 0),
"resolution_rate": resolution_rate
}
except Exception as e:
error_msg = f"处理文件 {os.path.basename(audio_file)} 时出错: {str(e)}"
self.error.emit(error_msg)
logger.error(error_msg, exc_info=True)
return None
finally:
# 清理临时文件
if is_temp and os.path.exists(wav_file):
try:
os.unlink(wav_file)
except Exception:
pass
def process_diarization(self, wav_file, duration):
"""分块处理说话人分离,避免大文件内存溢出"""
# 对于短音频直接处理
if duration <= 600: # 10分钟以下
return self.cached_models['pyannote'](wav_file)
# 对于长音频分块处理
self.message.emit(f"音频较长({duration:.1f}秒),将分块处理...")
diarization_result = []
chunk_size = 300 # 5分钟块
for start in range(0, int(duration), chunk_size):
if not self.running:
return []
end = min(start + chunk_size, duration)
self.message.emit(f"处理片段: {start}-{end}秒")
# 提取音频片段
with tempfile.NamedTemporaryFile(suffix='.wav') as tmpfile:
self.extract_audio_segment(wav_file, start, end, tmpfile.name)
segment_diarization = self.cached_models['pyannote'](tmpfile.name)
# 调整时间偏移
for segment, _, speaker in segment_diarization.itertracks(yield_label=True):
diarization_result.append((
segment.start + start,
segment.end + start,
speaker
))
return diarization_result
def extract_audio_segment(self, input_file, start_sec, end_sec, output_file):
"""提取音频片段"""
audio = AudioSegment.from_wav(input_file)
start_ms = int(start_sec * 1000)
end_ms = int(end_sec * 1000)
segment = audio[start_ms:end_ms]
segment.export(output_file, format="wav")
def process_speaker_audio(self, wav_file, segments, speaker_type):
"""处理说话人音频(优化内存使用)"""
if not segments:
return {'text': "", 'segments': []}
text = ""
segment_details = []
whisper_model = self.cached_models['whisper']
# 处理每个片段
for idx, (start, end) in enumerate(segments):
if not self.running:
break
# 每处理5个片段报告一次进度
if idx % 5 == 0:
self.message.emit(f"{speaker_type}: 处理片段 {idx+1}/{len(segments)}")
duration = end - start
segment_text = self.transcribe_audio_segment(wav_file, start, end, whisper_model)
segment_details.append({
'start': start,
'end': end,
'duration': duration,
'text': segment_text
})
text += segment_text + " "
return {
'text': text.strip(),
'segments': segment_details
}
def identify_speakers(self, wav_file, diarization, opening_keywords):
"""
改进的客服识别方法
1. 检查前三个片段是否有开场白关键词
2. 如果片段不足三个,则检查所有存在的片段
3. 如果无法确定客服,则默认第二个说话人是客服
"""
if not diarization:
return [], []
speaker_segments = defaultdict(list)
speaker_first_occurrence = {} # 记录每个说话人的首次出现时间
# 收集所有说话人片段并记录首次出现时间
for item in diarization:
if len(item) == 3: # 来自分块处理的结果
start, end, speaker = item
else: # 来自pyannote的直接结果
segment, _, speaker = item
start, end = segment.start, segment.end
speaker_segments[speaker].append((start, end))
if speaker not in speaker_first_occurrence or start < speaker_first_occurrence[speaker]:
speaker_first_occurrence[speaker] = start
# 如果没有说话人
if not speaker_segments:
return [], []
# 如果只有一个说话人
if len(speaker_segments) == 1:
speaker = list(speaker_segments.keys())[0]
return speaker_segments[speaker], []
# 计算每个说话人的开场白得分
speaker_scores = {}
whisper_model = self.cached_models['whisper']
for speaker, segments in speaker_segments.items():
score = 0
# 检查前三个片段(如果存在)
check_segments = segments[:3] # 最多取前三个片段
for start, end in check_segments:
# 转录片段
text = self.transcribe_audio_segment(wav_file, start, end, whisper_model)
# 检查开场白关键词
for keyword in opening_keywords:
if keyword and keyword in text:
score += 1
break # 找到一个关键词就加分并跳出循环
speaker_scores[speaker] = score
# 尝试找出得分最高的说话人
max_score = max(speaker_scores.values())
max_speakers = [spk for spk, score in speaker_scores.items() if score == max_score]
# 如果有唯一最高分说话人,作为客服
if len(max_speakers) == 1:
agent_speaker = max_speakers[0]
else:
# 无法通过开场白确定客服时,默认第二个说话人是客服
# 按首次出现时间排序
sorted_speakers = sorted(speaker_first_occurrence.items(), key=lambda x: x[1])
# 确保至少有两个说话人
if len(sorted_speakers) >= 2:
# 取时间上第二个出现的说话人
agent_speaker = sorted_speakers[1][0]
else:
# 如果只有一个说话人(理论上不会进入此分支,但安全处理)
agent_speaker = sorted_speakers[0][0]
# 分离客服和客户片段
agent_segments = speaker_segments[agent_speaker]
customer_segments = []
for speaker, segments in speaker_segments.items():
if speaker != agent_speaker:
customer_segments.extend(segments)
return agent_segments, customer_segments
def load_keywords(self):
"""从Excel文件加载关键词(增强健壮性)"""
try:
df = pd.read_excel(self.keyword_file)
# 确保列存在
columns = ['opening', 'closing', 'forbidden', 'resolution']
for col in columns:
if col not in df.columns:
raise ValueError(f"关键词文件缺少必要列: {col}")
keywords = {
"opening": [str(k).strip() for k in df['opening'].dropna().tolist() if str(k).strip()],
"closing": [str(k).strip() for k in df['closing'].dropna().tolist() if str(k).strip()],
"forbidden": [str(k).strip() for k in df['forbidden'].dropna().tolist() if str(k).strip()],
"resolution": [str(k).strip() for k in df['resolution'].dropna().tolist() if str(k).strip()]
}
# 检查是否有足够的关键词
if not any(keywords.values()):
raise ValueError("关键词文件中没有找到有效关键词")
return keywords
except Exception as e:
raise Exception(f"加载关键词文件失败: {str(e)}")
def convert_to_wav(self, audio_file):
"""将音频文件转换为WAV格式(增强健壮性)"""
try:
if not os.path.exists(audio_file):
raise FileNotFoundError(f"音频文件不存在: {audio_file}")
if audio_file.lower().endswith('.wav'):
return audio_file, False
# 使用临时文件避免磁盘IO
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmpfile:
output_file = tmpfile.name
audio = AudioSegment.from_file(audio_file)
audio.export(output_file, format='wav')
return output_file, True
except Exception as e:
raise Exception(f"音频转换失败: {str(e)}")
def get_audio_info(self, wav_file):
"""获取音频文件信息(增强健壮性)"""
try:
if not os.path.exists(wav_file):
raise FileNotFoundError(f"音频文件不存在: {wav_file}")
# 使用soundfile获取更可靠的信息
with sf.SoundFile(wav_file) as f:
duration = len(f) / f.samplerate
sample_rate = f.samplerate
channels = f.channels
return duration, sample_rate, channels
except Exception as e:
raise Exception(f"获取音频信息失败: {str(e)}")
def transcribe_audio_segment(self, wav_file, start, end, model):
"""转录单个音频片段 - 优化内存使用"""
# 使用pydub加载音频
audio = AudioSegment.from_wav(wav_file)
# 转换为毫秒
start_ms = int(start * 1000)
end_ms = int(end * 1000)
segment_audio = audio[start_ms:end_ms]
# 使用临时文件
with tempfile.NamedTemporaryFile(suffix='.wav') as tmpfile:
segment_audio.export(tmpfile.name, format="wav")
try:
result = model.transcribe(
tmpfile.name,
fp16=cuda_available() # 使用FP16加速(如果可用)
)
return result['text']
except RuntimeError as e:
if "out of memory" in str(e).lower():
# 尝试释放内存后重试
torch.cuda.empty_cache()
gc.collect()
result = model.transcribe(
tmpfile.name,
fp16=cuda_available()
)
return result['text']
raise
def analyze_emotions(self, texts):
"""批量分析文本情感(提高效率)"""
if not any(t.strip() for t in texts):
return [{"label": "中性", "score": 0.0} for _ in texts]
# 截断长文本以提高性能
processed_texts = [t[:500] if len(t) > 500 else t for t in texts]
# 批量处理
classifier = self.cached_models['emotion_classifier']
results = classifier(processed_texts, truncation=True, max_length=512, batch_size=4)
# 确保返回格式一致
emotions = []
for result in results:
if isinstance(result, list) and result:
emotions.append({
"label": result[0]['label'],
"score": result[0]['score']
})
else:
emotions.append({
"label": "中性",
"score": 0.0
})
return emotions
def check_opening(self, text, opening_keywords):
"""检查开场白(使用正则表达式提高准确性)"""
if not text or not opening_keywords:
return False
pattern = "|".join(re.escape(k) for k in opening_keywords)
return bool(re.search(pattern, text))
def check_closing(self, text, closing_keywords):
"""检查结束语(使用正则表达式提高准确性)"""
if not text or not closing_keywords:
return False
pattern = "|".join(re.escape(k) for k in closing_keywords)
return bool(re.search(pattern, text))
def check_forbidden(self, text, forbidden_keywords):
"""检查服务禁语(使用正则表达式提高准确性)"""
if not text or not forbidden_keywords:
return False
pattern = "|".join(re.escape(k) for k in forbidden_keywords)
return bool(re.search(pattern, text))
def analyze_speech_rate(self, segments):
"""改进的语速分析 - 基于实际识别文本"""
if not segments:
return 0
total_chars = 0
total_duration = 0
for segment in segments:
# 计算片段时长(秒)
duration = segment['duration']
total_duration += duration
# 计算中文字符数(去除标点和空格)
chinese_chars = sum(1 for char in segment['text'] if '\u4e00' <= char <= '\u9fff')
total_chars += chinese_chars
if total_duration == 0:
return 0
# 语速 = 总字数 / 总时长(分钟)
return total_chars / (total_duration / 60)
def analyze_volume(self, wav_file, segments, sample_rate):
"""改进的音量分析 - 使用librosa计算RMS分贝值"""
if not segments:
return {"mean": -60, "std": 0}
# 使用soundfile加载音频(更高效)
try:
y, sr = sf.read(wav_file, dtype='float32')
if sr != sample_rate:
y = librosa.resample(y, orig_sr=sr, target_sr=sample_rate)
sr = sample_rate
except Exception:
# 回退到librosa
y, sr = librosa.load(wav_file, sr=sample_rate, mono=True)
all_dB = []
for start, end in segments:
start_sample = int(start * sr)
end_sample = int(end * sr)
# 确保片段在有效范围内
if start_sample < len(y) and end_sample <= len(y):
segment_audio = y[start_sample:end_sample]
# 计算RMS并转换为dB
rms = librosa.feature.rms(y=segment_audio)[0]
dB = librosa.amplitude_to_db(rms, ref=1.0) # 使用标准参考值
all_dB.extend(dB)
if not all_dB:
return {"mean": -60, "std": 0}
return {
"mean": float(np.mean(all_dB)),
"std": float(np.std(all_dB))
}
def analyze_resolution(self, agent_text, customer_text, resolution_keywords):
"""分析问题解决率(使用更智能的匹配)"""
# 检查客户是否提到问题
problem_patterns = [
"问题", "故障", "解决", "怎么办", "如何", "为什么", "不行", "不能",
"无法", "错误", "bug", "issue", "疑问", "咨询"
]
problem_regex = re.compile("|".join(problem_patterns))
has_problem = bool(problem_regex.search(customer_text))
# 检查客服是否提供解决方案
solution_regex = re.compile("|".join(re.escape(k) for k in resolution_keywords))
solution_found = bool(solution_regex.search(agent_text))
# 如果没有检测到问题,则认为已解决
if not has_problem:
return True
return solution_found
def stop(self):
"""停止分析"""
self.running = False
self.message.emit("正在停止分析...")
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("外呼电话录音包质检分析系统")
self.setGeometry(100, 100, 1000, 700)
self.setStyleSheet("""
QMainWindow {
background-color: #f0f0f0;
}
QGroupBox {
font-weight: bold;
border: 1px solid gray;
border-radius: 5px;
margin-top: 1ex;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 5px;
}
QPushButton {
background-color: #4CAF50;
color: white;
border: none;
padding: 5px 10px;
border-radius: 3px;
}
QPushButton:hover {
background-color: #45a049;
}
QPushButton:disabled {
background-color: #cccccc;
}
QProgressBar {
border: 1px solid grey;
border-radius: 3px;
text-align: center;
}
QProgressBar::chunk {
background-color: #4CAF50;
width: 10px;
}
QTextEdit {
font-family: Consolas, Monaco, monospace;
}
""")
# 初始化变量
self.audio_files = []
self.keyword_file = ""
self.whisper_model_path = "./models/whisper-small"
self.pyannote_model_path = "./models/pyannote-speaker-diarization"
self.emotion_model_path = "./models/Erlangshen-Roberta-110M-Sentiment"
self.output_dir = os.path.expanduser("~/质检报告")
# 创建主控件
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout(central_widget)
main_layout.setSpacing(10)
main_layout.setContentsMargins(15, 15, 15, 15)
# 文件选择区域
file_group = QGroupBox("文件选择")
file_layout = QVBoxLayout(file_group)
file_layout.setSpacing(8)
# 音频文件选择
audio_layout = QHBoxLayout()
self.audio_label = QLabel("音频文件/文件夹:")
audio_layout.addWidget(self.audio_label)
self.audio_path_edit = QLineEdit()
self.audio_path_edit.setPlaceholderText("请选择音频文件或文件夹")
audio_layout.addWidget(self.audio_path_edit, 3)
self.audio_browse_btn = QPushButton("浏览...")
self.audio_browse_btn.clicked.connect(self.browse_audio)
audio_layout.addWidget(self.audio_browse_btn)
file_layout.addLayout(audio_layout)
# 关键词文件选择
keyword_layout = QHBoxLayout()
self.keyword_label = QLabel("关键词文件:")
keyword_layout.addWidget(self.keyword_label)
self.keyword_path_edit = QLineEdit()
self.keyword_path_edit.setPlaceholderText("请选择Excel格式的关键词文件")
keyword_layout.addWidget(self.keyword_path_edit, 3)
self.keyword_browse_btn = QPushButton("浏览...")
self.keyword_browse_btn.clicked.connect(self.browse_keyword)
keyword_layout.addWidget(self.keyword_browse_btn)
file_layout.addLayout(keyword_layout)
main_layout.addWidget(file_group)
# 模型设置区域
model_group = QGroupBox("模型设置")
model_layout = QVBoxLayout(model_group)
model_layout.setSpacing(8)
# Whisper模型路径
whisper_layout = QHBoxLayout()
whisper_layout.addWidget(QLabel("Whisper模型路径:"))
self.whisper_edit = QLineEdit(self.whisper_model_path)
whisper_layout.addWidget(self.whisper_edit, 3)
model_layout.addLayout(whisper_layout)
# Pyannote模型路径
pyannote_layout = QHBoxLayout()
pyannote_layout.addWidget(QLabel("Pyannote模型路径:"))
self.pyannote_edit = QLineEdit(self.pyannote_model_path)
pyannote_layout.addWidget(self.pyannote_edit, 3)
model_layout.addLayout(pyannote_layout)
# 情感分析模型路径
emotion_layout = QHBoxLayout()
emotion_layout.addWidget(QLabel("情感分析模型路径:"))
self.emotion_edit = QLineEdit(self.emotion_model_path)
emotion_layout.addWidget(self.emotion_edit, 3)
model_layout.addLayout(emotion_layout)
# 输出目录
output_layout = QHBoxLayout()
output_layout.addWidget(QLabel("输出目录:"))
self.output_edit = QLineEdit(self.output_dir)
self.output_edit.setPlaceholderText("请选择报告输出目录")
output_layout.addWidget(self.output_edit, 3)
self.output_browse_btn = QPushButton("浏览...")
self.output_browse_btn.clicked.connect(self.browse_output)
output_layout.addWidget(self.output_browse_btn)
model_layout.addLayout(output_layout)
main_layout.addWidget(model_group)
# 控制按钮区域
control_layout = QHBoxLayout()
control_layout.setSpacing(10)
self.start_btn = QPushButton("开始分析")
self.start_btn.setStyleSheet("background-color: #2196F3;")
self.start_btn.clicked.connect(self.start_analysis)
control_layout.addWidget(self.start_btn)
self.stop_btn = QPushButton("停止分析")
self.stop_btn.setStyleSheet("background-color: #f44336;")
self.stop_btn.clicked.connect(self.stop_analysis)
self.stop_btn.setEnabled(False)
control_layout.addWidget(self.stop_btn)
self.clear_btn = QPushButton("清空")
self.clear_btn.clicked.connect(self.clear_all)
control_layout.addWidget(self.clear_btn)
main_layout.addLayout(control_layout)
# 进度条
self.progress_bar = QProgressBar()
self.progress_bar.setValue(0)
self.progress_bar.setFormat("就绪")
self.progress_bar.setMinimumHeight(25)
main_layout.addWidget(self.progress_bar)
# 日志输出区域
log_group = QGroupBox("分析日志")
log_layout = QVBoxLayout(log_group)
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
log_layout.addWidget(self.log_text)
main_layout.addWidget(log_group, 1) # 给日志区域更多空间
# 状态区域
status_layout = QHBoxLayout()
self.status_label = QLabel("状态: 就绪")
status_layout.addWidget(self.status_label, 1)
self.file_count_label = QLabel("已选择0个音频文件")
status_layout.addWidget(self.file_count_label)
main_layout.addLayout(status_layout)
# 初始化分析线程
self.analysis_thread = None
def browse_audio(self):
"""浏览音频文件或文件夹"""
options = QFileDialog.Options()
files, _ = QFileDialog.getOpenFileNames(
self, "选择音频文件", "",
"音频文件 (*.mp3 *.wav *.amr *.ogg *.flac *.m4a);;所有文件 (*)",
options=options
)
if files:
self.audio_files = files
self.audio_path_edit.setText("; ".join(files))
self.file_count_label.setText(f"已选择{len(files)}个音频文件")
self.log_text.append(f"已选择{len(files)}个音频文件")
def browse_keyword(self):
"""浏览关键词文件"""
options = QFileDialog.Options()
file, _ = QFileDialog.getOpenFileName(
self, "选择关键词文件", "",
"Excel文件 (*.xlsx *.xls);;所有文件 (*)",
options=options
)
if file:
self.keyword_file = file
self.keyword_path_edit.setText(file)
self.log_text.append(f"已选择关键词文件: {file}")
def browse_output(self):
"""浏览输出目录"""
options = QFileDialog.Options()
directory = QFileDialog.getExistingDirectory(
self, "选择输出目录", self.output_dir, options=options
)
if directory:
self.output_dir = directory
self.output_edit.setText(directory)
self.log_text.append(f"输出目录设置为: {directory}")
def start_analysis(self):
"""开始分析"""
if not self.audio_files:
self.show_warning("请先选择音频文件")
return
if not self.keyword_file:
self.show_warning("请先选择关键词文件")
return
if not os.path.exists(self.keyword_file):
self.show_warning("关键词文件不存在,请重新选择")
return
# 检查模型路径
model_paths = [
self.whisper_edit.text(),
self.pyannote_edit.text(),
self.emotion_edit.text()
]
for path in model_paths:
if not os.path.exists(path):
self.show_warning(f"模型路径不存在: {path}")
return
# 更新模型路径
self.whisper_model_path = self.whisper_edit.text()
self.pyannote_model_path = self.pyannote_edit.text()
self.emotion_model_path = self.emotion_edit.text()
self.output_dir = self.output_edit.text()
# 创建输出目录
os.makedirs(self.output_dir, exist_ok=True)
self.log_text.append("开始分析...")
self.start_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
self.status_label.setText("状态: 分析中...")
self.progress_bar.setFormat("分析中... 0%")
self.progress_bar.setValue(0)
# 创建并启动分析线程
self.analysis_thread = AnalysisThread(
self.audio_files,
self.keyword_file,
self.whisper_model_path,
self.pyannote_model_path,
self.emotion_model_path
)
self.analysis_thread.progress.connect(self.update_progress)
self.analysis_thread.message.connect(self.log_text.append)
self.analysis_thread.analysis_complete.connect(self.on_analysis_complete)
self.analysis_thread.error.connect(self.on_analysis_error)
self.analysis_thread.finished.connect(self.on_analysis_finished)
self.analysis_thread.start()
def update_progress(self, value):
"""更新进度条"""
self.progress_bar.setValue(value)
self.progress_bar.setFormat(f"分析中... {value}%")
def stop_analysis(self):
"""停止分析"""
if self.analysis_thread and self.analysis_thread.isRunning():
self.analysis_thread.stop()
self.log_text.append("正在停止分析...")
self.stop_btn.setEnabled(False)
def clear_all(self):
"""清空所有内容"""
self.audio_files = []
self.keyword_file = ""
self.audio_path_edit.clear()
self.keyword_path_edit.clear()
self.log_text.clear()
self.progress_bar.setValue(0)
self.progress_bar.setFormat("就绪")
self.status_label.setText("状态: 就绪")
self.file_count_label.setText("已选择0个音频文件")
self.log_text.append("已清空所有内容")
def show_warning(self, message):
"""显示警告消息"""
QMessageBox.warning(self, "警告", message)
self.log_text.append(f"警告: {message}")
def on_analysis_complete(self, result):
"""分析完成处理"""
try:
self.log_text.append("正在生成报告...")
if not result.get("results"):
self.log_text.append("警告: 没有生成任何分析结果")
return
# 生成Excel报告
excel_path = os.path.join(self.output_dir, "质检分析报告.xlsx")
self.generate_excel_report(result, excel_path)
# 生成Word报告
word_path = os.path.join(self.output_dir, "质检分析报告.docx")
self.generate_word_report(result, word_path)
self.log_text.append(f"分析报告已保存至: {excel_path}")
self.log_text.append(f"可视化报告已保存至: {word_path}")
self.log_text.append("分析完成!")
self.status_label.setText(f"状态: 分析完成!报告保存至: {self.output_dir}")
self.progress_bar.setFormat("分析完成!")
# 显示完成消息
QMessageBox.information(
self,
"分析完成",
f"分析完成!报告已保存至:\n{excel_path}\n{word_path}"
)
except Exception as e:
import traceback
error_msg = f"生成报告时出错: {str(e)}\n{traceback.format_exc()}"
self.log_text.append(error_msg)
logger.error(error_msg)
def on_analysis_error(self, message):
"""分析错误处理"""
self.log_text.append(f"错误: {message}")
self.status_label.setText("状态: 发生错误")
self.progress_bar.setFormat("发生错误")
QMessageBox.critical(self, "分析错误", message)
def on_analysis_finished(self):
"""分析线程结束处理"""
self.start_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
def generate_excel_report(self, result, output_path):
"""生成Excel报告(增强健壮性)"""
try:
# 从结果中提取数据
data = []
for res in result['results']:
data.append({
"文件名": res['file_name'],
"音频时长(秒)": res['duration'],
"开场白检查": "通过" if res['opening_check'] else "未通过",
"结束语检查": "通过" if res['closing_check'] else "未通过",
"服务禁语检查": "通过" if not res['forbidden_check'] else "未通过",
"客服情感": res['agent_emotion']['label'],
"客服情感得分": res['agent_emotion']['score'],
"客户情感": res['customer_emotion']['label'],
"客户情感得分": res['customer_emotion']['score'],
"语速(字/分)": res['speech_rate'],
"平均音量(dB)": res['volume_mean'],
"音量标准差": res['volume_std'],
"问题解决率": "是" if res['resolution_rate'] else "否"
})
# 创建DataFrame并保存
df = pd.DataFrame(data)
# 尝试使用openpyxl引擎(更稳定)
try:
df.to_excel(output_path, index=False, engine='openpyxl')
except ImportError:
df.to_excel(output_path, index=False)
# 添加汇总统计
try:
with pd.ExcelWriter(output_path, engine='openpyxl', mode='a', if_sheet_exists='replace') as writer:
summary_data = {
"统计项": ["总文件数", "开场白通过率", "结束语通过率", "服务禁语通过率", "问题解决率"],
"数值": [
len(result['results']),
df['开场白检查'].value_counts().get('通过', 0) / len(df),
df['结束语检查'].value_counts().get('通过', 0) / len(df),
df['服务禁语检查'].value_counts().get('通过', 0) / len(df),
df['问题解决率'].value_counts().get('是', 0) / len(df)
]
}
summary_df = pd.DataFrame(summary_data)
summary_df.to_excel(writer, sheet_name='汇总统计', index=False)
except Exception as e:
self.log_text.append(f"添加汇总统计时出错: {str(e)}")
except Exception as e:
raise Exception(f"生成Excel报告失败: {str(e)}")
def generate_word_report(self, result, output_path):
"""生成Word报告(增强健壮性)"""
try:
doc = Document()
# 添加标题
doc.add_heading('外呼电话录音质检分析报告', 0)
# 添加基本信息
doc.add_heading('分析概况', level=1)
doc.add_paragraph(f"分析时间: {time.strftime('%Y-%m-%d %H:%M:%S')}")
doc.add_paragraph(f"分析文件数量: {len(result['results'])}")
doc.add_paragraph(f"关键词文件: {os.path.basename(self.keyword_file)}")
# 添加汇总统计
doc.add_heading('汇总统计', level=1)
# 创建汇总表格
table = doc.add_table(rows=5, cols=2)
table.style = 'Table Grid'
# 表头
hdr_cells = table.rows[0].cells
hdr_cells[0].text = '统计项'
hdr_cells[1].text = '数值'
# 计算统计数据
df = pd.DataFrame(result['results'])
pass_rates = {
"开场白通过率": df['opening_check'].mean() if not df.empty else 0,
"结束语通过率": df['closing_check'].mean() if not df.empty else 0,
"服务禁语通过率": (1 - df['forbidden_check']).mean() if not df.empty else 0,
"问题解决率": df['resolution_rate'].mean() if not df.empty else 0
}
# 填充表格
rows = [
("总文件数", len(result['results'])),
("开场白通过率", f"{pass_rates['开场白通过率']:.2%}"),
("结束语通过率", f"{pass_rates['结束语通过率']:.2%}"),
("服务禁语通过率", f"{pass_rates['服务禁语通过率']:.2%}"),
("问题解决率", f"{pass_rates['问题解决率']:.2%}")
]
for i, row_data in enumerate(rows):
if i < len(table.rows):
row_cells = table.rows[i].cells
row_cells[0].text = row_data[0]
row_cells[1].text = str(row_data[1])
# 添加情感分析图表
if result['results']:
doc.add_heading('情感分析', level=1)
# 客服情感分布
agent_emotions = [res['agent_emotion']['label'] for res in result['results']]
agent_emotion_counts = pd.Series(agent_emotions).value_counts()
if not agent_emotion_counts.empty:
fig, ax = plt.subplots(figsize=(6, 4))
agent_emotion_counts.plot.pie(autopct='%1.1f%%', ax=ax)
ax.set_title('客服情感分布')
ax.set_ylabel('') # 移除默认的ylabel
plt.tight_layout()
# 保存图表到临时文件
chart_path = os.path.join(self.output_dir, "agent_emotion_chart.png")
plt.savefig(chart_path, dpi=100, bbox_inches='tight')
plt.close()
doc.add_picture(chart_path, width=Inches(4))
doc.add_paragraph('图1: 客服情感分布')
# 客户情感分布
customer_emotions = [res['customer_emotion']['label'] for res in result['results']]
customer_emotion_counts = pd.Series(customer_emotions).value_counts()
if not customer_emotion_counts.empty:
fig, ax = plt.subplots(figsize=(6, 4))
customer_emotion_counts.plot.pie(autopct='%1.1f%%', ax=ax)
ax.set_title('客户情感分布')
ax.set_ylabel('') # 移除默认的ylabel
plt.tight_layout()
chart_path = os.path.join(self.output_dir, "customer_emotion_chart.png")
plt.savefig(chart_path, dpi=100, bbox_inches='tight')
plt.close()
doc.add_picture(chart_path, width=Inches(4))
doc.add_paragraph('图2: 客户情感分布')
# 添加详细分析结果
doc.add_heading('详细分析结果', level=1)
# 创建详细表格
table = doc.add_table(rows=1, cols=6)
table.style = 'Table Grid'
# 表头
hdr_cells = table.rows[0].cells
headers = ['文件名', '开场白', '结束语', '禁语', '客服情感', '问题解决']
for i, header in enumerate(headers):
hdr_cells[i].text = header
# 填充数据
for res in result['results']:
row_cells = table.add_row().cells
row_cells[0].text = res['file_name']
row_cells[1].text = "✓" if res['opening_check'] else "✗"
row_cells[2].text = "✓" if res['closing_check'] else "✗"
row_cells[3].text = "✗" if res['forbidden_check'] else "✓"
row_cells[4].text = res['agent_emotion']['label']
row_cells[5].text = "✓" if res['resolution_rate'] else "✗"
# 保存文档
doc.save(output_path)
except Exception as e:
raise Exception(f"生成Word报告失败: {str(e)}")
if __name__ == "__main__":
# 检查是否安装了torch
try:
import torch
except ImportError:
print("警告: PyTorch 未安装,情感分析可能无法使用GPU加速")
app = QApplication(sys.argv)
# 设置应用样式
app.setStyle("Fusion")
window = MainWindow()
window.show()
sys.exit(app.exec_())