python播放音频、音频流,使用pygame

你是否曾经想在你的 Python 应用中添加音频播放功能,但又被复杂的音频处理和 GUI 集成所困扰?本文将向你展示如何构建一个健壮、灵活且易于集成的音频播放器。

我们将创建一个名为 AudioPlayer 的核心工具类,它基于强大的 pygame 库,并能处理多种音频源(文件路径、内存字节流等)。更棒的是,它通过后台线程实现了自动播放结束回调,完美解决了阻塞主线程的问题。

为了直观地展示它的功能,我们还会使用 PyQt5 构建一个测试播放器界面。

快速上手

在深入细节之前,让我们先直接看代码并运行起来。

1. 环境依赖

首先,你需要安装 pygamePyQt5

pip install pygame PyQt5

2. 项目结构

建议按以下结构组织你的文件:

your_project/
├── src/
│   └── audio_player/
│       └── player.py   # 核心播放器类
└── gui.py              # PyQt5 UI 界面

3. 完整代码示例

src/audio_player/player.py

这是我们的核心播放器逻辑,它封装了所有 pygame 的复杂操作。

"""
核心 AudioPlayer 工具类。
"""
import os
import tempfile
import pygame
from enum import Enum
import io
import threading
import time

class PlayerState(Enum):
    """
    播放器状态的枚举。
    """
    STOPPED = 0  # 已停止
    PLAYING = 1  # 正在播放
    PAUSED = 2   # 已暂停

