音乐播放器开发:QtMultimedia 音频引擎与播放列表管理

37_音乐播放器开发:QtMultimedia 音频引擎与播放列表管理.md

作者 : WeClaw 开发团队
日期 : 2026-03-25
版本 : v1.0
标签: QtMultimedia、QMediaPlayer、音乐播放、播放列表、Audio Ducking


📖 摘要

本文深入剖析 WeClaw 音乐播放器系统的完整设计与实现。针对本地音乐库管理场景,我们展示了如何使用 QtMultimedia 音频引擎构建功能完善的音乐播放器。文章涵盖歌曲库 JSON 结构设计、标签分类系统、播放列表管理、工具层与 UI 层分离架构、Audio Ducking 与 TTS 协调等核心技术。

核心收获

  • 🎵 掌握 QtMultimedia QMediaPlayer 音频播放
  • 📂 学会歌曲库 JSON 数据结构设计
  • 🏷️ 理解多维度标签分类系统
  • 🎶 获得播放列表 CRUD 完整实现
  • 🔊 理解 Audio Ducking 降噪机制
  • 🏗️ 掌握工具层与 UI 层分离架构

🎯 需求背景:为什么需要本地音乐播放器?

真实用户场景

在 WeClaw 的用户调研中,我们发现以下高频需求:

  1. 家庭共享音乐 👨‍👩‍👧‍👦

    • 全家人共用一个音乐库
    • 不同成员有不同的音乐偏好
    • 背景音乐播放(吃饭/休息时)
  2. 语音交互场景 🎙️

    • "播放轻音乐"
    • "来首古典音乐"
    • "播放我收藏的歌曲"
  3. 与 TTS 协调 🔊

    • 听音乐时突然需要语音播报
    • 播报完成后自动恢复播放
    • 背景音乐与语音不冲突

现有方案的局限

方案 优点 缺点 用户体验
QQ音乐/网易云 曲库大 需联网、有广告 ⭐⭐⭐
系统自带播放器 本地播放 功能单一 ⭐⭐
foobar2000 专业 学习成本高 ⭐⭐⭐
自建播放器 定制化 开发量大 ⭐⭐⭐⭐

我们的解决方案

QtMultimedia + 工具层 + UI 层三层架构

复制代码
用户语音指令:"播放轻音乐"
    ↓
MusicPlayerTool(工具层)
    ├─ 搜索歌曲(标签匹配)
    ├─ 发送到控制器
    └─ 返回结果
    ↓
MusicPlayerController(控制器层)
    ├─ 回调机制
    └─ 事件分发
    ↓
MiniPlayerPanel(UI 层)
    ├─ QMediaPlayer 播放
    └─ 显示播放状态

核心优势

  • 离线播放:本地歌曲库,无需联网
  • 语音控制:自然语言点歌
  • 标签管理:多维度分类(风格/场景/心情)
  • Audio Ducking:语音播报时自动降低音乐音量
  • Mini 悬浮窗:不打扰主界面

🏗️ 整体架构设计

系统架构图

复制代码
┌─────────────────────────────────────────────────────┐
│                   UI 层(MiniPlayerPanel)           │
│  - QMediaPlayer 音频播放                            │
│  - 可拖拽悬浮窗口                                   │
│  - 迷你/展开模式切换                                 │
│  - 键盘快捷键                                       │
└───────────────────┬─────────────────────────────────┘
                    │
        ┌───────────▼───────────┐
        │   Controller 层       │
        │  (MusicPlayerController) │
        │  - 单例模式                                    │
        │  - 回调事件分发                                │
        └───────────┬───────────┘
                    │
        ┌───────────▼───────────┐
        │   Tool 层             │
        │  (MusicPlayerTool)    │
        │  - 歌曲库管理                                  │
        │  - 播放控制                                    │
        │  - 标签/播放列表                               │
        └───────────────────────┘
                    │
        ┌───────────▼───────────┐
        │   数据持久化层         │
        │  - JSON 文件           │
        │  - .qoder/data/music_library/
        └───────────────────────┘

核心模块划分

模块 文件 职责
UI 播放器 music_player_panel.py QtMultimedia 播放、悬浮窗
控制器 music_player_controller.py 事件分发、单例管理
歌曲库工具 music_player.py 歌曲管理、搜索、标签
数据存储 library.json 歌曲库持久化

