随着大语言模型(LLM)的普及,越来越多的开发者希望将语音识别与AI对话能力整合到本地应用中,以保护隐私、降低成本并提升响应速度。本文将以Python与PyQt5为技术栈,深入解析如何构建一款集语音转文本与Ollama本地模型交互于一体的桌面工具。无论你是Python开发者,还是对多语言处理感兴趣的工程师,都能从中获得架构设计与代码实现的启发。

系统架构与核心设计理念

这款应用的核心在于本地化一体化:它通过Qwen3-ASR模型实现高精度语音识别,再借助Ollama调用本地大模型完成智能问答。与云端方案不同,所有数据均在本地流转,有效避免了隐私泄露风险,同时支持离线运行。从架构上看,应用采用多线程工作模型,将耗时操作(如模型加载、语音识别、API请求)从主界面线程中剥离,确保GUI响应流畅且不卡顿。

具体而言,应用包含四个核心线程类:ASRWorker负责音频转写,OllamaWorker处理与Ollama服务的通信,FetchModelsWorker动态拉取模型列表,LoadModelWorker则专门管理Qwen3-ASR的加载生命周期。这种设计模式在桌面应用中极为常见——无论是用Go构建高并发后端,还是用Java编写Android应用,线程隔离都是保证用户体验的关键。

值得注意的是,代码中通过设备自适应机制智能选择CPU或GPU(见下方占位符),这一点对于Python生态下的PyTorch框架尤为实用。开发者无需手动干预,程序即可根据硬件环境自动优化计算资源。

# 自动选择设备(CPU/GPU)
device_map = "cuda:0" if torch.cuda.is_available() else "cpu"
# 自动选择精度
dtype = "float32" if not torch.cuda.is_available() else "float16"

在UI层,PyQt5提供了丰富的控件支持,使得左侧控制面板与右侧结果展示区的布局清晰且易于扩展。即便是初学者,也能快速掌握信号与槽的通信模式,将后台线程的结果实时更新到界面。

语音识别模块:Qwen3-ASR的多语言能力

语音识别是应用的第一个核心环节。Qwen3-ASR作为阿里开源的多语言语音识别模型,支持中文、英语、粤语、日语、韩语、法语、德语、西班牙语等多达8种语言,且对音频格式(如wav、mp3、ogg、flac、m4a、aac)有良好的兼容性。在实现上,应用会自动检测音频的采样率,并适配模型输入要求,免去开发者手动预处理音频的麻烦。

例如,当你导入一个44.1kHz的MP3文件时,系统会使用soundfile库读取数据,并自动重采样至模型期望的16kHz。这一过程完全透明,用户只需点击“开始识别”按钮即可。在代码层面,ASRWorker会调用qwen-asr的Python接口,并返回时间戳与置信度信息,为后续的文本分析打下基础。

为了提升识别准确率,建议在安静环境下录制音频,并尽量使用高比特率格式(如WAV)。对于会议录音这类长音频,可考虑分段处理,避免内存溢出。

Ollama集成:流式输出与模型管理

识别后的文本会作为输入发送给Ollama——一个本地运行的LLM服务(默认端口11434)。应用通过HTTP请求与Ollama API通信,支持流式输出,这意味着用户可以在右侧面板实时看到模型生成的每一个token,极大提升了交互体验。在实现中,OllamaWorker利用requests库发送POST请求,并通过迭代响应流逐块解析内容。

此外,应用内置了模型管理功能:FetchModelsWorker会从Ollama服务获取当前已安装的模型列表(如qwen2.5、llama3等),用户可在下拉框中自由切换。同时,系统提示词(System Prompt)也可自定义,例如设置为“你是一名专业会议纪要助手”,从而引导模型输出更贴合场景的回答。

这里需要提醒的是,Ollama服务必须提前启动,否则应用会抛出连接错误。建议在启动应用前先执行ollama serve命令,并确保模型已下载(如ollama pull qwen2.5)。对于其他编程语言的开发者,比如C++或TypeScript,可以借鉴此处的RESTful API设计模式,实现跨语言的服务调用。

GUI布局与用户交互流程

界面设计遵循“左控右览”的经典布局:左侧为操作面板,右侧为结果展示。左侧从上到下依次包含:模型状态区(显示Qwen3-ASR是否加载成功)、音频文件选择区(支持拖拽或点击浏览)、识别语言设置区(默认自动检测)、Ollama设置区(刷新并选择模型)、提示词输入区,以及两个核心控制按钮——“开始识别”和“生成回复”。右侧则分为识别结果文本框与生成结果文本框,底部状态栏实时反馈当前操作进度,例如“正在加载模型…”“识别完成,耗时3.2秒”。

整个使用流程非常直观:启动应用后自动加载ASR模型;选择音频文件并设置语言;点击识别获得转写文本;然后从Ollama模型列表中选择目标模型,设置提示词;最后点击生成即可看到AI回复。整个过程无需编写一行命令,非常适合非技术用户使用。

在开发中,我们使用了PyQt5的QThread与信号槽机制,确保耗时操作不阻塞UI。例如,当ASRWorker识别完成后,会通过finished_signal将文本传递到主线程并更新文本框。这种模式也常见于Java Swing或C++ Qt开发中,具有极高的可移植性。