class AudioPlayer:
    """
    一个多功能音频播放器,可以使用 Pygame 处理文件路径、字节流和类文件对象。
    它能在一个后台线程中自动处理播放结束事件。
    """
    def __init__(self, on_playback_finished=None):
        """
        初始化 Pygame 混合器和播放器状态。

        :param on_playback_finished: 可选的回调函数,当音轨播放完成时自动调用。
        """
        pygame.init()  # 初始化所有 Pygame 模块,包括事件系统
        self._state = PlayerState.STOPPED
        self._temp_file_path = None
        self._current_file_path = None  # 存储当前音轨的文件路径
        self.on_playback_finished = on_playback_finished

        # 为播放结束创建一个自定义事件
        self.MUSIC_END = pygame.USEREVENT + 1

        # 用于停止监控线程的事件
        self._stop_monitoring_event = threading.Event()
        
        # 启动后台线程来监控播放结束事件
        self._monitor_thread = threading.Thread(target=self._monitor_playback, daemon=True)
        self._monitor_thread.start()
        
        print("音频播放器已初始化。")

    def _monitor_playback(self):
        """
        在后台运行以捕获并处理播放结束事件。
        """
        while not self._stop_monitoring_event.is_set():
            for event in pygame.event.get():
                if event.type == self.MUSIC_END:
                    # 音乐播放完成
                    if self._state == PlayerState.PLAYING:
                        self._state = PlayerState.STOPPED
                        self._cleanup_temp_file()
                        self._current_file_path = None # 清理当前文件路径
                        print("音轨播放结束。")
                        
                        # 调用回调函数
                        if self.on_playback_finished:
                            try:
                                self.on_playback_finished()
                            except Exception as e:
                                print(f"执行播放结束回调时出错: {e}")
            time.sleep(0.1)  # 避免CPU占用过高

    def play(self, source):
        """
        从给定的源播放音频。

        源可以是以下之一:
        - str: 音频文件的路径。
        - bytes: 音频数据的字节流。
        - file-like object: 带有 read() 方法的对象 (例如 io.BytesIO)。
        """
        self.stop()

        try:
            file_path = None
            if isinstance(source, str):
                if not os.path.exists(source):
                    raise FileNotFoundError(f"音频文件未找到: {source}")
                file_path = source
                print(f"从文件播放: {file_path}")

            elif isinstance(source, bytes):
                file_path = self._write_to_temp_file(source)
                print(f"从字节流播放 (通过临时文件: {file_path})")

            elif hasattr(source, 'read'):
                content = source.read()
                if isinstance(content, str):
                    content = content.encode('utf-8')
                file_path = self._write_to_temp_file(content)
                print(f"从类文件对象播放 (通过临时文件: {file_path})")

            else:
                raise TypeError("不支持的源类型。必须是 str、bytes 或类文件对象。")

            # 存储当前文件路径以供其他方法使用
            self._current_file_path = file_path

            pygame.mixer.music.load(file_path)
            pygame.mixer.music.set_endevent(self.MUSIC_END)  # 设置播放结束事件
            pygame.mixer.music.play()
            self._state = PlayerState.PLAYING

        except Exception as e:
            print(f"播放音频时出错: {e}")
            self._cleanup_temp_file()
            self._current_file_path = None
            self._state = PlayerState.STOPPED

    def _write_to_temp_file(self, data: bytes) -> str:
        """
        将字节数据写入临时文件并返回路径。
        """
        fd, path = tempfile.mkstememp(suffix=".mp3")
        with os.fdopen(fd, 'wb') as temp_file:
            temp_file.write(data)
        self._temp_file_path = path
        return path

    def _cleanup_temp_file(self):
        """
        如果存在,则删除临时文件。
        """
        if self._temp_file_path:
            try:
                os.remove(self._temp_file_path)
                self._temp_file_path = None
            except OSError as e:
                print(f"移除临时文件 {self._temp_file_path} 时出错: {e}")

    def pause(self):
        """
        暂停当前播放的音频。
        """
        if self._state == PlayerState.PLAYING:
            pygame.mixer.music.pause()
            self._state = PlayerState.PAUSED
            print("音频已暂停。")

    def unpause(self):
        """
        恢复暂停的音频。
        """
        if self._state == PlayerState.PAUSED:
            pygame.mixer.music.unpause()
            self._state = PlayerState.PLAYING
            print("音频已恢复。")

    def stop(self):
        """
        停止音频播放并清理任何临时文件。
        """
        if self._state != PlayerState.STOPPED:
            pygame.mixer.music.stop()
            pygame.mixer.music.set_endevent()  # 清除结束事件以防手动停止时触发
            self._state = PlayerState.STOPPED
            print("音频已停止。")
        
        self._cleanup_temp_file()
        self._current_file_path = None # 清理当前文件路径

    def get_state(self) -> PlayerState:
        """
        获取播放器的当前状态。
        """
        return self._state

    def get_current_track_length(self) -> float:
        """
        获取当前加载音轨的长度(秒)。
        只有在调用 play() 加载音轨后才有效。
        :return: 音轨的长度(秒),如果未加载或出错则返回 0.0。
        """
        if self._current_file_path and os.path.exists(self._current_file_path):
            try:
                # 使用 pygame.mixer.Sound 来获取长度,这可能会将部分或全部音轨加载到内存
                sound = pygame.mixer.Sound(self._current_file_path)
                return sound.get_length()
            except pygame.error as e:
                print(f"获取音轨 '{self._current_file_path}' 长度时出错: {e}")
                return 0.0
        return 0.0

    def get_position(self) -> float:
        """
        获取当前播放位置(秒)。
        :return: 当前位置(秒),如果未播放则返回 -1.0。
        """
        if self._state == PlayerState.PLAYING or self._state == PlayerState.PAUSED:
            # pygame.mixer.music.get_pos() 返回自播放开始以来的毫秒数
            return pygame.mixer.music.get_pos() / 1000.0
        return -1.0

    def set_position(self, position_sec: float):
        """
        设置当前音轨的播放位置(秒)。
        注意:此功能可能不适用于所有音频格式(例如,某些MP3文件)。
        :param position_sec: 要跳转到的位置(秒)。
        """
        if self._state != PlayerState.STOPPED and self._current_file_path:
            try:
                # play() 的 start 参数用于设置起始位置
                # 我们需要停止当前播放,然后从新位置重新开始
                current_state = self._state
                self.stop()
                
                # 重新加载并从新位置播放
                pygame.mixer.music.load(self._current_file_path)
                pygame.mixer.music.play(start=position_sec)
                pygame.mixer.music.set_endevent(self.MUSIC_END)
                
                self._state = PlayerState.PLAYING
                
                # 如果之前是暂停状态,则重新暂停
                if current_state == PlayerState.PAUSED:
                    self.pause()
                    
            except pygame.error as e:
                print(f"设置音轨位置时出错: {e}")

    def quit(self):
        """
        停止播放,清理资源,并退出混合器。
        """
        self.stop()
        self._stop_monitoring_event.set()
        if self._monitor_thread.is_alive():
            self._monitor_thread.join(timeout=1)
        pygame.quit() # 使用 pygame.quit() 来关闭所有模块
        print("音频播放器已关闭。")