🎵 核心模块一:QtMultimedia 音频播放

QMediaPlayer 集成

python 复制代码
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
from PySide6.QtCore import QUrl, Signal


class MiniPlayerPanel(QFrame):
    """迷你音乐播放器面板。"""
    
    # 信号定义
    play_state_changed = Signal(bool)  # 播放状态变化
    song_changed = Signal(dict)        # 当前歌曲变化
    volume_changed = Signal(float)     # 音量变化
    
    def __init__(self, music_tool=None):
        super().__init__()
        self._music_tool = music_tool
        
        # 创建音频输出
        self._audio_output = QAudioOutput()
        
        # 创建媒体播放器
        self._player = QMediaPlayer()
        self._player.setAudioOutput(self._audio_output)
        
        # 设置初始音量
        self._audio_output.setVolume(0.8)
        
        # 连接信号
        self._player.positionChanged.connect(self._on_position_changed)
        self._player.durationChanged.connect(self._on_duration_changed)
        self._player.playbackStateChanged.connect(self._on_playback_state_changed)
        self._player.errorOccurred.connect(self._on_error)
    
    def play_song(self, song: dict) -> None:
        """播放指定歌曲。
        
        Args:
            song: 歌曲信息字典
                - file_path: 文件路径
                - title: 歌曲名
                - artist: 艺术家
        """
        file_path = song.get("file_path")
        if not file_path:
            logger.error("歌曲文件路径为空")
            return
        
        # 转换为本地文件 URL
        url = QUrl.fromLocalFile(str(Path(file_path)))
        
        # 设置媒体源
        self._player.setSource(url)
        
        # 开始播放
        self._player.play()
        
        # 更新 UI
        self._update_song_info(song)
        
        # 发送信号
        self.song_changed.emit(song)
        self.play_state_changed.emit(True)
        
        logger.info(f"开始播放:{song.get('title')} - {song.get('artist')}")

播放控制实现

python 复制代码
def play(self) -> None:
    """继续播放。"""
    if self._player.playbackState() == QMediaPlayer.PlaybackState.PausedState:
        self._player.play()
        self.play_state_changed.emit(True)

def pause(self) -> None:
    """暂停播放。"""
    self._player.pause()
    self.play_state_changed.emit(False)

def stop(self) -> None:
    """停止播放。"""
    self._player.stop()
    self.play_state_changed.emit(False)

def _next_song(self) -> None:
    """播放下一首。"""
    if not self._playlist:
        return
    
    # 获取随机设置
    shuffle = self._settings.get("shuffle", False)
    
    if shuffle:
        import random
        self._current_index = random.randint(0, len(self._playlist) - 1)
    else:
        self._current_index = (self._current_index + 1) % len(self._playlist)
    
    self.play_song(self._playlist[self._current_index])

def _prev_song(self) -> None:
    """播放上一首。"""
    if not self._playlist:
        return
    
    shuffle = self._settings.get("shuffle", False)
    
    if shuffle:
        import random
        self._current_index = random.randint(0, len(self._playlist) - 1)
    else:
        self._current_index = (self._current_index - 1) % len(self._playlist)
    
    self.play_song(self._playlist[self._current_index])

播放状态监听

python 复制代码
def _on_playback_state_changed(self, state: QMediaPlayer.PlaybackState) -> None:
    """播放状态变化回调。
    
    处理逻辑:
    1. 播放完毕 → 根据循环模式决定下一步
    2. 暂停/继续 → 更新 UI
    """
    if state == QMediaPlayer.PlaybackState.EndOfMedia:
        logger.info("播放完毕,切换下一首")
        # 单曲循环
        if self._settings.get("loop_mode") == "single":
            self._player.setPosition(0)
            self._player.play()
        else:
            self._next_song()
    
    elif state == QMediaPlayer.PlaybackState.PlayingState:
        self._play_button.setIcon(self._pause_icon)
        self.play_state_changed.emit(True)
    
    elif state == QMediaPlayer.PlaybackState.PausedState:
        self._play_button.setIcon(self._play_icon)
        self.play_state_changed.emit(False)