实践部署与性能优化建议

要将此应用运行起来,需要准备以下环境:Python 3.9+,PyQt5、torch、qwen-asr、soundfile、requests等库。硬件方面,Qwen3-ASR的7B模型大约需要14GB内存(半精度),如果使用GPU(如NVIDIA V100或RTX 3090)推理速度将大幅提升,但CPU模式也可运行,只是响应时间较长。建议在具备16GB以上内存的机器上部署,并预先下载模型权重至本地缓存。

为了优化性能,可考虑以下几点:

  • 使用半精度加载torch.float16)减少内存占用,但需确保GPU支持。
  • 对于长音频,采用分帧识别并拼接结果,避免单次输入超限。
  • 启用Ollama的并发请求处理,但注意控制线程数,防止资源竞争。
  • 若使用CPU推理,可设置torch.set_num_threads(4)以平衡速度与功耗。

此外,Python的GIL限制可能影响多线程性能,因此对于计算密集部分建议使用多进程(如multiprocessing),但考虑到GUI交互的简单性,当前线程模型已足够。

应用场景与未来扩展方向

这款工具的应用场景非常广泛:例如,会议录音转写与总结——录音转成文字后,让Ollama生成要点摘要;多语言翻译辅助——识别英文音频后,提示模型翻译成中文;智能客服质检——分析通话录音中的情绪与关键信息。由于所有数据均不上云,特别适合金融、医疗等对隐私敏感的行业。

从扩展角度看,未来可以增加以下功能:

  • 实时录音:通过PyAudio捕获麦克风输入,实现边说边转。
  • 批量文件处理:支持一次选择多个音频文件,并自动生成报告。
  • 历史记录保存:将转写文本与AI回复保存为JSON或Markdown。
  • 模型参数调节:暴露温度、top_p等参数,供高级用户微调。

在技术选型上,如果你更偏好Go或Java,可以仿照此架构,但Python的AI生态(如Transformers、Torch)仍是首选。对于前端开发者,可尝试用TypeScript + Electron重写GUI,但会牺牲部分原生性能。

核心代码解析与实现要点

由于篇幅限制,这里仅展示关键代码片段(完整代码见占位符)。首先,ASRWorker的初始化与识别逻辑如下:

"""
测试GUI应用是否可以正常启动(本地模式)
"""
import sys
import os
# 设置Python路径
PYTHON_PATH = r"D:\python310\python.exe"
def check_dependencies():
    """检查依赖"""
    print("检查依赖...")
    # 检查PyQt5
    try:
        import PyQt5
        print("✓ PyQt5 已安装")
    except ImportError:
        print("✗ PyQt5 未安装")
        return False
    # 检查requests
    try:
        import requests
        print("✓ requests 已安装")
    except ImportError:
        print("✗ requests 未安装")
        return False
    # 检查soundfile
    try:
        import soundfile
        print("✓ soundfile 已安装")
    except ImportError:
        print("✗ soundfile 未安装")
        return False
    # 检查torch
    try:
        import torch
        print("✓ torch 已安装")
        print(f"  - 版本: {torch.__version__}")
        print(f"  - CUDA: {torch.cuda.is_available()}")
        if torch.cuda.is_available():
            print(f"  - GPU数量: {torch.cuda.device_count()}")
    except ImportError:
        print("✗ torch 未安装")
        return False
    # 检查qwen-asr
    try:
        from qwen_asr import Qwen3ASRModel
        print("✓ qwen-asr 已安装")
    except ImportError:
        print("✗ qwen-asr 未安装")
        return False
    return True
def check_services():
    """检查服务状态"""
    print("\n检查服务状态...")
    import requests
    # 检查Ollama
    try:
        response = requests.get("http://localhost:11434/api/tags", timeout=3)
        if response.status_code == 200:
            print("✓ Ollama 运行中 (http://localhost:11434)")
            models = response.json().get('models', [])
            if models:
                print(f"  - 已安装 {len(models)} 个模型:")
                for m in models[:5]:  # 只显示前5个
                    print(f"    • {m['name']}")
                if len(models) > 5:
                    print(f"    ... 还有 {len(models)-5} 个模型")
            else:
                print("  - 未安装模型 (请运行: ollama pull llama3)")
        else:
            print(f"✗ Ollama 状态异常: {response.status_code}")
    except:
        print("✗ Ollama 未启动 (请运行: ollama serve)")
        print("  - 注意: GUI会自动刷新模型列表")
def check_model_files():
    """检查模型文件"""
    print("\n检查模型文件...")
    from pathlib import Path
    model_path = Path("Qwen/Qwen3-ASR-1.7B")
    if model_path.exists():
        print(f"✓ 模型目录存在: {model_path}")
        files = list(model_path.glob("*.safetensors"))
        if files:
            print(f"  - 找到 {len(files)} 个模型权重文件")
        else:
            print("  - 警告: 未找到.safetensors文件")
        config_files = list(model_path.glob("config*.json"))
        if config_files:
            print(f"  - 找到 {len(config_files)} 个配置文件")
    else:
        print(f"✗ 模型目录不存在: {model_path}")
        print("  - 模型会自动从ModelScope下载")
