音乐播放器开发: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 秒到非阻塞并发》------如何优化工具调用的并发性能。

相关推荐
TechVoyager_82464 小时前
多形态输入与12 通道音频:IT6615 这颗 HDMI 发射器的接口宽度
音视频·音频芯片·芯片选型·视频发射
Raymond16885 小时前
AI配音工具长文本合成架构技术解析:全局音色锚定与声学状态跟踪
人工智能·架构·实时互动·视觉检测·音视频·语音识别
hflmwl811 小时前
Wise Data Recovery找回误删的文档照片音乐和视频
音视频·datarecovery·找回误删的文档照片音乐和视频·找回误删的文档照片音乐视频
技灵AI11 小时前
AI 视频转场提示词怎么写?让前后画面衔接更自然
人工智能·prompt·aigc·音视频
youandyouyou13 小时前
跨区培训的屏幕共享怎么做才稳?同屏同步与弱网优化实践
音视频·网络通信
开开心心就好13 小时前
课堂随机点名工具,绿色版免安装双击即用
java·服务器·前端·ffmpeg·音视频·perl·symfony
美狐美颜SDK开放平台14 小时前
直播APP集成美颜SDK,如何梳理第三方组件的数据权限?
开发语言·人工智能·深度学习·音视频·第三方美颜sdk
l143723326715 小时前
批量视频硬字幕擦除多少钱?影响报价的关键因素全解析
音视频
重生六道15 小时前
B站视频时长统计:一段JS脚本搞定分P/合集总时长(附倍速计算)
开发语言·javascript·音视频
音视频牛哥15 小时前
不换摄像机,也能接入 WebRTC?SmartMediaKit RTSP/RTMP 转 WHIP 技术实践
音视频·webrtc·smartmediakit·rtsp转whip·rtmp转whip·whip推流·rtsp转webrtc播放