def _on_position_changed(self, position: int) -> None:
    """播放进度变化回调。"""
    # 更新进度条
    if self._player.duration() > 0:
        progress = position / self._player.duration() * 100
        self._progress_slider.setValue(int(progress))
    
    # 更新时间标签
    current = self._format_time(position)
    total = self._format_time(self._player.duration())
    self._time_label.setText(f"{current}/{total}")


def _on_error(self, error: QMediaPlayer.Error, error_string: str) -> None:
    """错误处理。"""
    logger.error(f"播放器错误:{error} - {error_string}")
    
    if error == QMediaPlayer.Error.ResourceError:
        # 资源错误(文件不存在)
        logger.warning(f"音频文件可能不存在,尝试播放下一首")
        self._next_song()

📂 核心模块二:歌曲库数据结构设计

支持的音频格式

python 复制代码
# 支持的音频格式
SUPPORTED_AUDIO_FORMATS = {
    ".mp3",   # 最常见,压缩率高
    ".wav",   # 无损,文件大
    ".flac",  # 无损压缩
    ".ogg",   # 开源格式
    ".m4a",   # AAC 编码
    ".wma",   # Windows Media
    ".aac",   # 高级音频编码
}

歌曲库 JSON 结构

json 复制代码
{
  "settings": {
    "volume": 0.8,
    "loop_mode": "list",
    "shuffle": false,
    "last_played_song_id": "song_abc123"
  },
  "songs": {
    "song_abc123": {
      "id": "song_abc123",
      "title": "月光下的浪漫",
      "artist": "轻音乐团",
      "album": "轻音乐精选集",
      "duration": 245,
      "file_path": "D:/Music/轻音乐/月光下的浪漫.mp3",
      "file_size": 8524000,
      "rating": 5,
      "favorite": true,
      "tags": ["轻音乐", "浪漫", "放松"],
      "play_count": 42,
      "added_at": "2026-03-20T10:30:00",
      "last_played_at": "2026-03-25T15:20:00"
    }
  },
  "playlists": {
    "晨间唤醒": {
      "description": "早晨起床听的音乐",
      "song_ids": ["song_abc123", "song_def456"],
      "created_at": "2026-03-21T08:00:00"
    },
    "睡前故事": {
      "description": "哄孩子睡觉的轻柔音乐",
      "song_ids": ["song_ghi789"],
      "created_at": "2026-03-22T20:00:00"
    }
  },
  "tags": {
    "轻音乐": {"color": "#4CAF50", "category": "style", "count": 15},
    "古典": {"color": "#9C27B0", "category": "style", "count": 8},
    "浪漫": {"color": "#E91E63", "category": "mood", "count": 12},
    "睡前": {"color": "#3F51B5", "category": "scene", "count": 5}
  }
}

歌曲数据类

python 复制代码
@dataclass
class Song:
    """歌曲数据类。"""
    
    id: str
    title: str
    artist: str
    album: str = ""
    duration: int = 0  # 秒
    file_path: str = ""
    file_size: int = 0
    rating: int = 0  # 0-5
    favorite: bool = False
    tags: list = field(default_factory=list)
    play_count: int = 0
    added_at: str = ""
    last_played_at: str = ""
    
    @property
    def duration_formatted(self) -> str:
        """格式化时长 MM:SS"""
        minutes = self.duration // 60
        seconds = self.duration % 60
        return f"{minutes}:{seconds:02d}"
    
    @property
    def file_size_formatted(self) -> str:
        """格式化文件大小"""
        if self.file_size < 1024:
            return f"{self.file_size} B"
        elif self.file_size < 1024 * 1024:
            return f"{self.file_size / 1024:.1f} KB"
        else:
            return f"{self.file_size / 1024 / 1024:.1f} MB"

🏷️ 核心模块三:标签分类系统

内置标签定义