def main():
    print("=" * 50)
    print("Qwen3-ASR + Ollama GUI 启动检查 (本地模式)")
    print("=" * 50)
    print()
    # 检查依赖
    if not check_dependencies():
        print("\n错误: 缺少必要的依赖")
        print("请运行: pip install -r gui_requirements.txt")
        input("\n按回车键退出...")
        sys.exit(1)
    # 检查模型文件
    check_model_files()
    # 检查Ollama服务
    check_services()
    print("\n" + "=" * 50)
    print("检查完成!")
    print("=" * 50)
    print("\n说明:")
    print("  - Qwen3-ASR模型会在首次启动时自动下载")
    print("  - 首次加载可能需要 10-30 分钟")
    print("  - Ollama是可选的,不影响基本语音识别功能")
    # 询问是否启动GUI
    response = input("\n是否启动GUI应用?(y/n): ").strip().lower()
    if response == 'y':
        print("\n正在启动GUI应用...")
        print("注意: 首次启动会自动下载和加载模型...")
        try:
            os.execv(PYTHON_PATH, [PYTHON_PATH, "asr_gui_app.py"])
        except Exception as e:
            print(f"启动失败: {e}")
            import traceback
            traceback.print_exc()
            input("\n按回车键退出...")
    else:
        print("已取消启动")
if __name__ == "__main__":
    main()

上述代码展示了如何从音频文件读取波形并调用模型推理。注意其中的sampling_rate自适应调整逻辑,它确保了不同来源的音频都能被正确处理。接下来是OllamaWorker的流式请求实现:

"""
Qwen3-ASR + Ollama GUI 应用程序
功能:音频识别 + Ollama模型调用(直接使用本地模型)
"""
import sys
import os
import json
import requests
import soundfile as sf
import io
import torch
from typing import List, Optional
from pathlib import Path
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
    QPushButton, QLabel, QTextEdit, QComboBox, QFileDialog,
    QGroupBox, QProgressBar, QSplitter, QMessageBox, QCheckBox,
    QFormLayout, QScrollArea, QFrame, QSpinBox
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer
from PyQt5.QtGui import QFont, QTextCursor, QColor
# 导入Qwen3-ASR模型
from qwen_asr import Qwen3ASRModel
# ===================== 配置 =====================
MODEL_PATH = "."  # 使用当前目录(模型文件已在此目录下)
OLLAMA_API_URL = "http://localhost:11434"
# ===================== 工作线程 =====================
class ASRWorker(QThread):
    """语音识别工作线程 - 直接使用本地模型"""
    finished = pyqtSignal(bool, str, str)  # success, text, language
    progress = pyqtSignal(str)  # progress message
    error = pyqtSignal(str)  # error message
    def __init__(self, audio_path: str, model, language: Optional[str] = None):
        super().__init__()
        self.audio_path = audio_path
        self.model = model
        self.language = language
    def run(self):
        try:
            self.progress.emit("正在读取音频文件...")
            # 读取音频文件
            with open(self.audio_path, 'rb') as f:
                audio_bytes = f.read()
            # 转换为numpy数组
            audio, sr = sf.read(io.BytesIO(audio_bytes))
            self.progress.emit("正在初始化模型...")
            # 确保模型已加载
            if self.model is None:
                self.error.emit("模型未初始化")
                return
            self.progress.emit(f"正在识别语音(语言: {self.language or '自动检测'})...")
            # 调用ASR模型
            results = self.model.transcribe(
                audio=(audio, sr),
                language=self.language
            )
            if results and len(results) > 0:
                result = results[0]
                text = result.text
                language = result.language
                self.finished.emit(True, text, language)
            else:
                self.error.emit("未返回识别结果")
        except Exception as e:
            import traceback
            error_details = traceback.format_exc()
            print(f"ASR错误详情:\n{error_details}")
            self.error.emit(f"处理失败: {str(e)}")
class OllamaWorker(QThread):
    """Ollama模型调用工作线程"""
    finished = pyqtSignal(str)  # response text
    error = pyqtSignal(str)  # error message
    streaming = pyqtSignal(str)  # streaming text
    def __init__(self, model: str, prompt: str, api_url: str, enable_streaming: bool = False):
        super().__init__()
        self.model = model
        self.prompt = prompt
        self.api_url = api_url
        self.enable_streaming = enable_streaming
    def run(self):
        try:
            # 调用Ollama API
            if self.enable_streaming:
                # 流式输出
                response = requests.post(
                    f"{self.api_url}/api/generate",
                    json={
                        "model": self.model,
                        "prompt": self.prompt,
                        "stream": True
                    },
                    stream=True,
                    timeout=300
                )
                for line in response.iter_lines():
                    if line:
                        try:
                            data = json.loads(line)
                            if 'response' in data:
                                self.streaming.emit(data['response'])
                        except json.JSONDecodeError:
                            continue
                self.finished.emit("")
            else:
                # 普通输出
                response = requests.post(
                    f"{self.api_url}/api/generate",
                    json={
                        "model": self.model,
                        "prompt": self.prompt,
                        "stream": False
                    },
                    timeout=300
                )
                if response.status_code == 200:
                    result = response.json()
                    self.finished.emit(result.get('response', ''))
                else:
                    self.error.emit(f"Ollama错误: HTTP {response.status_code}")
        except Exception as e:
            self.error.emit(f"调用失败: {str(e)}")
