简介:这套基于PySide6开发的校园广播播放系统,以完整源代码形式呈现,主要面向校园广播管理员、运维人员及Python GUI应用开发者。系统具备定时播放、自定义铃声、一键切换阴雨天与调休模式、批量修改与导入导出铃声等功能,可满足课间铃声、活动通知、考试提醒等自动化广播场景。资源包共35个文件,压缩包约110KB,主要内容包括10个Python脚本、PySide6界面定义文件、7套QSS主题样式、数据库文件及图标素材,代码与资源分离的目录结构方便直接运行和二次开发。目前已有722人学习下载。通过研读源码,可以掌握PySide6的窗口布局设计、自定义滑动控件实现、定时任务后台调度、配置文件与数据库读写等核心技巧;同时,多样化的主题样式和日志记录模块,也为构建规范、可维护的GUI项目提供了良好范例。
1. 校园广播的定时播放场景与 PySide6 方案选型
校园广播和普通音乐播放器最大的区别,在于它必须“到了时间自己响,播完自己停”。上课铃、午间音乐、眼保健操、考试指令分别挂在早中晚的不同时刻,有时还要考虑周末不播、雨天不播。如果让老师手动开电脑、手动点开始,迟播一分钟教学秩序就乱一下。用 Python 做这类系统时,PySide6 是性价比很高的选型:QMediaPlayer 管播放,QTimer 或 APScheduler 管调度,Qt 信号槽把播放状态安全传回界面,整个项目运行在一台 Windows 办公电脑上就能无人值守。下面从播放核心开始,逐步拆到怎么落地部署。
2. 用 QMediaPlayer 与 QAudioOutput 搭起播放核心
2.1 为什么选 PySide6 而不是 PyQt5 或 pygame
PySide6 是 Qt 官方的 Python 绑定,PyQt5 是 Riverbank 维护的绑定,两者 API 相似但许可证不同。PySide6 采用 LGPL,闭源交付学校使用时不需要购买商业授权,PyQt5 的 GPL 条款对不愿开源的分发更麻烦。另一个实际差异是维护节奏:PySide6 跟随 Qt 的年度发布周期,新协议、多媒体模块的更新通常先出现在这边。
功能上选择 PySide6 的关键是 QtMultimedia。QMediaPlayer 可以直接吃本地文件流,不会把整首 WAV/MP3 读进内存;QAudioOutput 负责音量与输出设备选择。这类组件和 QTimer、QTableWidget 同属一套事件循环,播放、定时、界面更新不必互相开线程。如果换成 pygame 或 pyaudio 播放,输出设备和界面刷新是两套时钟,做播放进度条和状态回显就要自己维护同步。
安装只需一条命令:pip install PySide6,要求 Python 3.9 以上。公司内网部署时,提前用 pip download 拉好 wheel 包离线安装,比在学校电脑上现场编译省事得多。
2.2 播放器基类的最小代码
# player.py from PySide6.QtCore import QObject, QUrl, Signal from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer class BroadcastPlayer(QObject): finished = Signal(str) # 播放结束,参数为文件路径 def __init__(self, parent=None): super().__init__(parent) self._audio = QAudioOutput(self) self._audio.setVolume(0.8) self._player = QMediaPlayer(self) self._player.setAudioOutput(self._audio) self._player.mediaStatusChanged.connect(self._on_media_status) def play_file(self, path: str): self.stop() self._player.setSource(QUrl.fromLocalFile(path)) self._player.play() def stop(self): self._player.stop() self._player.setSource(QUrl()) def set_volume(self, value: float): self._audio.setVolume(max(0.0, min(1.0, value))) def _on_media_status(self, status): if status == QMediaPlayer.MediaStatus.EndOfMedia: self.finished.emit(self._player.source().toLocalFile())重点看 play_file 里先调用 stop 再 setSource:手动点“播放”时,如果上一个文件还挂在播放器上,直接 setSource 会触发一次旧文件的结束信号,导致任务状态误判。stop 之后把 source 清成 QUrl(),再加载新路径,状态转换是干净的。
QAudioOutput 的 volume 范围是 0.0 到 1.0,UI 上的音量滑条如果按 0-100 显示,记得在 setValue 和 set_volume 之间做一次除法换算。setSource 只接受 QUrl,Windows 中文路径要交给 QUrl.fromLocalFile 处理,不要手拼 file:/// 前缀,否则带空格和中文的目录会解析失败。
2.3 状态信号、停止延迟与线程边界
QMediaPlayer 的状态通过 mediaStatusChanged 异步回传,枚举含义如下表,调试任务状态机时对照这个表会快很多。
| 状态枚举 | 触发时机 | 常见误读 |
|---|---|---|
| NoMedia | 未设置音频源 | 加载后立刻出现,不代表失败 |
| Loading | 正在解析文件头 | 对大文件明显,此时不要读 position |
| Buffered | 已缓冲到可播放 | 可安全获取 duration |
| EndOfMedia | 播放到文件末尾 | 停止也会触发,需区分 stop 与自然结束 |
| InvalidMedia | 文件不可用或编码不支持 | 多数是 MP3 解码器缺失 |
stop 之后当前文件还能听到一小段时间的尾音,这是声卡缓冲造成的,不是代码问题。连续切歌可以接受 50-100ms 的尾音重叠;如果要求严格,播放下一首前加一个 QTimer.singleShot(50, ...) 延迟即可。
另一个坑是线程边界。QMediaPlayer 底层有自己的拉流线程,但所有信号都回传到创建它的线程。不要在界面里开 python 多进程或多线程去调用 player.play_file,也不要在回调里用 time.sleep 阻塞主线程;阻塞主线程会让定时触发和界面刷新一起卡住。服务端背景里“起线程做事”的习惯到 Qt 里要反过来——事件循环本身就是调度器,阻塞才是敌人。
3. 定时调度:从 QTimer 到 APScheduler 的选型与落地
3.1 校园广播任务的两个难点
校园广播的排程表和普通闹钟有本质区别。第一,任务不按“N 秒后”排列,而是“每周一到周五的 8:00、9:40、14:20”这种基于日历的周期,周六日停播;这要求调度器理解星期字段。第二,任务时长不确定,午间 20 分钟的音乐由若干首曲目拼成,结束时间随播放内容浮动,不能简单用“开始时间 + 固定秒数”去计算。
QTimer 能解决“周期性回调”,但表达“每周一到周五、排除节假日”需要自己维护星期判断和下一个触发点计算,代码量不大但边界条件多。APScheduler 的 CronTrigger 原生支持 day_of_week、hour、minute 的组合,且调度器可以运行在独立线程中,不依赖 GUI 事件循环。
3.2 三种调度方案对比
| 方案 | 精度 | 适用场景 | 主要风险 |
|---|---|---|---|
| QTimer 每 1 秒轮询 | 秒级 | 单机、简化监控 | 主线程被界面操作阻塞时触发延迟 |
| QTimer + 提前计算下一次时间 | 秒级 | 固定铃声 | 跨天计算繁琐,节假日逻辑要手写 |
| APScheduler CronTrigger | 秒级 | 多点定时、复杂日历 | 任务回调在线程,不能直接碰 UI |
单纯做上课铃,QTimer 提前计算完全够用;一旦加入午间音乐循环、考试指令、周末不开机等条件,APScheduler 的 cron 字段能省掉一半的判断代码。下面的实现按 APScheduler 走,实际部署中也遇到问题最少。
3.3 把调度器封装成独立模块
APScheduler 的 BackgroundScheduler 默认跑在自己的线程里,回调不占用 Qt 事件循环。需要归位的跨线程状态通过 Qt 信号传回主线程。
# scheduler.py from PySide6.QtCore import QObject, Signal from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger class SchedulerBridge(QObject): play_requested = Signal(str) # UI 线程接收后执行播放 def __init__(self, parent=None): super().__init__(parent) self._scheduler = BackgroundScheduler(timezone="Asia/Shanghai") def start(self): self._scheduler.start() def shutdown(self): self._scheduler.shutdown(wait=False) def add_play_task(self, task_id, path, hour, minute, days): self._scheduler.add_job( self.play_requested.emit, CronTrigger(day_of_week=days, hour=hour, minute=minute), args=[path], id=task_id, max_instances=1, misfire_grace_time=60, replace_existing=True, )add_play_task 的 job 函数直接传 self.play_requested.emit,APScheduler 在后台线程触发信号,Qt 会自动使用队列连接把它投递到主线程事件循环,主线程里的播放器再执行 setSource 和 play。这样就绕过了“线程里调用 UI 对象”的崩溃问题。
参数按实际部署需求调整:misfire_grace_time 控制任务错过既定时间后的容忍窗口,学校电脑开机晚、系统休眠后恢复的场景下建议放到 60 秒,超过 60 秒的过期任务直接丢弃,避免下午开机补播早上铃。max_instances=1 防止同一任务上次还没播完又被触发。days 传 (0,1,2,3,4) 表示周一到周五,星期取值从 0 开始。
3.4 UI 侧如何订阅播放请求
SchedulerBridge 实例化后,与播放器、界面依次连接:
self.bridge = SchedulerBridge(self) self.bridge.play_requested.connect(self.player.play_file) self.bridge.play_requested.connect(self._mark_task_playing)对音频总是覆盖播放,连接顺序有意义。先 play_file 再 _mark_task_playing,界面状态列的颜色能在同一轮事件里更新。手动点击“立即播放”按钮时,也调用同样的信号而不是直接操作 player,保证触摸屏和自动化脚本的一致性。
开机自启时,先启动 UI 再调用 bridge.start()。BackgroundScheduler 的线程一旦 start 就会持续存活,应用退出前调用 shutdown(wait=False),否则部分环境会出现 “QThread: Destroyed while thread is still running” 的报错。
4. 任务配置表与界面状态同步的实现细节
4.1 用 JSON 承载任务配置
校园广播的配置人员通常是信息技术老师,不会直接改 Python 源码。把任务明细放 JSON,软件启动时读取,老师可以用记事本或在线编辑器维护。结构示例:
{ "timezone": "Asia/Shanghai", "volume": 0.8, "tasks": [ { "id": "bell_morning", "name": "上午上课铃", "path": "audio/bell_morning.wav", "hour": 8, "minute": 0, "days": [0, 1, 2, 3, 4], "loop": false }, { "id": "music_noon", "name": "午间音乐", "path": "audio/lunch_playlist/", "hour": 11, "minute": 55, "days": [0, 1, 2, 3, 4], "loop": true, "duration_minutes": 20 } ] }字段含义如下表所示,循环目录播放时要特别控制时长,否则会一直放到第二天。
| 字段 | 含义 | 注意 |
|---|---|---|
| days | 每周第几天 | 0 代表周一,6 代表周日 |
| path | 音频文件或目录 | 目录模式下 loop 建议为 true |
| loop | 是否循环播放 | 适用于目录 |
| duration_minutes | 最长播放时长 | 到时强制停止,避免超时 |
4.2 任务表的表格渲染与状态回写
我一般采用 QTableWidget 做任务表,渲染写成一个函数进行逐行写入:
# main_window.py 片段 from PySide6.QtCore import Qt from PySide6.QtWidgets import QTableWidgetItem def refresh_task_table(self, tasks): self.task_map = {task["id"]: task for task in tasks} self.table.setRowCount(len(tasks)) self.table.setHorizontalHeaderLabels( ["任务ID", "名称", "时间", "音频", "状态"] ) for row, task in enumerate(tasks): self.table.setItem(row, 0, QTableWidgetItem(task["id"])) self.table.setItem(row, 1, QTableWidgetItem(task["name"])) self.table.setItem( row, 2, QTableWidgetItem(f"{task['hour']:02d}:{task['minute']:02d}") ) self.table.setItem(row, 3, QTableWidgetItem(task["path"])) status_item = QTableWidgetItem("等待") status_item.setData(Qt.UserRole, task["id"]) self.table.setItem(row, 4, status_item) self.table.resizeColumnsToContents()Qt.UserRole 就是数值 256,这里的用户数据存的是任务 id,后续更新状态时通过它定位行。特别注意 QTableWidget 的 cellChanged 信号:setItem 会触发 cellChanged,如果信号里又去刷新整个表格可能造成递归。所以只在启动时一次性渲染,或者维护内部数据副本后只更新变化行。
4.3 状态列同步与防递归处理
当播放状态发生变化时调用 update_status:
from PySide6.QtGui import QColor def update_status(self, task_id: str, status: str, color: str = "#ffffff"): self.table.blockSignals(True) for row in range(self.table.rowCount()): item = self.table.item(row, 0) if item is not None and item.text() == task_id: status_item = self.table.item(row, 4) status_item.setText(status) status_item.setBackground(QColor(color)) break self.table.blockSignals(False)把 blockSignals(True) 放在更新前、blockSignals(False) 放在更新后,保证这里引发的 itemChanged/cellChanged 不会再次触发外层逻辑。状态颜色按约定处理:等待白色、播放中绿色、结束灰色、失败红色。这个约定同时输出到日志,方便事后翻查任务执行情况。
4.4 无 Designer 环境下的界面组织与交互
PySide6 安装后自带 designer,但需要到安装目录运行 pyside6-designer 命令。不少开发者会遇到“pyside6没有designer”的情况,大概率是命令行工具没进 PATH。如果不想开 .ui 文件,纯代码组织界面也足够:用一个 build_ui() 函数集中创建控件,再用 QVBoxLayout/QHBoxLayout 组装。控件对象持有引用后,后续所有更新都直接操作字段。
手动播放选中任务的交互用 currentRow 即可,不需要 selectedItems:
def manual_play(self): row = self.table.currentRow() if row < 0: return task_id = self.table.item(row, 0).text() task = self.task_map[task_id] # bridge 是外部传入的 SchedulerBridge 实例 self.bridge.play_requested.emit(task["path"])currentRow 在没有选中时返回 -1,用返回保护避免索引出错。手动播放也走 play_requested,定时触发和手动触发共享同一套状态更新路径。文件路径是相对路径时,使用 Path(file).resolve().parent 作为基准拼接,避免从其他目录启动时找不到音频。
5. 打包、精度验证与 Linux 部署的三个常见坑
5.1 PyInstaller 打包与音频资源分离
PyInstaller 推荐用目录模式而不是单文件模式。单文件 -F 会在启动时把全部文件解到临时目录,音频文件一旦外置就需要运行时重新拼路径,热更新不好做。
pyinstaller -w -D --name school_broadcast main.py把生成的 dist/school_broadcast 目录整体拷到学校电脑,audio 文件夹放在程序同级。启动路径不要依赖 sys.argv[0],PyInstaller 打包后 argv[0] 指向临时解压目录,用 sys.executable 的父目录或者固定相对路径读取配置文件更稳妥。
5.2 定时精度验证方法
上线第一周每天检查一次日志:记录每次触发的系统时间戳与任务计划时间,做差值统计。如果长期偏差超过 2 秒,优先排查系统休眠和电源计划,而不是调度代码。快速验证时把任务的 hour/minute 改成当前时间加 2 分钟,触发后看播放状态列是否正确切换,确认后改回真实时间。
5.3 Linux 下 MP3 解码与无头运行
校园环境偶尔会把服务跑在 Linux 上,PySide6 依赖系统 gstreamer 解码库。只装 Qt 不装 gstreamer 插件,常有 MP3 无法播放的问题,需要安装 gstreamer1.0-plugins-good、gstreamer1.0-plugins-bad、gstreamer1.0-plugins-ugly 对应包。管理机上没有显示器时,需要设置 QT_QPA_PLATFORM=offscreen 环境变量再启动,否则 QMediaPlayer 初始化会报错。
资源替换的建议:铃声和广播内容尽量准备一份 WAV 版本,WAV PCM 是 Qt 内置格式,不依赖外部解码器,Linux 和 Windows 表现一致。MP3 适用于磁盘紧张的场景,但换机部署时优先验证解码器。
检查日志里每次触发的系统时间与计划时间的差值,如果长期超过 2 秒,优先去查这台机器是否开启了系统休眠,而不是去改调度代码。
本文还有配套的精品资源,点击获取