python 复制代码
# 内置标签定义
BUILTIN_TAGS = {
    # 风格标签
    "轻音乐": {"color": "#4CAF50", "category": "style"},
    "古典": {"color": "#9C27B0", "category": "style"},
    "流行": {"color": "#2196F3", "category": "style"},
    "摇滚": {"color": "#F44336", "category": "style"},
    "爵士": {"color": "#FF9800", "category": "style"},
    "电子": {"color": "#00BCD4", "category": "style"},
    "民乐": {"color": "#795548", "category": "style"},
    
    # 场景标签
    "晨间": {"color": "#FFC107", "category": "scene"},
    "工作": {"color": "#9E9E9E", "category": "scene"},
    "运动": {"color": "#FF5722", "category": "scene"},
    "休息": {"color": "#673AB7", "category": "scene"},
    "睡前": {"color": "#3F51B5", "category": "scene"},
    
    # 心情标签
    "欢快": {"color": "#FFEB3B", "category": "mood"},
    "平静": {"color": "#81D4FA", "category": "mood"},
    "浪漫": {"color": "#E91E63", "category": "mood"},
    "忧伤": {"color": "#607D8B", "category": "mood"},
    "振奋": {"color": "#FF9800", "category": "mood"},
    
    # 语言标签
    "中文": {"color": "#F44336", "category": "language"},
    "英文": {"color": "#2196F3", "category": "language"},
    "纯音乐": {"color": "#9C27B0", "category": "language"},
}

标签管理实现

python 复制代码
async def _create_tag(self, params: dict[str, Any]) -> ToolResult:
    """创建自定义标签。"""
    name = params.get("name")
    color = params.get("color", "#607D8B")
    category = params.get("category", "custom")
    
    if not name:
        return ToolResult(
            status=ToolResultStatus.ERROR,
            error="标签名称不能为空"
        )
    
    if name in self._library_data["tags"]:
        return ToolResult(
            status=ToolResultStatus.ERROR,
            error=f"标签 '{name}' 已存在"
        )
    
    self._library_data["tags"][name] = {
        "color": color,
        "category": category,
        "count": 0,
    }
    self._save_library()
    
    return ToolResult(
        status=ToolResultStatus.SUCCESS,
        output=f"已创建标签:{name}",
        data={"name": name, "color": color}
    )


async def _update_song_tags(self, params: dict[str, Any]) -> ToolResult:
    """更新歌曲标签。"""
    song_id = params.get("song_id")
    tags = params.get("tags", [])
    
    if not song_id or song_id not in self._library_data["songs"]:
        return ToolResult(
            status=ToolResultStatus.ERROR,
            error="歌曲不存在"
        )
    
    song = self._library_data["songs"][song_id]
    old_tags = set(song.get("tags", []))
    new_tags = set(tags)
    
    # 找出新增和删除的标签
    added_tags = new_tags - old_tags
    removed_tags = old_tags - new_tags
    
    # 更新标签计数
    for tag in added_tags:
        if tag in self._library_data["tags"]:
            self._library_data["tags"][tag]["count"] += 1
        else:
            # 自动创建新标签
            self._library_data["tags"][tag] = {
                "color": "#607D8B",
                "category": "custom",
                "count": 1
            }
    
    for tag in removed_tags:
        if tag in self._library_data["tags"]:
            self._library_data["tags"][tag]["count"] = max(0,
                self._library_data["tags"][tag]["count"] - 1)
    
    # 更新歌曲标签
    song["tags"] = list(new_tags)
    self._save_library()
    
    return ToolResult(
        status=ToolResultStatus.SUCCESS,
        output=f"已更新歌曲标签",
        data={
            "song_id": song_id,
            "added_tags": list(added_tags),
            "removed_tags": list(removed_tags)
        }
    )

标签搜索实现

python 复制代码
async def _search_songs(self, params: dict[str, Any]) -> ToolResult:
    """搜索歌曲。
    
    支持多种搜索方式:
    1. 按标题/艺术家/专辑
    2. 按标签
    3. 按评分/收藏
    """
    keyword = params.get("keyword", "").strip()
    tags = params.get("tags", [])
    favorites_only = params.get("favorites", False)
    min_rating = params.get("min_rating", 0)
    limit = params.get("limit", 20)
    
    results = []
    
    for song_id, song in self._library_data["songs"].items():
        # 关键词过滤
        if keyword:
            match = (
                keyword.lower() in song.get("title", "").lower() or
                keyword.lower() in song.get("artist", "").lower() or
                keyword.lower() in song.get("album", "").lower()
            )
            if not match:
                continue
        
        # 标签过滤
        if tags:
            song_tags = set(song.get("tags", []))
            if not any(tag in song_tags for tag in tags):
                continue
        
        # 收藏过滤
        if favorites_only and not song.get("favorite"):
            continue
        
        # 评分过滤
        if song.get("rating", 0) < min_rating:
            continue
        
        results.append(song)
    
    # 按播放次数排序
    results.sort(key=lambda s: s.get("play_count", 0), reverse=True)
    
    # 限制数量
    results = results[:limit]
    
    # 格式化输出
    if not results:
        return ToolResult(
            status=ToolResultStatus.SUCCESS,
            output="未找到符合条件的歌曲",
            data={"songs": [], "count": 0}
        )
    
    lines = [f"找到 {len(results)} 首歌曲:", "━" * 50]
    for i, song in enumerate(results, 1):
        favorite = "❤️" if song.get("favorite") else ""
        rating = "⭐" * song.get("rating", 0)
        duration = self._format_duration(song.get("duration", 0))
        lines.append(
            f"{i}. {song['title']} - {song['artist']} ({duration}) {favorite} {rating}"
        )
    
    return ToolResult(
        status=ToolResultStatus.SUCCESS,
        output="\n".join(lines),
        data={"songs": results, "count": len(results)}
    )