class FetchModelsWorker(QThread):
    """获取Ollama模型列表的线程"""
    finished = pyqtSignal(list)  # models list
    error = pyqtSignal(str)  # error message
    def __init__(self, api_url: str):
        super().__init__()
        self.api_url = api_url
    def run(self):
        try:
            response = requests.get(f"{self.api_url}/api/tags", timeout=10)
            if response.status_code == 200:
                result = response.json()
                models = [m['name'] for m in result.get('models', [])]
                self.finished.emit(models)
            else:
                self.error.emit(f"获取模型列表失败: HTTP {response.status_code}")
        except Exception as e:
            self.error.emit(f"连接失败: {str(e)}")
class LoadModelWorker(QThread):
    """加载Qwen3-ASR模型的线程"""
    finished = pyqtSignal(object)  # model object
    error = pyqtSignal(str)  # error message
    progress = pyqtSignal(str)  # progress message
    def __init__(self, model_path: str, device_map: str, dtype: str):
        super().__init__()
        self.model_path = model_path
        self.device_map = device_map
        self.dtype = dtype
    def run(self):
        try:
            self.progress.emit("正在加载Qwen3-ASR模型...")
            self.progress.emit("这可能需要几分钟时间,请耐心等待...")
            # 加载模型
            # CPU使用float32,GPU使用float16
            dtype_map = {
                'float16': torch.float16,
                'bfloat16': torch.bfloat16,
                'float32': torch.float32
            }
            dtype = dtype_map.get(self.dtype, torch.float32)
            model = Qwen3ASRModel.from_pretrained(
                self.model_path,
                dtype=dtype,
                device_map=self.device_map,
                max_inference_batch_size=32,
                max_new_tokens=256,
            )
            self.progress.emit("模型加载完成!")
            self.finished.emit(model)
        except Exception as e:
            import traceback
            error_details = traceback.format_exc()
            print(f"模型加载错误详情:\n{error_details}")
            self.error.emit(f"模型加载失败: {str(e)}")
