22"""
33课堂号数抽取程序(PySide2重构版)- 解决线程安全问题
44"""
5+ import random
56import sys
67import json
78import pickle
1516import numpy as np
1617import 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
2022from PySide2 .QtWidgets import (QApplication , QDialog , QLabel , QVBoxLayout ,
2123 QSystemTrayIcon , QMenu , QAction , QMessageBox )
2224from PySide2 .QtCore import Qt , QTimer , Signal , QObject
@@ -37,20 +39,29 @@ def flush(self):
3739# ==================== 全局配置 ====================
3840ICON_FILE = 'assets/icon.ico'
3941SOUND_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# 配置文件读取
4263config = ConfigParser ()
4364config .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# 命令行参数处理
5667parser = ArgumentParser ()
@@ -64,18 +75,29 @@ def flush(self):
6475parser .add_argument ('--voice-rate' , type = int , help = '语音速率' )
6576parser .add_argument ('--voice-volume' , type = float , help = '语音音量' )
6677parser .add_argument ('--voice-id' , type = str , help = '语音ID' )
78+ parser .add_argument ('--dynamic-voice' , type = int , help = "是否开启灵活的形容词" )
6779args = parser .parse_args ()
6880
6981if args .min_number is not None :
7082 MIN_NUMBER = args .min_number
83+ else :
84+ MIN_NUMBER = config .getint ('lottery' , 'min_number' , fallback = 1 )
7185if args .max_number is not None :
7286 MAX_NUMBER = args .max_number
87+ else :
88+ MAX_NUMBER = config .getint ('lottery' , 'max_number' , fallback = 48 )
7389if args .delay is not None :
7490 DELAY = args .delay
91+ else :
92+ DELAY = config .getint ('lottery' , 'delay' , fallback = 3 )
7593if args .keep is not None :
7694 KEEP = args .keep
95+ else :
96+ KEEP = config .getint ('lottery' , 'keep' , fallback = 3 )
7797if args .student_mode is not None :
7898 STUDENT_MODE = args .student_mode
99+ else :
100+ STUDENT_MODE = config .getint ('lottery' , 'student_mode' , fallback = 0 )
79101if args .enable_voice is not None :
80102 ENABLE_VOICE = args .enable_voice
81103else :
@@ -98,6 +120,11 @@ def flush(self):
98120
99121if 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
101128else :
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