🎶 核心模块四:播放列表管理

播放列表 CRUD

python 复制代码
async def _create_playlist(self, params: dict[str, Any]) -> ToolResult:
    """创建播放列表。"""
    name = params.get("name")
    description = params.get("description", "")
    song_ids = params.get("song_ids", [])
    
    if not name:
        return ToolResult(
            status=ToolResultStatus.ERROR,
            error="播放列表名称不能为空"
        )
    
    if name in self._library_data["playlists"]:
        return ToolResult(
            status=ToolResultStatus.ERROR,
            error=f"播放列表 '{name}' 已存在"
        )
    
    # 过滤有效的歌曲 ID
    valid_song_ids = [
        sid for sid in song_ids
        if sid in self._library_data["songs"]
    ]
    
    self._library_data["playlists"][name] = {
        "description": description,
        "song_ids": valid_song_ids,
        "created_at": datetime.now().isoformat(),
    }
    self._save_library()
    
    return ToolResult(
        status=ToolResultStatus.SUCCESS,
        output=f"已创建播放列表:{name}({len(valid_song_ids)} 首歌曲)",
        data={"name": name, "song_count": len(valid_song_ids)}
    )


async def _add_to_playlist(self, params: dict[str, Any]) -> ToolResult:
    """添加歌曲到播放列表。"""
    playlist_name = params.get("playlist_name")
    song_ids = params.get("song_ids", [])
    
    if not playlist_name or playlist_name not in self._library_data["playlists"]:
        return ToolResult(
            status=ToolResultStatus.ERROR,
            error="播放列表不存在"
        )
    
    playlist = self._library_data["playlists"][playlist_name]
    added_count = 0
    
    for song_id in song_ids:
        if song_id in self._library_data["songs"]:
            if song_id not in playlist["song_ids"]:
                playlist["song_ids"].append(song_id)
                added_count += 1
    
    self._save_library()
    
    return ToolResult(
        status=ToolResultStatus.SUCCESS,
        output=f"已添加 {added_count} 首歌曲到播放列表「{playlist_name}」",
        data={"playlist_name": playlist_name, "added_count": added_count}
    )


async def _remove_from_playlist(self, params: dict[str, Any]) -> ToolResult:
    """从播放列表移除歌曲。"""
    playlist_name = params.get("playlist_name")
    song_ids = params.get("song_ids", [])
    
    if not playlist_name or playlist_name not in self._library_data["playlists"]:
        return ToolResult(
            status=ToolResultStatus.ERROR,
            error="播放列表不存在"
        )
    
    playlist = self._library_data["playlists"][playlist_name]
    removed_count = 0
    
    for song_id in song_ids:
        if song_id in playlist["song_ids"]:
            playlist["song_ids"].remove(song_id)
            removed_count += 1
    
    self._save_library()
    
    return ToolResult(
        status=ToolResultStatus.SUCCESS,
        output=f"已从播放列表「{playlist_name}」移除 {removed_count} 首歌曲",
        data={"playlist_name": playlist_name, "removed_count": removed_count}
    )

智能歌曲推荐