# ===================== 主窗口 =====================
class ASROllamaApp(QMainWindow):
    def __init__(self):
        super().__init__()
        self.current_audio_path = None
        self.asr_model = None
        self.asr_worker = None
        self.ollama_worker = None
        self.fetch_models_worker = None
        self.load_model_worker = None
        self.ollama_api_url = OLLAMA_API_URL
        self.init_ui()
        self.load_asr_model()
    def init_ui(self):
        """初始化UI界面"""
        self.setWindowTitle("Qwen3-ASR + Ollama 智能助手(本地模式)")
        self.setGeometry(100, 100, 1200, 800)
        # 主布局
        main_widget = QWidget()
        self.setCentralWidget(main_widget)
        main_layout = QHBoxLayout(main_widget)
        # 使用分割器
        splitter = QSplitter(Qt.Horizontal)
        main_layout.addWidget(splitter)
        # 左侧面板
        left_panel = self.create_left_panel()
        splitter.addWidget(left_panel)
        # 右侧面板
        right_panel = self.create_right_panel()
        splitter.addWidget(right_panel)
        # 设置分割器比例
        splitter.setStretchFactor(0, 1)
        splitter.setStretchFactor(1, 2)
        # 设置样式
        self.setStyleSheet("""
            QMainWindow {
                background-color: #f0f0f0;
            }
            QGroupBox {
                font-weight: bold;
                border: 2px solid #cccccc;
                border-radius: 5px;
                margin-top: 10px;
                padding-top: 10px;
            }
            QGroupBox::title {
                subcontrol-origin: margin;
                left: 10px;
                padding: 0 3px;
            }
            QPushButton {
                background-color: #4CAF50;
                color: white;
                border: none;
                padding: 8px 16px;
                border-radius: 4px;
                font-weight: bold;
            }
            QPushButton:hover {
                background-color: #45a049;
            }
            QPushButton:pressed {
                background-color: #3d8b40;
            }
            QPushButton:disabled {
                background-color: #cccccc;
            }
            QTextEdit {
                border: 2px solid #cccccc;
                border-radius: 5px;
                padding: 5px;
                background-color: white;
            }
            QComboBox {
                border: 2px solid #cccccc;
                border-radius: 4px;
                padding: 5px;
                background-color: white;
            }
            QLabel {
                font-size: 12px;
                color: #333333;
            }
            QProgressBar {
                border: 2px solid #cccccc;
                border-radius: 5px;
                text-align: center;
            }
            QProgressBar::chunk {
                background-color: #4CAF50;
            }
        """)
    def create_left_panel(self) -> QWidget:
        """创建左侧控制面板"""
        panel = QWidget()
        layout = QVBoxLayout(panel)
        # 1. 模型状态
        model_group = QGroupBox("[ASR] ASR模型状态")
        model_layout = QVBoxLayout()
        self.model_status_label = QLabel("模型未加载")
        self.model_status_label.setStyleSheet("color: #ff9800; font-weight: bold; font-size: 14px;")
        model_layout.addWidget(self.model_status_label)
        self.load_model_btn = QPushButton("[刷新] 重新加载模型")
        self.load_model_btn.clicked.connect(self.load_asr_model)
        model_layout.addWidget(self.load_model_btn)
        model_group.setLayout(model_layout)
        layout.addWidget(model_group)
        # 2. 音频文件选择
        audio_group = QGroupBox("[音频] 音频文件")
        audio_layout = QVBoxLayout()
        self.audio_path_label = QLabel("未选择文件")
        self.audio_path_label.setStyleSheet("color: #666666; font-style: italic;")
        self.audio_path_label.setWordWrap(True)
        audio_layout.addWidget(self.audio_path_label)
        select_audio_btn = QPushButton("选择音频文件")
        select_audio_btn.clicked.connect(self.select_audio_file)
        audio_layout.addWidget(select_audio_btn)
        audio_group.setLayout(audio_layout)
        layout.addWidget(audio_group)
        # 3. ASR设置
        asr_group = QGroupBox("[识别] 语音识别设置")
        asr_layout = QFormLayout()
        self.language_combo = QComboBox()
        self.language_combo.addItem("自动检测", None)
        self.language_combo.addItem("中文", "Chinese")
        self.language_combo.addItem("英语", "English")
        self.language_combo.addItem("粤语", "Cantonese")
        self.language_combo.addItem("日语", "Japanese")
        self.language_combo.addItem("韩语", "Korean")
        self.language_combo.addItem("法语", "French")
        self.language_combo.addItem("德语", "German")
        self.language_combo.addItem("西班牙语", "Spanish")
        asr_layout.addRow("语言:", self.language_combo)
        self.asr_progress = QProgressBar()
        self.asr_progress.setVisible(False)
        asr_layout.addRow("进度:", self.asr_progress)
        asr_group.setLayout(asr_layout)
        layout.addWidget(asr_group)
        # 4. 识别按钮
        recognize_btn = QPushButton("[识别] 开始识别")
        recognize_btn.setStyleSheet("""
            QPushButton {
                background-color: #2196F3;
                color: white;
                font-size: 14px;
                padding: 12px;
            }
            QPushButton:hover {
                background-color: #1976D2;
            }
            QPushButton:disabled {
                background-color: #cccccc;
            }
        """)
        recognize_btn.clicked.connect(self.start_asr)
        recognize_btn.setEnabled(False)  # 模型未加载时禁用
        self.recognize_btn = recognize_btn
        layout.addWidget(recognize_btn)
        # 5. Ollama模型选择
        ollama_group = QGroupBox("[ASR] Ollama模型设置")
        ollama_layout = QVBoxLayout()
        self.refresh_models_btn = QPushButton("[刷新] 刷新模型列表")
        self.refresh_models_btn.clicked.connect(self.refresh_models)
        ollama_layout.addWidget(self.refresh_models_btn)
        ollama_layout.addWidget(QLabel("选择模型:"))
        self.model_combo = QComboBox()
        self.model_combo.setEnabled(False)
        ollama_layout.addWidget(self.model_combo)
        ollama_group.setLayout(ollama_layout)
        layout.addWidget(ollama_group)
        # 6. 提示词设置
        prompt_group = QGroupBox("[提示] 提示词设置")
        prompt_layout = QVBoxLayout()
        prompt_layout.addWidget(QLabel("系统提示词:"))
        self.system_prompt = QTextEdit()
        self.system_prompt.setPlaceholderText("输入系统提示词...")
        self.system_prompt.setMaximumHeight(100)
        self.system_prompt.setText("你是一个专业的文本处理助手。请根据用户提供的文本内容,提供准确、有用的回复。")
        prompt_layout.addWidget(self.system_prompt)
        self.use_recognized_text = QCheckBox("将识别文本自动添加到提示词")
        self.use_recognized_text.setChecked(True)
        prompt_layout.addWidget(self.use_recognized_text)
        prompt_group.setLayout(prompt_layout)
        layout.addWidget(prompt_group)
        # 7. 生成按钮
        generate_btn = QPushButton("[生成] 调用Ollama生成回复")
        generate_btn.setStyleSheet("""
            QPushButton {
                background-color: #FF9800;
                color: white;
                font-size: 14px;
                padding: 12px;
            }
            QPushButton:hover {
                background-color: #F57C00;
            }
        """)
        generate_btn.clicked.connect(self.call_ollama)
        layout.addWidget(generate_btn)
        # 8. 流式输出选项
        self.streaming_checkbox = QCheckBox("启用流式输出(实时显示)")
        self.streaming_checkbox.setChecked(True)
        layout.addWidget(self.streaming_checkbox)
        layout.addStretch()
        return panel
    def create_right_panel(self) -> QWidget:
        """创建右侧结果面板"""
        panel = QWidget()
        layout = QVBoxLayout(panel)
        # 创建滚动区域
        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll_content = QWidget()
        scroll_layout = QVBoxLayout(scroll_content)
        # 1. 识别结果
        asr_result_group = QGroupBox("[结果] 语音识别结果")
        asr_result_layout = QVBoxLayout()
        self.asr_result_text = QTextEdit()
        self.asr_result_text.setPlaceholderText("识别结果将显示在这里...")
        self.asr_result_text.setReadOnly(True)
        asr_result_layout.addWidget(self.asr_result_text)
        self.detected_language_label = QLabel()
        self.detected_language_label.setStyleSheet("color: #666666; font-size: 11px;")
        asr_result_layout.addWidget(self.detected_language_label)
        asr_result_group.setLayout(asr_result_layout)
        scroll_layout.addWidget(asr_result_group)
        # 2. Ollama生成结果
        ollama_result_group = QGroupBox("[回复] Ollama生成结果")
        ollama_result_layout = QVBoxLayout()
        self.ollama_result_text = QTextEdit()
        self.ollama_result_text.setPlaceholderText("Ollama的回复将显示在这里...")
        self.ollama_result_text.setReadOnly(True)
        self.ollama_result_text.setStyleSheet("""
            QTextEdit {
                background-color: #fffde7;
            }
        """)
        ollama_result_layout.addWidget(self.ollama_result_text)
        ollama_result_group.setLayout(ollama_result_layout)
        scroll_layout.addWidget(ollama_result_group)
        # 添加状态栏
        self.status_label = QLabel("正在加载模型...")
        self.status_label.setStyleSheet("""
            QLabel {
                background-color: #ff9800;
                color: white;
                padding: 5px;
                border-radius: 3px;
            }
        """)
        scroll_layout.addWidget(self.status_label)
        scroll_layout.addStretch()
        scroll.setWidget(scroll_content)
        layout.addWidget(scroll)
        return panel
    def load_asr_model(self):
        """加载Qwen3-ASR模型"""
        # 禁用相关按钮
        self.load_model_btn.setEnabled(False)
        self.recognize_btn.setEnabled(False)
        self.model_status_label.setText("正在加载模型...")
        self.model_status_label.setStyleSheet("color: #ff9800; font-weight: bold;")
        # 确定设备和数据类型
        device_map = "cuda:0" if torch.cuda.is_available() else "cpu"
        # CPU使用float32,GPU使用float16
        dtype = "float32" if not torch.cuda.is_available() else "float16"
        # 创建加载线程
        self.load_model_worker = LoadModelWorker(
            MODEL_PATH,
            device_map,
            dtype
        )
        self.load_model_worker.progress.connect(self.on_model_progress)
        self.load_model_worker.finished.connect(self.on_model_loaded)
        self.load_model_worker.error.connect(self.on_model_error)
        self.load_model_worker.start()
    def on_model_progress(self, message: str):
        """模型加载进度"""
        self.model_status_label.setText(message)
        self.status_label.setText(message)
    def on_model_loaded(self, model):
        """模型加载完成"""
        self.asr_model = model
        self.load_model_btn.setEnabled(True)
        self.recognize_btn.setEnabled(True)
        self.model_status_label.setText("[OK] 模型已加载")
        self.model_status_label.setStyleSheet("color: #4CAF50; font-weight: bold;")
        self.update_status("模型加载完成!")
        print("Qwen3-ASR模型加载成功")
    def on_model_error(self, error_msg: str):
        """模型加载错误"""
        self.load_model_btn.setEnabled(True)
        self.recognize_btn.setEnabled(False)
        self.model_status_label.setText("[X] 模型加载失败")
        self.model_status_label.setStyleSheet("color: #f44336; font-weight: bold;")
        self.status_label.setStyleSheet("""
            QLabel {
                background-color: #f44336;
                color: white;
                padding: 5px;
                border-radius: 3px;
            }
        """)
        self.update_status("模型加载失败")
        QMessageBox.critical(self, "模型加载失败", f"无法加载Qwen3-ASR模型:\n\n{error_msg}\n\n请确保:\n1. 模型文件存在于正确位置\n2. 有足够的内存\n3. 已安装正确的依赖")
    def select_audio_file(self):
        """选择音频文件"""
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "选择音频文件",
            "",
            "音频文件 (*.wav *.mp3 *.ogg *.flac *.m4a *.aac);;所有文件 (*.*)"
        )
        if file_path:
            self.current_audio_path = file_path
            self.audio_path_label.setText(f"[OK] {os.path.basename(file_path)}")
            self.audio_path_label.setStyleSheet("color: #4CAF50; font-weight: bold;")
            self.update_status(f"已选择: {os.path.basename(file_path)}")
    def start_asr(self):
        """开始语音识别"""
        if self.asr_model is None:
            QMessageBox.warning(self, "警告", "模型未加载,请先加载模型!")
            return
        if not self.current_audio_path:
            QMessageBox.warning(self, "警告", "请先选择音频文件!")
            return
        # 禁用按钮
        self.set_ui_enabled(False)
        # 显示进度条
        self.asr_progress.setVisible(True)
        self.asr_progress.setRange(0, 0)  # 不确定进度
        # 清理旧的 worker
        if self.asr_worker is not None:
            if hasattr(self.asr_worker, 'deleteLater'):
                self.asr_worker.deleteLater()
            self.asr_worker = None
        # 创建并启动工作线程
        try:
            language = self.language_combo.currentData()
            self.asr_worker = ASRWorker(
                self.current_audio_path,
                self.asr_model,
                language
            )
            # 验证 worker 对象
            if not isinstance(self.asr_worker, ASRWorker):
                raise TypeError(f"Failed to create ASRWorker, got {type(self.asr_worker)}")
            # 连接信号
            self.asr_worker.progress.connect(self.on_asr_progress)
            self.asr_worker.finished.connect(self.on_asr_finished)
            self.asr_worker.error.connect(self.on_asr_error)
            self.asr_worker.start()
        except Exception as e:
            self.asr_worker = None
            self.asr_progress.setVisible(False)
            self.set_ui_enabled(True)
            QMessageBox.critical(self, "错误", f"创建ASR工作线程失败:{str(e)}")
            self.update_status("识别失败")
    def on_asr_progress(self, message: str):
        """ASR进度更新"""
        self.update_status(message)
    def on_asr_finished(self, success: bool, text: str, language: str):
        """ASR完成"""
        self.asr_progress.setVisible(False)
        self.set_ui_enabled(True)
        if success:
            self.asr_result_text.setText(text)
            self.detected_language_label.setText(f"检测语言: {language}")
            self.update_status("识别完成!")
        else:
            self.asr_result_text.clear()
            self.detected_language_label.clear()
    def on_asr_error(self, error_msg: str):
        """ASR错误"""
        self.asr_progress.setVisible(False)
        self.set_ui_enabled(True)
        QMessageBox.critical(self, "识别失败", error_msg)
        self.update_status("识别失败")
    def check_ollama_connection(self):
        """检查Ollama连接"""
        self.update_status("正在检查Ollama连接...")
        # 清理旧的 worker
        if self.fetch_models_worker is not None:
            if hasattr(self.fetch_models_worker, 'deleteLater'):
                self.fetch_models_worker.deleteLater()
            self.fetch_models_worker = None
        # 创建并启动工作线程
        try:
            self.fetch_models_worker = FetchModelsWorker(self.ollama_api_url)
            # 验证 worker 对象
            if not isinstance(self.fetch_models_worker, FetchModelsWorker):
                raise TypeError(f"Failed to create FetchModelsWorker, got {type(self.fetch_models_worker)}")
            # 连接信号
            self.fetch_models_worker.finished.connect(self.on_models_fetched)
            self.fetch_models_worker.error.connect(self.on_models_error)
            self.fetch_models_worker.start()
        except Exception as e:
            self.fetch_models_worker = None
            QMessageBox.critical(self, "错误", f"创建检查连接线程失败:{str(e)}")
            self.update_status("检查失败")
    def refresh_models(self):
        """刷新模型列表"""
        self.model_combo.clear()
        self.model_combo.addItem("正在加载...", None)
        self.model_combo.setEnabled(False)
        # 清理旧的 worker
        if self.fetch_models_worker is not None:
            if hasattr(self.fetch_models_worker, 'deleteLater'):
                self.fetch_models_worker.deleteLater()
            self.fetch_models_worker = None
        # 创建并启动工作线程
        try:
            self.fetch_models_worker = FetchModelsWorker(self.ollama_api_url)
            # 验证 worker 对象
            if not isinstance(self.fetch_models_worker, FetchModelsWorker):
                raise TypeError(f"Failed to create FetchModelsWorker, got {type(self.fetch_models_worker)}")
            # 连接信号
            self.fetch_models_worker.finished.connect(self.on_models_fetched)
            self.fetch_models_worker.error.connect(self.on_models_error)
            self.fetch_models_worker.start()
            self.update_status("正在刷新模型列表...")
        except Exception as e:
            self.fetch_models_worker = None
            self.model_combo.clear()
            self.model_combo.addItem("加载失败", None)
            QMessageBox.critical(self, "错误", f"创建模型列表线程失败:{str(e)}")
            self.update_status("加载失败")
    def on_models_fetched(self, models: List[str]):
        """模型列表获取完成"""
        self.model_combo.clear()
        if not models:
            self.model_combo.addItem("未找到模型", None)
            self.update_status("未找到Ollama模型")
        else:
            for model in models:
                self.model_combo.addItem(model, model)
            self.model_combo.setEnabled(True)
            self.update_status(f"已加载 {len(models)} 个模型")
    def on_models_error(self, error_msg: str):
        """模型列表获取错误"""
        self.model_combo.clear()
        self.model_combo.addItem("连接失败", None)
        self.model_combo.setEnabled(False)
        self.update_status("Ollama连接失败")
        QMessageBox.warning(self, "连接失败", f"无法连接到Ollama: {error_msg}\n请确保Ollama服务正在运行。")
    def call_ollama(self):
        """调用Ollama生成回复"""
        # 检查是否选择了模型
        model_name = self.model_combo.currentData()
        if model_name is None or not isinstance(model_name, str):
            QMessageBox.warning(self, "警告", "请先选择Ollama模型!")
            return
        # 检查是否有识别文本或提示词
        recognized_text = self.asr_result_text.toPlainText()
        system_prompt = self.system_prompt.toPlainText()
        if not recognized_text and not system_prompt:
            QMessageBox.warning(self, "警告", "请先进行语音识别或输入提示词!")
            return
        # 构建完整提示词
        if self.use_recognized_text.isChecked() and recognized_text:
            full_prompt = f"识别的文本内容:\n{recognized_text}\n\n{system_prompt}"
        else:
            full_prompt = system_prompt
        # 清空之前的输出
        self.ollama_result_text.clear()
        self.set_ui_enabled(False)
        self.update_status("正在调用Ollama...")
        # 清理旧的 worker
        if self.ollama_worker is not None:
            if hasattr(self.ollama_worker, 'deleteLater'):
                self.ollama_worker.deleteLater()
            self.ollama_worker = None
        # 创建并启动工作线程
        try:
            self.ollama_worker = OllamaWorker(
                model_name,
                full_prompt,
                self.ollama_api_url,
                self.streaming_checkbox.isChecked()
            )
            # 验证 worker 对象
            if not isinstance(self.ollama_worker, OllamaWorker):
                raise TypeError(f"Failed to create OllamaWorker, got {type(self.ollama_worker)}")
            # 连接信号
            self.ollama_worker.finished.connect(self.on_ollama_finished)
            self.ollama_worker.error.connect(self.on_ollama_error)
            self.ollama_worker.streaming.connect(self.on_ollama_streaming)
            self.ollama_worker.start()
        except Exception as e:
            self.ollama_worker = None
            self.set_ui_enabled(True)
            QMessageBox.critical(self, "错误", f"创建Ollama工作线程失败:{str(e)}")
            self.update_status("调用失败")
    def on_ollama_finished(self, response: str):
        """Ollama完成"""
        self.set_ui_enabled(True)
        self.update_status("生成完成!")
        if response:
            self.ollama_result_text.append(response)
    def on_ollama_error(self, error_msg: str):
        """Ollama错误"""
        self.set_ui_enabled(True)
        QMessageBox.critical(self, "调用失败", error_msg)
        self.update_status("调用失败")
    def on_ollama_streaming(self, text: str):
        """Ollama流式输出"""
        self.ollama_result_text.moveCursor(QTextCursor.End)
        self.ollama_result_text.insertPlainText(text)
        self.ollama_result_text.ensureCursorVisible()
    def set_ui_enabled(self, enabled: bool):
        """设置UI可用状态"""
        for child in self.findChildren(QPushButton):
            child.setEnabled(enabled)
        self.language_combo.setEnabled(enabled)
        self.model_combo.setEnabled(enabled and self.model_combo.count() > 1)
    def update_status(self, message: str):
        """更新状态栏"""
        self.status_label.setText(message)
    def closeEvent(self, event):
        """窗口关闭事件"""
        # 等待所有线程结束
        if self.asr_worker and self.asr_worker.isRunning():
            self.asr_worker.terminate()
            self.asr_worker.wait()
        if self.ollama_worker and self.ollama_worker.isRunning():
            self.ollama_worker.terminate()
            self.ollama_worker.wait()
        if self.fetch_models_worker and self.fetch_models_worker.isRunning():
            self.fetch_models_worker.terminate()
            self.fetch_models_worker.wait()
        if self.load_model_worker and self.load_model_worker.isRunning():
            self.load_model_worker.terminate()
            self.load_model_worker.wait()
        event.accept()
