Skip to content

Commit d496bf5

Browse files
committed
- feat(speak_number): 创建了灵活的形容词功能
- perf(lottery): 跨平台支持到Mac - perf(lottery): 使用Pyside2的原生模块替换winsound以增强稳定性 - perf(park): 移除打包winsound减少文件大小 - fix(TrayIcon): 增加了对于mac的icns图标
1 parent 0b2897d commit d496bf5

4 files changed

Lines changed: 133 additions & 49 deletions

File tree

assets/icon.icns

41 KB
Binary file not shown.

classroom_lottery.spec

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@ a_main = Analysis(
7676
'pyttsx3.drivers.espeak',
7777
'pyttsx3.drivers.nsss',
7878
'pyttsx3.drivers.sapi5',
79-
'winsound',
8079
'keyboard',
8180
'configparser',
8281
'json',

launcher.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,17 @@ def init_ui(self):
138138
voice_layout.addWidget(self.voice_template_edit)
139139
config_layout.addLayout(voice_layout)
140140

141+
# 灵活地叫号设置
142+
self.dynamic_voice_layout = QCheckBox("启用灵活的形容词")
143+
enable_voice = self.config.get('lottery', 'dynamic_voice_layout', fallback='1')
144+
self.dynamic_voice_layout.setChecked(enable_voice == '1')
145+
voice_layout.addWidget(self.dynamic_voice_layout)
146+
141147
# 语音设置区域
142148
voice_setting_layout = QHBoxLayout()
143149
voice_setting_layout.addWidget(QLabel("语速:"), 1)
144150
self.voice_rate_edit = QLineEdit()
145-
self.voice_rate_edit.setText(self.config.get('lottery', 'voice_rate', fallback='200'))
151+
self.voice_rate_edit.setText(self.config.get('lottery', 'voice_rate', fallback='150'))
146152
self.voice_rate_edit.setFixedWidth(100)
147153
voice_setting_layout.addWidget(self.voice_rate_edit)
148154
config_layout.addLayout(voice_setting_layout)
@@ -180,6 +186,7 @@ def init_ui(self):
180186

181187
# 添加学生信息显示标签
182188
self.student_info_label = QLabel("未加载学生名单")
189+
self.check_student_list()
183190
student_layout.addWidget(self.student_info_label)
184191

185192
note_label = QLabel("注意: CSV文件应包含'学号','姓名'列,Excel文件第一列为学号,第二列为姓名")
@@ -232,7 +239,8 @@ def save_config(self):
232239
self.config.set('lottery', 'voice_rate', self.voice_rate_edit.text())
233240
self.config.set('lottery', 'voice_volume', self.voice_volume_edit.text())
234241
self.config.set('lottery', 'voice_id', self.voice_combo.currentData() or '')
235-
242+
self.config.set('lottery', 'dynamic_voice', '1'
243+
if self.dynamic_voice_layout.isChecked() else '0')
236244
# 写入文件
237245
with open(self.config_file, 'w', encoding='utf-8') as configfile:
238246
self.config.write(configfile)

main.py

Lines changed: 123 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"""
33
课堂号数抽取程序(PySide2重构版)- 解决线程安全问题
44
"""
5+
import random
56
import sys
67
import json
78
import pickle
@@ -15,8 +16,9 @@
1516
import numpy as np
1617
import pyttsx3
1718

18-
from winsound import SND_ASYNC, SND_FILENAME, PlaySound
19-
import keyboard
19+
from PySide2.QtMultimedia import QSoundEffect
20+
from PySide2.QtCore import QUrl, QCoreApplication
21+
from pynput import keyboard
2022
from PySide2.QtWidgets import (QApplication, QDialog, QLabel, QVBoxLayout,
2123
QSystemTrayIcon, QMenu, QAction, QMessageBox)
2224
from PySide2.QtCore import Qt, QTimer, Signal, QObject
@@ -37,20 +39,29 @@ def flush(self):
3739
# ==================== 全局配置 ====================
3840
ICON_FILE = 'assets/icon.ico'
3941
SOUND_FILE = 'assets/rise_enable.wav'
42+
classroom_adjectives = [
43+
"聪明的",
44+
"勤奋的",
45+
"积极的",
46+
"认真的",
47+
"用心的",
48+
"细心的",
49+
"专注的",
50+
"机智的",
51+
"灵巧的",
52+
"灵敏的",
53+
"主动的",
54+
"勤劳的",
55+
"刻苦的",
56+
"虚心的",
57+
"友善的",
58+
"有趣的",
59+
"创新的"
60+
]
4061

4162
# 配置文件读取
4263
config = ConfigParser()
4364
config.read('config.ini', encoding='utf-8')
44-
MIN_NUMBER = config.getint('lottery', 'min_number', fallback=1)
45-
MAX_NUMBER = config.getint('lottery', 'max_number', fallback=48)
46-
DELAY = config.getint('lottery', 'delay', fallback=3)
47-
KEEP = config.getint('lottery', 'keep', fallback=3)
48-
STUDENT_MODE = config.getint('lottery', 'student_mode', fallback=0)
49-
ENABLE_VOICE = config.getint('lottery', 'enable_voice', fallback=1)
50-
VOICE_TEMPLATE = config.get('lottery', 'voice_template', fallback='请{}号同学回答问题')
51-
VOICE_RATE = config.getint('lottery', 'voice_rate', fallback=200)
52-
VOICE_VOLUME = config.getfloat('lottery', 'voice_volume', fallback=1.0)
53-
VOICE_ID = config.get('lottery', 'voice_id', fallback='')
5465

5566
# 命令行参数处理
5667
parser = ArgumentParser()
@@ -64,18 +75,29 @@ def flush(self):
6475
parser.add_argument('--voice-rate', type=int, help='语音速率')
6576
parser.add_argument('--voice-volume', type=float, help='语音音量')
6677
parser.add_argument('--voice-id', type=str, help='语音ID')
78+
parser.add_argument('--dynamic-voice', type=int, help="是否开启灵活的形容词")
6779
args = parser.parse_args()
6880

6981
if args.min_number is not None:
7082
MIN_NUMBER = args.min_number
83+
else:
84+
MIN_NUMBER = config.getint('lottery', 'min_number', fallback=1)
7185
if args.max_number is not None:
7286
MAX_NUMBER = args.max_number
87+
else:
88+
MAX_NUMBER = config.getint('lottery', 'max_number', fallback=48)
7389
if args.delay is not None:
7490
DELAY = args.delay
91+
else:
92+
DELAY = config.getint('lottery', 'delay', fallback=3)
7593
if args.keep is not None:
7694
KEEP = args.keep
95+
else:
96+
KEEP = config.getint('lottery', 'keep', fallback=3)
7797
if args.student_mode is not None:
7898
STUDENT_MODE = args.student_mode
99+
else:
100+
STUDENT_MODE = config.getint('lottery', 'student_mode', fallback=0)
79101
if args.enable_voice is not None:
80102
ENABLE_VOICE = args.enable_voice
81103
else:
@@ -98,6 +120,11 @@ def flush(self):
98120

99121
if args.voice_id is not None:
100122
VOICE_ID = args.voice_id
123+
else:
124+
VOICE_ID = config.getint('lottery', 'dynamic_voice', fallback=0)
125+
126+
if args.dynamic_voice is not None:
127+
dynamic_voice_layout = args.dynamic_voice
101128
else:
102129
VOICE_ID = config.get('lottery', 'voice_id', fallback='')
103130

@@ -166,10 +193,19 @@ def play_startup_sound():
166193
sound_path = os.path.join(os.getcwd(), SOUND_FILE)
167194
if os.path.exists(sound_path):
168195
try:
169-
Thread(
170-
target=lambda: PlaySound(sound_path, SND_FILENAME | SND_ASYNC),
171-
daemon=True
172-
).start()
196+
effect = QSoundEffect()
197+
# 设置音频源,需要使用绝对路径
198+
effect.setSource(QUrl.fromLocalFile(sound_path))
199+
200+
# 关键:防止函数返回后对象被立即回收
201+
# 将 QSoundEffect 对象挂载到 QCoreApplication 实例上
202+
if QCoreApplication.instance():
203+
effect.setParent(QCoreApplication.instance())
204+
205+
# 设置音量 (可选,默认为 1.0)
206+
# effect.setVolume(1.0)
207+
208+
effect.play()
173209
logger.info(f'启动音效播放成功:{SOUND_FILE}')
174210
except Exception as e:
175211
logger.warning(f'启动音效播放失败:{str(e)}')
@@ -686,10 +722,9 @@ def speak_number(number):
686722

687723
try:
688724
student_name = STUDENTS.get(number)
689-
if student_name:
690-
speak_text = VOICE_TEMPLATE.format(student_name)
691-
else:
692-
speak_text = VOICE_TEMPLATE.format(str(number) + '号')
725+
after_handle = (random.choice(classroom_adjectives)
726+
if dynamic_voice_layout else "" ) + student_name if student_name else str(number) + '号'
727+
speak_text = VOICE_TEMPLATE.format(after_handle)
693728

694729
engine = pyttsx3.init()
695730
# 设置语音引擎参数
@@ -723,24 +758,45 @@ def __init__(self):
723758

724759
# 播放启动音效
725760
play_startup_sound()
761+
self.hotkey_listener = None
726762

727763
def create_tray_icon(self):
728764
global tray_icon
729-
icon_path = os.path.join(os.getcwd(), ICON_FILE)
730765

731-
try:
732-
icon = QIcon(icon_path)
733-
logger.info(f'成功加载图标文件:{ICON_FILE}')
734-
except Exception as e:
735-
logger.warning(f'图标文件加载失败,使用默认图标:{str(e)}')
736-
# 创建默认图标
737-
pixmap = Image.new('RGB', (64, 64), 'red')
766+
# ==================== 跨平台图标路径选择 ====================
767+
icon_path = ''
768+
if sys.platform == 'win32':
769+
# Windows 使用 .ico
770+
icon_path = os.path.join(os.getcwd(), 'assets/icon.ico')
771+
elif sys.platform == 'darwin':
772+
# macOS 使用 .icns
773+
icon_path = os.path.join(os.getcwd(), 'assets/icon.icns')
774+
else:
775+
# Linux 或其他平台备选(例如 .png)
776+
icon_path = os.path.join(os.getcwd(), 'assets/icon.png')
777+
778+
# ==================== 加载图标 ====================
779+
icon = QIcon()
780+
if os.path.exists(icon_path):
781+
try:
782+
icon = QIcon(icon_path)
783+
if icon.isNull():
784+
raise ValueError("加载的图标为空,可能文件损坏")
785+
logger.info(f'成功加载图标文件:{icon_path}')
786+
except Exception as e:
787+
logger.warning(f'图标文件 {icon_path} 加载失败:{str(e)}')
788+
789+
# ==================== 生成默认图标(备用) ====================
790+
if icon.isNull():
791+
logger.warning('未找到适配平台的图标文件,生成默认红色圆形图标')
792+
# 创建一个简单的 PNG 内存图标
793+
pixmap = Image.new('RGBA', (64, 64), (255, 0, 0, 255))
738794
draw = ImageDraw.Draw(pixmap)
739-
draw.ellipse((10, 10, 54, 54), fill='darkred')
740-
# 转换为QIcon
741-
qim = QImage(pixmap)
795+
draw.ellipse((10, 10, 54, 54), fill=(200, 0, 0, 255))
796+
qim = QImage(pixmap.tobytes(), pixmap.width, pixmap.height, QImage.Format_RGBA8888)
742797
icon = QIcon(QPixmap.fromImage(qim))
743798

799+
# ==================== 创建托盘对象 ====================
744800
tray_icon = QSystemTrayIcon(icon, self.app)
745801
tray_icon.setToolTip('课堂抽号(快捷键:按alt)')
746802

@@ -751,21 +807,43 @@ def create_tray_icon(self):
751807
tray_menu.addAction(exit_action)
752808

753809
tray_icon.setContextMenu(tray_menu)
754-
tray_icon.show()
755-
logger.info('托盘功能启动成功')
810+
811+
# 显示托盘图标
812+
if QSystemTrayIcon.isSystemTrayAvailable():
813+
tray_icon.show()
814+
logger.info('托盘功能启动成功')
815+
else:
816+
logger.error('当前系统不支持系统托盘')
756817

757818
def start_hotkey_listener(self):
758-
global hotkey_listener
819+
"""
820+
使用 pynput 替代 keyboard,解决 macOS 跨平台问题
821+
注意:在 macOS 系统设置 -> 隐私与安全性 -> 辅助功能 中,必须添加 Python 或终端/IDE 并授权
822+
"""
759823
try:
760-
keyboard.add_hotkey(HOTKEY, self.on_hotkey)
761-
hotkey_listener = Thread(target=keyboard.wait)
762-
hotkey_listener.daemon = True
763-
hotkey_listener.start()
764-
logger.info(f'快捷键监听启动成功({HOTKEY})')
824+
# 定义按键按下时的处理函数
825+
def on_press(key):
826+
try:
827+
# 检测是否按下了 Alt 键 (兼容左 Alt 和右 Alt)
828+
# 注意:如果配置文件中有其他快捷键配置,这里需要解析配置
829+
# 目前根据原代码,HOTKEY 默认为 'alt'
830+
if key == keyboard.Key.alt or key == keyboard.Key.alt_l or key == keyboard.Key.alt_r:
831+
self.on_hotkey()
832+
except AttributeError:
833+
pass
834+
835+
# 创建监听器,非阻塞模式
836+
self.hotkey_listener = keyboard.Listener(on_press=on_press)
837+
self.hotkey_listener.start()
838+
839+
logger.info(f'全局快捷键监听已启动 ({HOTKEY})')
765840
return True
766841
except Exception as e:
767-
logger.error(f'快捷键注册失败:{str(e)}')
768-
QMessageBox.warning(None, '警告', f'快捷键注册失败,可能存在冲突!')
842+
logger.error(f'全局快捷键监听启动失败:{str(e)}')
843+
# 在 macOS 上,如果未授权,这里通常会抛出异常
844+
QMessageBox.warning(None, '权限警告',
845+
'macOS 需要授予 Python 辅助功能权限才能使用全局快捷键。\n'
846+
'请前往:系统设置 -> 隐私与安全性 -> 辅助功能 -> 添加 Python/终端')
769847
return False
770848

771849
def on_hotkey(self):
@@ -797,11 +875,10 @@ def show_lottery_window(self, number):
797875
def exit_app(self):
798876
global tray_icon
799877
logger.info('用户通过托盘退出程序')
800-
try:
801-
if hotkey_listener:
802-
keyboard.unhook_all()
803-
except:
804-
pass
878+
879+
# 停止 pynput 监听
880+
if self.hotkey_listener:
881+
self.hotkey_listener.stop()
805882

806883
if tray_icon:
807884
tray_icon.hide()

0 commit comments

Comments
 (0)