python 复制代码
async def _get_recommendations(self, params: dict[str, Any]) -> ToolResult:
    """获取歌曲推荐。
    
    推荐策略:
    1. 同标签歌曲
    2. 同艺术家歌曲
    3. 高评分歌曲
    4. 最近添加
    """
    current_song_id = params.get("current_song_id")
    limit = params.get("limit", 5)
    
    recommendations = []
    
    # 获取当前歌曲信息
    current_song = self._library_data["songs"].get(current_song_id)
    
    if current_song:
        current_tags = set(current_song.get("tags", []))
        current_artist = current_song.get("artist", "")
        
        for song_id, song in self._library_data["songs"].items():
            if song_id == current_song_id:
                continue
            
            score = 0
            
            # 标签匹配
            song_tags = set(song.get("tags", []))
            tag_match = len(current_tags & song_tags)
            score += tag_match * 3
            
            # 同艺术家
            if song.get("artist") == current_artist:
                score += 5
            
            # 高评分
            score += song.get("rating", 0)
            
            # 高播放次数
            score += min(song.get("play_count", 0) / 10, 5)
            
            recommendations.append((song, score))
    
    # 如果没有当前歌曲,按评分和播放次数推荐
    if not recommendations:
        for song_id, song in self._library_data["songs"].items():
            score = song.get("rating", 0) * 2 + min(song.get("play_count", 0) / 10, 5)
            recommendations.append((song, score))
    
    # 排序并取前 N 个
    recommendations.sort(key=lambda x: x[1], reverse=True)
    results = [song for song, _ in recommendations[:limit]]
    
    return ToolResult(
        status=ToolResultStatus.SUCCESS,
        output=f"推荐 {len(results)} 首歌曲",
        data={"recommendations": results}
    )

🔊 核心模块五:Audio Ducking 与 TTS 协调

Audio Ducking 原理

Audio Ducking(音频闪避)是一种音频处理技术,当语音播报开始时,自动降低背景音乐的音量,播报结束后恢复。

应用场景

  • 🎵 播放音乐时,突然需要语音播报
  • 📢 导航播报
  • 🔔 系统通知

控制器实现

python 复制代码
class MusicPlayerController:
    """全局音乐播放器控制器。
    
    单例模式,用于在工具层和 UI 层之间传递播放指令。
    """
    
    _instance: Optional[MusicPlayerController] = None
    
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._initialized = False
        return cls._instance
    
    def __init__(self):
        if self._initialized:
            return
        
        self._initialized = True
        self._callbacks: dict[str, list[Callable]] = {
            "play": [],
            "pause": [],
            "resume": [],
            "stop": [],
            "next": [],
            "prev": [],
            "seek": [],
            "set_volume": [],
        }
        
        # Audio Ducking 状态
        self._ducking_active = False
        self._original_volume = 0.8
    
    def register_callback(self, event: str, callback: Callable) -> None:
        """注册事件回调。"""
        if event in self._callbacks:
            self._callbacks[event].append(callback)
    
    def unregister_callback(self, event: str, callback: Callable) -> None:
        """取消注册事件回调。"""
        if event in self._callbacks:
            self._callbacks[event] = [
                cb for cb in self._callbacks[event]
                if cb != callback
            ]
    
    def emit(self, event: str, **kwargs) -> None:
        """触发事件。"""
        for callback in self._callbacks.get(event, []):
            try:
                callback(**kwargs)
            except Exception as e:
                logger.error(f"回调执行失败:{e}")
    
    # Audio Ducking 控制
    def start_ducking(self, target_volume: float = 0.1) -> None:
        """开始 Audio Ducking。
        
        Args:
            target_volume: Ducking 时的目标音量(0.0-1.0)
        """
        if self._ducking_active:
            return
        
        self._ducking_active = True
        self.emit("set_volume", volume=target_volume)
        logger.info(f"Audio Ducking 开始,目标音量:{target_volume}")
    
    def stop_ducking(self) -> None:
        """停止 Audio Ducking,恢复原始音量。"""
        if not self._ducking_active:
            return
        
        self._ducking_active = False
        self.emit("set_volume", volume=self._original_volume)
        logger.info("Audio Ducking 结束,恢复音量")

TTS 协调集成

python 复制代码
class TTSSpeaker:
    """TTS 播报器,与音乐播放器协调。"""
    
    def __init__(self, music_controller: MusicPlayerController):
        self._controller = music_controller
    
    async def speak(self, text: str) -> None:
        """播报文本,期间降低背景音乐音量。"""
        # 开始 Ducking
        self._controller.start_ducking(target_volume=0.1)
        
        try:
            # 执行 TTS 播报
            await self._tts_engine.speak(text)
        finally:
            # 播报结束后恢复音乐
            self._controller.stop_ducking()

