|
| 1 | +# ================================================== |
| 2 | +# 导入库 |
| 3 | +# ================================================== |
| 4 | + |
| 5 | +import threading |
| 6 | +import time |
| 7 | +from typing import Optional |
| 8 | +import numpy as np |
| 9 | +import sounddevice as sd |
| 10 | +import soundfile as sf |
| 11 | +from loguru import logger |
| 12 | + |
| 13 | +from app.tools.path_utils import * |
| 14 | +from app.tools.settings_default import * |
| 15 | +from app.tools.settings_access import * |
| 16 | +from app.Language.obtain_language import * |
| 17 | + |
| 18 | + |
| 19 | +# ================================================== |
| 20 | +# 音乐播放器类 |
| 21 | +# ================================================== |
| 22 | +class MusicPlayer: |
| 23 | + """音乐播放器类,用于在点名、闪抽动画和抽奖功能中播放背景音乐""" |
| 24 | + |
| 25 | + def __init__(self): |
| 26 | + """初始化音乐播放器""" |
| 27 | + self._current_music: Optional[str] = None |
| 28 | + self._is_playing: bool = False |
| 29 | + self._stop_flag: threading.Event = threading.Event() |
| 30 | + self._play_thread: Optional[threading.Thread] = None |
| 31 | + self._volume: float = 1.0 # 默认音量 |
| 32 | + self._fade_in_duration: float = 0.0 # 渐入时长(秒) |
| 33 | + self._fade_out_duration: float = 0.0 # 渐出时长(秒) |
| 34 | + self._fade_out_thread: Optional[threading.Thread] = None |
| 35 | + |
| 36 | + def play_music( |
| 37 | + self, |
| 38 | + music_file: str, |
| 39 | + settings_group: str, |
| 40 | + loop: bool = True, |
| 41 | + fade_in: bool = True, |
| 42 | + ) -> bool: |
| 43 | + """播放音乐 |
| 44 | +
|
| 45 | + Args: |
| 46 | + music_file: 音乐文件名,空字符串表示不播放音乐 |
| 47 | + settings_group: 设置组名,如"roll_call_settings"、"quick_draw_settings"等 |
| 48 | + loop: 是否循环播放 |
| 49 | + fade_in:是否使用渐入效果 |
| 50 | +
|
| 51 | + Returns: |
| 52 | + bool: 是否成功开始播放 |
| 53 | + """ |
| 54 | + # 如果音乐文件为空或"无音乐",则不播放 |
| 55 | + if not music_file or music_file == get_content_name_async( |
| 56 | + "music_settings", "no_music" |
| 57 | + ): |
| 58 | + logger.debug("音乐文件为空或选择无音乐,不播放") |
| 59 | + return False |
| 60 | + |
| 61 | + # 停止当前播放的音乐 |
| 62 | + self.stop_music() |
| 63 | + |
| 64 | + # 获取音乐文件路径 |
| 65 | + try: |
| 66 | + music_path = get_audio_path(f"music/{music_file}") |
| 67 | + if not music_path.exists(): |
| 68 | + logger.error(f"音乐文件不存在: {music_path}") |
| 69 | + return False |
| 70 | + except Exception as e: |
| 71 | + logger.error(f"获取音乐文件路径失败: {e}") |
| 72 | + return False |
| 73 | + |
| 74 | + # 从设置中获取音量和渐入渐出时长 |
| 75 | + try: |
| 76 | + # 获取音量设置,默认为100% |
| 77 | + volume = readme_settings_async( |
| 78 | + settings_group, "animation_music_volume", 100 |
| 79 | + ) |
| 80 | + self._volume = volume / 100.0 |
| 81 | + |
| 82 | + # 获取渐入时长设置 |
| 83 | + if fade_in: |
| 84 | + fade_in_ms = readme_settings_async( |
| 85 | + settings_group, "animation_music_fade_in", 0 |
| 86 | + ) |
| 87 | + self._fade_in_duration = fade_in_ms / 1000.0 |
| 88 | + else: |
| 89 | + self._fade_in_duration = 0.0 |
| 90 | + |
| 91 | + # 获取渐出时长设置 |
| 92 | + fade_out_ms = readme_settings_async( |
| 93 | + settings_group, "animation_music_fade_out", 0 |
| 94 | + ) |
| 95 | + self._fade_out_duration = fade_out_ms / 1000.0 |
| 96 | + except Exception as e: |
| 97 | + logger.error(f"获取音乐设置失败: {e}") |
| 98 | + self._volume = 1.0 |
| 99 | + self._fade_in_duration = 0.0 |
| 100 | + self._fade_out_duration = 0.0 |
| 101 | + |
| 102 | + # 启动播放线程 |
| 103 | + self._current_music = music_file |
| 104 | + self._stop_flag.clear() |
| 105 | + self._is_playing = True |
| 106 | + self._play_thread = threading.Thread( |
| 107 | + target=self._play_music_worker, |
| 108 | + args=(str(music_path), loop), |
| 109 | + daemon=True, |
| 110 | + ) |
| 111 | + self._play_thread.start() |
| 112 | + |
| 113 | + logger.info(f"开始播放音乐: {music_file}, 音量: {self._volume}, 循环: {loop}") |
| 114 | + return True |
| 115 | + |
| 116 | + def stop_music(self, fade_out: bool = True) -> None: |
| 117 | + """停止播放音乐 |
| 118 | +
|
| 119 | + Args: |
| 120 | + fade_out: 是否使用渐出效果 |
| 121 | + """ |
| 122 | + if not self._is_playing: |
| 123 | + return |
| 124 | + |
| 125 | + logger.debug("停止播放音乐") |
| 126 | + |
| 127 | + # 如果需要渐出效果且当前有渐出时长设置 |
| 128 | + if fade_out and self._fade_out_duration > 0 and self._is_playing: |
| 129 | + # 启动渐出线程 |
| 130 | + self._fade_out_thread = threading.Thread( |
| 131 | + target=self._fade_out_worker, daemon=True |
| 132 | + ) |
| 133 | + self._fade_out_thread.start() |
| 134 | + # 等待渐出完成 |
| 135 | + self._fade_out_thread.join(timeout=self._fade_out_duration + 1.0) |
| 136 | + |
| 137 | + # 设置停止标志 |
| 138 | + self._stop_flag.set() |
| 139 | + self._is_playing = False |
| 140 | + |
| 141 | + # 等待播放线程结束 |
| 142 | + if self._play_thread and self._play_thread.is_alive(): |
| 143 | + self._play_thread.join(timeout=2.0) |
| 144 | + |
| 145 | + self._current_music = None |
| 146 | + logger.debug("音乐已停止") |
| 147 | + |
| 148 | + def is_playing(self) -> bool: |
| 149 | + """检查是否正在播放音乐 |
| 150 | +
|
| 151 | + Returns: |
| 152 | + bool: 是否正在播放音乐 |
| 153 | + """ |
| 154 | + return self._is_playing |
| 155 | + |
| 156 | + def get_current_music(self) -> Optional[str]: |
| 157 | + """获取当前播放的音乐文件名 |
| 158 | +
|
| 159 | + Returns: |
| 160 | + Optional[str]: 当前播放的音乐文件名,如果没有播放则返回None |
| 161 | + """ |
| 162 | + return self._current_music |
| 163 | + |
| 164 | + def _play_music_worker(self, music_path: str, loop: bool) -> None: |
| 165 | + """音乐播放工作线程 |
| 166 | +
|
| 167 | + Args: |
| 168 | + music_path: 音乐文件路径 |
| 169 | + loop: 是否循环播放 |
| 170 | + """ |
| 171 | + stream = None |
| 172 | + try: |
| 173 | + while not self._stop_flag.is_set(): |
| 174 | + # 读取音乐文件 |
| 175 | + try: |
| 176 | + data, fs = sf.read(music_path) |
| 177 | + if len(data.shape) > 1 and data.shape[1] > 1: |
| 178 | + # 转换为单声道 |
| 179 | + data = np.mean(data, axis=1) |
| 180 | + except Exception as e: |
| 181 | + logger.error(f"读取音乐文件失败: {e}") |
| 182 | + break |
| 183 | + |
| 184 | + # 初始化音频流 |
| 185 | + try: |
| 186 | + stream = sd.OutputStream( |
| 187 | + samplerate=fs, |
| 188 | + channels=1, |
| 189 | + dtype="float32", |
| 190 | + blocksize=2048, |
| 191 | + ) |
| 192 | + stream.start() |
| 193 | + except Exception as e: |
| 194 | + logger.error(f"初始化音频流失败: {e}") |
| 195 | + break |
| 196 | + |
| 197 | + # 计算渐入步数 |
| 198 | + fade_in_steps = int(self._fade_in_duration * fs) |
| 199 | + fade_in_step = 0 |
| 200 | + |
| 201 | + # 分块播放 |
| 202 | + chunk_size = 4096 |
| 203 | + for i in range(0, len(data), chunk_size): |
| 204 | + if self._stop_flag.is_set(): |
| 205 | + break |
| 206 | + |
| 207 | + chunk = data[i : i + chunk_size] |
| 208 | + chunk = chunk.astype(np.float32) |
| 209 | + |
| 210 | + # 应用渐入效果 |
| 211 | + if fade_in_steps > 0 and fade_in_step < fade_in_steps: |
| 212 | + remaining_steps = min(fade_in_steps - fade_in_step, len(chunk)) |
| 213 | + fade_in_factor = np.linspace(0, self._volume, remaining_steps) |
| 214 | + chunk[:remaining_steps] *= fade_in_factor |
| 215 | + if remaining_steps < len(chunk): |
| 216 | + chunk[remaining_steps:] *= self._volume |
| 217 | + fade_in_step += remaining_steps |
| 218 | + else: |
| 219 | + # 应用音量 |
| 220 | + chunk *= self._volume |
| 221 | + |
| 222 | + # 写入音频流 |
| 223 | + try: |
| 224 | + stream.write(chunk) |
| 225 | + except Exception as e: |
| 226 | + logger.error(f"写入音频流失败: {e}") |
| 227 | + break |
| 228 | + |
| 229 | + # 关闭音频流 |
| 230 | + if stream: |
| 231 | + try: |
| 232 | + stream.stop() |
| 233 | + stream.close() |
| 234 | + except Exception as e: |
| 235 | + logger.error(f"关闭音频流失败: {e}") |
| 236 | + stream = None |
| 237 | + |
| 238 | + # 如果不循环或者收到停止信号,退出循环 |
| 239 | + if not loop or self._stop_flag.is_set(): |
| 240 | + break |
| 241 | + |
| 242 | + except Exception as e: |
| 243 | + logger.error(f"音乐播放工作线程异常: {e}") |
| 244 | + finally: |
| 245 | + # 确保音频流关闭 |
| 246 | + if stream: |
| 247 | + try: |
| 248 | + stream.stop() |
| 249 | + stream.close() |
| 250 | + except Exception as e: |
| 251 | + logger.error(f"关闭音频流失败: {e}") |
| 252 | + self._is_playing = False |
| 253 | + logger.debug("音乐播放工作线程结束") |
| 254 | + |
| 255 | + def _fade_out_worker(self) -> None: |
| 256 | + """渐出效果工作线程""" |
| 257 | + if not self._is_playing or self._fade_out_duration <= 0: |
| 258 | + return |
| 259 | + |
| 260 | + logger.debug(f"开始音乐渐出,时长: {self._fade_out_duration}秒") |
| 261 | + |
| 262 | + # 计算渐出步数 |
| 263 | + fade_out_steps = int(self._fade_out_duration * 50) # 50Hz更新率 |
| 264 | + fade_out_step = 0 |
| 265 | + initial_volume = self._volume |
| 266 | + |
| 267 | + # 渐出效果循环 |
| 268 | + while fade_out_step < fade_out_steps and not self._stop_flag.is_set(): |
| 269 | + # 计算当前音量(线性递减) |
| 270 | + progress = fade_out_step / fade_out_steps |
| 271 | + self._volume = initial_volume * (1.0 - progress) |
| 272 | + |
| 273 | + # 等待下一帧 |
| 274 | + time.sleep(1.0 / 50) # 50Hz更新率 |
| 275 | + fade_out_step += 1 |
| 276 | + |
| 277 | + # 渐出完成,设置停止标志 |
| 278 | + self._stop_flag.set() |
| 279 | + logger.debug("音乐渐出完成") |
| 280 | + |
| 281 | + |
| 282 | +# 创建全局音乐播放器实例 |
| 283 | +music_player = MusicPlayer() |
| 284 | + |
| 285 | + |
| 286 | +def get_music_files(): |
| 287 | + """获取音乐文件列表 |
| 288 | +
|
| 289 | + Returns: |
| 290 | + List[str]: 音乐文件名列表,包含"无音乐"选项 |
| 291 | + """ |
| 292 | + from app.tools.path_utils import get_audio_path |
| 293 | + from app.Language.obtain_language import get_content_name_async |
| 294 | + |
| 295 | + # 获取音频文件目录 |
| 296 | + audio_dir = get_audio_path("music") |
| 297 | + # 确保目录存在 |
| 298 | + from app.tools.path_utils import ensure_dir |
| 299 | + |
| 300 | + ensure_dir(audio_dir) |
| 301 | + |
| 302 | + # 获取音频文件列表 |
| 303 | + music_files = [ |
| 304 | + get_content_name_async("music_settings", "no_music") |
| 305 | + ] # 无音乐选项,表示不使用音乐 |
| 306 | + if audio_dir.exists(): |
| 307 | + # 支持的音频格式 |
| 308 | + supported_formats = [".mp3", ".flac", ".wav", ".ogg"] |
| 309 | + # 遍历目录获取所有支持的音频文件 |
| 310 | + for file in audio_dir.iterdir(): |
| 311 | + if file.is_file() and file.suffix.lower() in supported_formats: |
| 312 | + music_files.append(file.name) |
| 313 | + |
| 314 | + return music_files |
0 commit comments