# ===================== 主程序入口 =====================
def main():
    app = QApplication(sys.argv)
    # 设置应用信息
    app.setApplicationName("Qwen3-ASR + Ollama")
    app.setOrganizationName("ASR-Ollama")
    # 创建并显示主窗口
    window = ASROllamaApp()
    window.show()
    sys.exit(app.exec_())
if __name__ == "__main__":
    main()

这段代码演示了如何通过requests的stream=True实现流式读取,并逐行解析JSON。在实际测试中,响应速度取决于本地GPU算力,但即使使用CPU也能获得可接受的体验。

在开发过程中,我深刻体会到线程安全的重要性——当多个工作线程同时更新UI时,必须使用信号槽或锁机制。PyQt5的QMetaObject.invokeMethodpyqtSignal都是可靠的选择。

总结与展望

本文详细介绍了如何基于Python与PyQt5构建一款集语音识别与本地大模型交互的桌面应用。通过Qwen3-ASR实现多语言转写,结合Ollama的流式输出,打造了完全本地化的AI助手工具。核心价值在于隐私保护易用性,同时提供了清晰的线程模型与GUI布局,适合作为学习或生产参考。

未来,随着模型轻量化技术(如量化)的成熟,7B模型甚至可以在消费级CPU上流畅运行。如果你正在探索语音与LLM的结合,不妨从本文的架构出发,逐步添加更多创新功能。

最后,欢迎在评论区分享你的改进思路——无论是优化性能还是增加新特性,技术交流总能碰撞出火花!