🗂️ 核心模块六:本地音乐扫描

文件扫描器

python 复制代码
async def _scan_folder(self, params: dict[str, Any]) -> ToolResult:
    """扫描本地音乐文件夹。
    
    扫描流程:
    1. 遍历文件夹所有文件
    2. 筛选支持的音频格式
    3. 读取音频元数据( mutagen)
    4. 提取 ID3 标签
    5. 生成歌曲 ID
    6. 保存到歌曲库
    """
    folder_path = params.get("folder_path", self._default_music_folder)
    recursive = params.get("recursive", True)
    
    if not folder_path or not Path(folder_path).exists():
        return ToolResult(
            status=ToolResultStatus.ERROR,
            error=f"文件夹不存在:{folder_path}"
        )
    
    folder = Path(folder_path)
    added_count = 0
    skipped_count = 0
    
    # 查找所有音频文件
    audio_files = []
    
    if recursive:
        for ext in SUPPORTED_AUDIO_FORMATS:
            audio_files.extend(folder.rglob(f"*{ext}"))
    else:
        for ext in SUPPORTED_AUDIO_FORMATS:
            audio_files.extend(folder.glob(f"*{ext}"))
    
    logger.info(f"扫描文件夹:{folder},找到 {len(audio_files)} 个音频文件")
    
    for file_path in audio_files:
        # 读取元数据
        metadata = await self._extract_metadata(file_path)
        
        if not metadata:
            skipped_count += 1
            continue
        
        # 生成歌曲 ID
        song_id = self._generate_song_id(metadata, file_path)
        
        # 检查是否已存在
        if song_id in self._library_data["songs"]:
            skipped_count += 1
            continue
        
        # 添加到歌曲库
        song_data = {
            "id": song_id,
            "title": metadata.get("title", file_path.stem),
            "artist": metadata.get("artist", "未知艺术家"),
            "album": metadata.get("album", "未知专辑"),
            "duration": metadata.get("duration", 0),
            "file_path": str(file_path),
            "file_size": file_path.stat().st_size,
            "rating": 0,
            "favorite": False,
            "tags": [],
            "play_count": 0,
            "added_at": datetime.now().isoformat(),
        }
        
        self._library_data["songs"][song_id] = song_data
        added_count += 1
    
    # 保存歌曲库
    self._save_library()
    
    output = f"扫描完成!\n"
    output += f"新增歌曲:{added_count}\n"
    output += f"跳过(已存在):{skipped_count}"
    
    return ToolResult(
        status=ToolResultStatus.SUCCESS,
        output=output,
        data={"added": added_count, "skipped": skipped_count}
    )


async def _extract_metadata(self, file_path: Path) -> dict | None:
    """提取音频文件元数据。
    
    使用 mutagen 库读取音频文件的 ID3 标签。
    """
    try:
        from mutagen import File
        
        audio = File(str(file_path))
        if audio is None:
            return None
        
        # 提取标签
        tags = audio.tags or {}
        
        # 获取时长
        duration = int(audio.info.duration) if hasattr(audio.info, 'duration') else 0
        
        # 获取标题
        title = None
        if hasattr(tags, 'get') and tags.get('TIT2'):
            title = str(tags.get('TIT2', [file_path.stem])[0])
        
        # 获取艺术家
        artist = None
        if hasattr(tags, 'get') and tags.get('TPE1'):
            artist = str(tags.get('TPE1', ['未知艺术家'])[0])
        
        # 获取专辑
        album = None
        if hasattr(tags, 'get') and tags.get('TALB'):
            album = str(tags.get('TALB', ['未知专辑'])[0])
        
        return {
            "title": title or file_path.stem,
            "artist": artist or "未知艺术家",
            "album": album or "未知专辑",
            "duration": duration,
        }
        
    except ImportError:
        logger.warning("mutagen 库未安装,无法读取音频元数据")
        # 回退方案:使用文件名作为标题
        return {
            "title": file_path.stem,
            "artist": "未知艺术家",
            "album": "未知专辑",
            "duration": 0,
        }
    except Exception as e:
        logger.error(f"读取元数据失败:{e}")
        return None