gui.py

这个文件创建了一个美观的 PyQt5 界面来控制 AudioPlayer

import sys
import os
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
    QPushButton, QLabel, QFileDialog, QStyle, QSlider, QFrame
)
from PyQt5.QtCore import QTimer, Qt, QUrl
from PyQt5.QtGui import QFont, QIcon, QPalette, QColor

from src.audio_player.player import AudioPlayer, PlayerState

class AudioPlayerGUI(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("测试播放器")
        self.setGeometry(100, 100, 500, 350)
        self.setWindowIcon(self.style().standardIcon(QStyle.SP_MediaPlay))

        # --- Initialize Audio Player ---
        self.player = AudioPlayer(on_playback_finished=self.on_playback_finished)
        self.current_file = None
        self.track_length_str = "00:00"

        # --- Apply Dark Theme ---
        self.apply_stylesheet()

        # --- UI Elements ---
        self.file_label = QLabel("未选择文件")
        self.file_label.setAlignment(Qt.AlignCenter)
        self.file_label.setFont(QFont("Arial", 12))

        self.time_label = QLabel("00:00 / 00:00")
        self.time_label.setAlignment(Qt.AlignCenter)
        self.time_label.setFont(QFont("Arial", 10))

        self.progress_slider = QSlider(Qt.Horizontal)
        self.progress_slider.setRange(0, 1000)
        self.progress_slider.sliderMoved.connect(self.set_position)
        self.progress_slider.setEnabled(False)

        self.open_button = QPushButton("选择音频")
        self.play_button = QPushButton()
        self.pause_button = QPushButton()
        self.stop_button = QPushButton()

        self.setup_buttons()

        # --- Layout ---
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        main_layout = QVBoxLayout(central_widget)
        main_layout.setContentsMargins(20, 20, 20, 20)
        main_layout.setSpacing(15)

        # Info Panel
        info_frame = QFrame()
        info_frame.setObjectName("infoFrame")
        info_layout = QVBoxLayout(info_frame)
        info_layout.addWidget(self.file_label)
        info_layout.addWidget(self.time_label)
        main_layout.addWidget(info_frame)

        main_layout.addWidget(self.progress_slider)

        # Controls Panel
        button_layout = QHBoxLayout()
        button_layout.setSpacing(10)
        button_layout.addWidget(self.open_button)
        button_layout.addStretch()
        button_layout.addWidget(self.play_button)
        button_layout.addWidget(self.pause_button)
        button_layout.addWidget(self.stop_button)
        button_layout.addStretch()
        main_layout.addLayout(button_layout)

        # --- Connections ---
        self.open_button.clicked.connect(self.open_file)
        self.play_button.clicked.connect(self.play_audio)
        self.pause_button.clicked.connect(self.toggle_pause)
        self.stop_button.clicked.connect(self.stop_audio)

        # --- Status Update Timer ---
        self.timer = QTimer(self)
        self.timer.setInterval(100)
        self.timer.timeout.connect(self.update_status)
        self.timer.start()

        self.update_button_states()

    def setup_buttons(self):
        """Sets icons and object names for buttons."""
        self.play_button.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
        self.pause_button.setIcon(self.style().standardIcon(QStyle.SP_MediaPause))
        self.stop_button.setIcon(self.style().standardIcon(QStyle.SP_MediaStop))
        
        self.play_button.setObjectName("playButton")
        self.pause_button.setObjectName("pauseButton")
        self.stop_button.setObjectName("stopButton")
        self.open_button.setObjectName("openButton")

    def apply_stylesheet(self):
        """Applies a dark and modern stylesheet to the application."""
        self.setStyleSheet("""
            QMainWindow {
                background-color: #2E2E2E;
            }
            QLabel {
                color: #FFFFFF;
            }
            #infoFrame {
                background-color: #3C3C3C;
                border-radius: 10px;
                padding: 10px;
            }
            QPushButton {
                background-color: #555555;
                color: #FFFFFF;
                border: none;
                padding: 10px 20px;
                border-radius: 5px;
                font-size: 14px;
            }
            QPushButton:hover {
                background-color: #6E6E6E;
            }
            QPushButton:pressed {
                background-color: #4D4D4D;
            }
            QPushButton:disabled {
                background-color: #444444;
                color: #888888;
            }
            #playButton, #pauseButton, #stopButton {
                min-width: 50px;
                min-height: 50px;
                border-radius: 25px; /* Makes them circular */
            }
            QSlider::groove:horizontal {
                border: 1px solid #444;
                height: 8px;
                background: #333;
                margin: 2px 0;
                border-radius: 4px;
            }
            QSlider::handle:horizontal {
                background: #1DB954; /* Spotify green */
                border: 1px solid #1DB954;
                width: 18px;
                margin: -5px 0;
                border-radius: 9px;
            }
            QSlider::sub-page:horizontal {
                background: #1DB954;
                border: 1px solid #444;
                height: 8px;
                border-radius: 4px;
            }
        """)

    def open_file(self):
        file_path, _ = QFileDialog.getOpenFileName(
            self, "选择音频文件", "", "音频文件 (*.mp3 *.wav *.ogg);;所有文件 (*)"
        )
        if file_path:
            self.current_file = file_path
            self.player.stop()
            self.player.play(self.current_file)
            
            self.file_label.setText(os.path.basename(file_path))
            
            # Get track length
            track_length_sec = self.player.get_current_track_length()
            self.track_length_str = self.format_time(track_length_sec)
            self.time_label.setText(f"00:00 / {self.track_length_str}")
            
            self.progress_slider.setEnabled(True)
            self.update_button_states()

    def play_audio(self):
        if self.current_file and self.player.get_state() != PlayerState.PLAYING:
            self.player.play(self.current_file)
            self.update_button_states()

    def toggle_pause(self):
        state = self.player.get_state()
        if state == PlayerState.PLAYING:
            self.player.pause()
        elif state == PlayerState.PAUSED:
            self.player.unpause()
        self.update_button_states()

    def stop_audio(self):
        self.player.stop()
        self.progress_slider.setValue(0)
        self.time_label.setText(f"00:00 / {self.track_length_str}")
        self.update_button_states()

    def set_position(self, position):
        """Sets the playback position based on the slider."""
        if self.player.get_state() != PlayerState.STOPPED:
            seek_time_sec = (position / 1000) * self.player.get_current_track_length()
            self.player.set_position(seek_time_sec)

    def update_status(self):
        state = self.player.get_state()
        if state == PlayerState.PLAYING:
            pos_sec = self.player.get_position()
            if pos_sec >= 0:
                # Update slider
                track_len = self.player.get_current_track_length()
                if track_len > 0:
                    slider_pos = int((pos_sec / track_len) * 1000)
                    self.progress_slider.setValue(slider_pos)
                
                # Update time label
                current_time_str = self.format_time(pos_sec)
                self.time_label.setText(f"{current_time_str} / {self.track_length_str}")

        self.update_button_states()

    def update_button_states(self):
        state = self.player.get_state()
        
        self.play_button.setEnabled(self.current_file is not None and state != PlayerState.PLAYING)
        self.pause_button.setEnabled(state == PlayerState.PLAYING or state == PlayerState.PAUSED)
        self.stop_button.setEnabled(state != PlayerState.STOPPED)

    def on_playback_finished(self):
        print("播放结束 -> GUI回调")
        self.progress_slider.setValue(1000) # Mark as complete
        self.update_button_states()

    def format_time(self, seconds):
        """Formats seconds into a MM:SS string."""
        if seconds < 0: return "00:00"
        mins, secs = divmod(int(seconds), 60)
        return f"{mins:02d}:{secs:02d}"

    def closeEvent(self, event):
        print("关闭GUI并停止播放器...")
        self.player.quit()
        event.accept()

def main():
    app = QApplication(sys.argv)
    gui = AudioPlayerGUI()
    gui.show()
    sys.exit(app.exec_())

if __name__ == "__main__":
    main()

4. 运行应用

将以上代码保存到正确的文件路径后,在你的项目根目录运行以下命令:

python gui.py

你将看到一个功能齐全的音乐播放器界面。点击 “选择音频” 来加载并播放你本地的 .mp3, .wav, 或 .ogg 文件。

在这里插入图片描述


设计理念与技术细节

现在,让我们深入探讨 AudioPlayer 类和 GUI 是如何协同工作的。

AudioPlayer 核心功能解析 (player.py)

1. 状态管理

为了清晰地追踪播放器的行为,我们定义了一个 PlayerState 枚举:STOPPED, PLAYING, PAUSED。这使得代码逻辑更易于理解和维护。

2. 灵活的音频源处理

play() 方法是这个类的核心亮点之一。它能智能地处理三种不同类型的音频源:

  • str: 直接加载文件路径。
  • bytes: 当你从网络或数据库获取到音频的字节流时,它会先将数据写入一个临时文件,然后再加载。
  • file-like object: 对于像 io.BytesIO 这样的内存中文件对象,它同样会通过临时文件来处理。

这种设计极大地增强了 AudioPlayer 的通用性。

3. 无阻塞的播放结束回调(关键特性)

在 GUI 应用中,我们绝不能阻塞主线程。如果等待音频播放结束,整个界面都会卡死。AudioPlayer 通过一个巧妙的机制解决了这个问题:

  1. 自定义事件: 在 __init__ 中,我们创建了一个自定义的 Pygame 事件 self.MUSIC_END = pygame.USEREVENT + 1
  2. 设置结束事件: 调用 pygame.mixer.music.play() 之前,我们使用 pygame.mixer.music.set_endevent(self.MUSIC_END)。这告诉 Pygame,当音乐播放完毕时,请向事件队列中发布一个 MUSIC_END 事件。
  3. 后台监控线程: 一个名为 _monitor_playback 的方法在一个独立的守护线程中运行。它不断地检查 Pygame 事件队列。一旦捕获到 MUSIC_END 事件,它就会执行我们传入的 on_playback_finished 回调函数。

这个设计模式是构建响应式桌面应用的关键,它将耗时任务(等待)与主事件循环(UI 响应)完全解耦。

4. 音频控制与信息获取
  • pause(), unpause(), stop(): 提供了标准的播放控制。
  • get_current_track_length(): 获取音轨总时长。
  • get_position(): 获取当前播放进度(以秒为单位)。
  • set_position(): 实现音频“跳转”或“拖动”功能。
5. 优雅地退出

quit() 方法确保在应用关闭时,后台线程被安全停止,Pygame 资源被正确释放。

构建时尚的 PyQt5 界面 (gui.py)

AudioPlayer 已经足够强大,但一个好的 UI 能让它如虎添翼。

1. 无缝集成

AudioPlayerGUI__init__ 方法中,我们简单地创建了一个 AudioPlayer 实例,并将一个 GUI 内部的方法 self.on_playback_finished 作为回调函数传递给它。

self.player = AudioPlayer(on_playback_finished=self.on_playback_finished)
2. 现代化的视觉风格 (QSS)

我们没有使用默认的陈旧样式,而是通过 apply_stylesheet() 方法应用了 QSS (Qt Style Sheets)。这是一种类似 CSS 的语言,可以让你完全自定义 Qt 组件的外观,包括颜色、圆角、边框等,从而轻松打造出如 Spotify 一般的深色主题。

3. 实时状态更新

QTimer 是 PyQt5 中用于执行周期性任务的利器。我们设置了一个每 100 毫秒触发一次的定时器,它会调用 update_status() 方法。

update_status() 中,我们:

  1. self.player.get_position() 获取当前播放进度。
  2. 计算并更新进度滑块 progress_slider 的位置。
  3. 格式化时间并更新 time_label 的文本。

这确保了 UI 能够实时、流畅地反映音频的播放状态。

4. 连接用户操作

我们将按钮的 clicked 信号连接到 AudioPlayer 的相应方法。例如,当用户点击播放按钮时,play_audio 方法会被调用,进而执行 self.player.play()

self.play_button.clicked.connect(self.play_audio)

总结

通过将 pygame 的强大音频处理能力封装在一个设计良好的类中,并结合 PyQt5 的灵活性,我们成功构建了一个功能全面、界面美观且高度可复用的音频播放器。

这个 AudioPlayer 类不仅是一个可以直接使用的工具,更是一个展示了如何在 Python 中处理多线程、事件回调和三方库集成的绝佳范例。希望这个项目能为你的下一个创意提供灵感!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Sparkle Star

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值