📊 测试验证

功能测试

测试项 预期 结果
播放控制 播放/暂停/切歌 ✅ 通过
音量调节 0-100% 范围 ✅ 通过
循环模式 单曲/列表/关闭 ✅ 通过
随机播放 随机选取歌曲 ✅ 通过
标签管理 创建/更新/删除 ✅ 通过
播放列表 CRUD 操作 ✅ 通过
本地扫描 提取元数据 ✅ 通过
Audio Ducking 语音时降音量 ✅ 通过

性能指标

操作 平均耗时 备注
播放响应 < 100ms 用户无感知延迟
扫描 100 首 ~8 秒 含元数据提取
标签搜索 < 50ms 内存操作
文件 I/O < 200ms 保存歌曲库

💡 经验教训

1. QMediaPlayer 文件路径问题

教训 :直接传文件路径给 setSource 失败。

解决方案:必须转换为 QUrl

python 复制代码
url = QUrl.fromLocalFile(str(Path(file_path)))
self._player.setSource(url)

2. 音频格式兼容性

教训:某些 FLAC 文件在 Windows 上播放失败。

解决方案:QtMultimedia 对 WMA/AAC 支持依赖系统编解码器

python 复制代码
# 检查格式支持
if file_path.suffix.lower() in {".mp3", ".wav", ".flac"}:
    # 使用内置支持
else:
    # 提示用户转换格式

3. Audio Ducking 时序问题

教训:Ducking 结束后音乐音量恢复太快,用户体验差。

解决方案:渐变恢复

python 复制代码
def _fade_volume(self, from_vol: float, to_vol: float, steps: int = 10):
    """渐变调整音量。"""
    delta = (to_vol - from_vol) / steps
    for i in range(steps):
        self._audio_output.setVolume(from_vol + delta * i)
        time.sleep(0.05)  # 50ms 间隔

4. 工具层与 UI 层耦合

教训:初期将播放逻辑写在 UI 层,导致难以测试。

解决方案:三层分离

复制代码
Tool 层(业务逻辑) → Controller 层(事件分发) → UI 层(界面展示)

📊 架构总结

完整数据流

复制代码
用户语音指令
    ↓
MusicPlayerTool(搜索歌曲、匹配标签)
    ↓
MusicPlayerController(事件分发)
    ↓
MiniPlayerPanel(QtMultimedia 播放)
    ↓
QMediaPlayer → QAudioOutput → 扬声器

关键技术栈

层次 技术 用途
UI 框架 PySide6 QtWidgets、Signal/Slot
音频引擎 QtMultimedia QMediaPlayer、QAudioOutput
元数据 mutagen ID3 标签读取
控制器 单例模式 事件分发
数据存储 JSON 歌曲库持久化

字数统计 : 约 7,200 字
阅读时间 : 约 18 分钟
代码行数: 约 650 行


上一篇文章回顾: 《课程表系统设计:iCalendar 标准与家庭生活日程管理》------深入剖析课程表与智能姓名匹配。

下一篇文章预告: 《CFTA 异步调用链优化:从阻塞 15 秒到非阻塞并发》------如何优化工具调用的并发性能。

相关推荐
优选资源分享6 小时前
小白转文字 v1.2.8.0 | 安卓离线免费音视频转写工具
android·音视频
不才小强6 小时前
Qt开发实战:屏幕录制项目中学习到的知识与遇到的难题
qt·音视频
要开心吖ZSH7 小时前
MP4 转 WAV 音频转码方案详解(ProcessBuilder + FFmpeg)
java·ffmpeg·音视频
deepdata_cn7 小时前
移动端高并发视频合成
音视频·视频合成
潜创微科技--高清音视频芯片方案开发1 天前
2026年对拷线芯片实用对比分析:从需求到选型的全维度指南
音视频·硬件工程
愚公搬代码1 天前
【愚公系列】《剪映+DeepSeek+即梦:短视频制作》033-调色:废片秒变氛围感大片(HSL的精准调节)
音视频
不才小强1 天前
macOS 屏幕录制开发完全指南:ScreenCaptureKit与音频采集实战
macos·音视频
轻口味2 天前
HarmonyOS 6 NDK开发系列1:音视频播放能力介绍
华为·音视频·harmonyos