# Inchie **Repository Path**: flashpig8014/inchie ## Basic Information - **Project Name**: Inchie - **Description**: Inchie(音吃)音乐播放器,Rust + Slint UI - **Primary Language**: Unknown - **License**: GPL-2.0 - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2026-07-28 - **Last Updated**: 2026-07-28 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 音频播放器设计方案(Rust + Slint) ## 1. 项目概述 基于 Rust + Slint UI 开发的桌面音频播放器,支持多种音频格式、自定义播放列表(SQLite 持久化)、播放进度控制以及基于频谱分析的律动动画。 ## 2. 技术选型 | 模块 | 技术/库 | 说明 | |------|---------|------| | UI 框架 | `slint` (>=1.6) | 声明式 UI,支持 winit 后端,动画能力强 | | 音频解码 | `symphonia` | 纯 Rust 解码库,支持格式最全 | | 音频输出 | `cpal` | 跨平台底层音频输出(symphonia 解码后直接推流,便于拿到 PCM 数据做频谱) | | 重采样 | `rubato` | 解码采样率与设备采样率不一致时重采样 | | 频谱分析 | `rustfft` | FFT 计算,驱动律动动画 | | 数据库 | `rusqlite` (bundled) | SQLite 持久化播放列表 | | 元数据 | `symphonia`(内置 metadata)+ `lofty`(补充封面/标签) | 读取标题、艺术家、专辑、封面 | | 文件对话框 | `rfd` | 打开文件/文件夹选择器 | | 系统托盘 | `tray-icon` | 托盘图标 + 托盘菜单(配合 Slint winit 后端) | | 图标资源 | `image` | 托盘图标解码 | | 异步/线程 | `std::thread` + `crossbeam-channel` | 播放引擎独立线程,消息驱动 | | 日志 | `tracing` + `tracing-subscriber` | 调试与错误跟踪 | > 说明:不选用 `rodio` 的原因——rodio 封装较深,难以在播放链路中截获 PCM 数据做频谱分析,且 seek 支持有限。直接用 `symphonia 解码 + cpal 输出` 可以完全掌控播放链路,实现精确 seek 与实时频谱。 ### 2.1 支持的音频格式(symphonia features 全开) - MP3、AAC (ADTS/M4A)、FLAC、WAV、OGG Vorbis、ALAC、AIFF、CAF、MKV/WebM 容器、MP4/M4A 容器 - 后续可选:Opus(`symphonia` 暂不支持解码,可用 `opus` crate 扩展) ## 3. 总体架构 ``` ┌────────────────────────────────────────────────────┐ │ UI 层 (Slint) │ │ 主窗口 / 播放列表面板 / 进度条 / 控制栏 / 律动动画 │ └──────────────┬─────────────────────▲───────────────┘ │ 命令 (invoke) │ 状态回调 (upgrade_in_event_loop) ┌──────────────▼─────────────────────┴───────────────┐ │ 应用控制层 (AppController) │ │ 命令分发 / 状态聚合 / 播放列表逻辑 / 定时器刷新 │ └───────┬────────────────────────────┬───────────────┘ │ │ ┌───────▼───────────┐ ┌─────────▼───────────────┐ │ 播放引擎 (独立线程) │ │ 数据层 (rusqlite) │ │ PlayerEngine │ │ PlaylistRepo / │ │ - symphonia 解码 │ │ TrackRepo │ │ - cpal 输出 │ │ (SQLite 文件) │ │ - seek / 音量 │ └─────────────────────────┘ │ - PCM tap → FFT │ └────────────────────┘ ``` ### 3.1 线程模型 | 线程 | 职责 | |------|------| | 主线程(Slint 事件循环) | UI 渲染、用户交互、通过 Timer 定时拉取播放状态与频谱数据 | | 播放引擎线程 | 解码循环:读包 → 解码 → 重采样 → 写入 ring buffer;响应 Play/Pause/Seek/SetVolume 命令 | | cpal 回调线程 | 从 ring buffer 取 PCM 推给声卡;同时把样本副本写入频谱分析缓冲 | 线程间通信: - UI → 引擎:`crossbeam_channel::Sender` - 引擎 → UI:`Arc`(原子变量存 position/duration/state)+ `slint::invoke_from_event_loop` 推送事件(曲目结束、错误等) - 频谱数据:`Arc>>` 采样缓冲,UI Timer 每帧取出做 FFT ### 3.2 核心消息定义 ```rust enum PlayerCommand { Load(PathBuf), // 加载并播放 Play, Pause, Stop, Seek(Duration), // 进度控制 SetVolume(f32), // 0.0 ~ 1.0 } enum PlayerEvent { TrackLoaded { duration: Duration, meta: TrackMeta }, Progress(Duration), TrackEnded, // 触发自动切歌 Error(String), } enum PlaybackState { Stopped, Playing, Paused } ``` ## 4. 模块设计 ### 4.1 目录结构 ``` audio-player/ ├── Cargo.toml ├── build.rs # slint-build 编译 .slint ├── ui/ │ ├── app.slint # 主窗口,组合各组件 │ ├── components/ │ │ ├── top_bar.slint # 顶栏(面板开关 + 窗口按钮) │ │ ├── player_bar.slint # 底部控制栏(播放/暂停/上下曲/音量/进度条) │ │ ├── playlist.slint # 播放列表面板 │ │ ├── track_list.slint # 曲目列表 │ │ └── visualizer/ │ │ ├── mod.slint # Visualizer 封装(按 style 分发) │ │ ├── bars.slint # 柱状图 │ │ ├── circular.slint# 圆形 │ │ ├── waveform.slint# 波形 │ │ └── dots.slint # 点阵 │ └── theme.slint # 颜色、字体、尺寸常量 └── src/ ├── main.rs # 入口:初始化日志/DB/引擎/UI,绑定回调 ├── controller.rs # AppController:UI 命令分发与状态同步 ├── player/ │ ├── mod.rs │ ├── engine.rs # 播放引擎线程主循环 │ ├── decoder.rs # symphonia 解码封装(open/seek/next_frame) │ ├── output.rs # cpal 输出流 + ring buffer │ └── resampler.rs # rubato 重采样封装 ├── spectrum.rs # FFT 频谱分析(汉宁窗 + 频段聚合 + 平滑)+ 波形降采样 ├── tray.rs # 系统托盘:图标、菜单、事件转发 ├── db/ │ ├── mod.rs # 连接管理 + migration │ ├── playlist_repo.rs # 播放列表 CRUD │ └── track_repo.rs # 曲目 CRUD ├── metadata.rs # 标签/封面读取 └── models.rs # Track / Playlist / TrackMeta 等领域模型 ``` ### 4.2 播放引擎(player/engine.rs) 解码循环伪代码: ```rust loop { // 1. 非阻塞处理命令(Seek 时调用 format_reader.seek 并 reset decoder) while let Ok(cmd) = cmd_rx.try_recv() { handle(cmd); } if state != Playing { park_timeout(10ms); continue; } // 2. 解码一个 packet → PCM (f32 interleaved) let frame = decoder.next_frame()?; // None => TrackEnded let frame = resampler.process(frame); // 匹配设备采样率 // 3. 写入 ring buffer(满则阻塞等待,实现自然背压) ring_buffer.push_blocking(&frame); // 4. 更新共享进度(AtomicU64 存毫秒) shared.position_ms.store(current_ts_ms); } ``` 关键点: - **精确 seek**:`FormatReader::seek(SeekMode::Accurate, ts)` + `decoder.reset()`,seek 后清空 ring buffer 避免残留旧数据; - **进度显示**:UI 端进度 = 引擎解码位置 − ring buffer 中未播放时长(用缓冲区水位换算),保证进度条与听感一致; - **曲目结束**:解码到 EOF 且 ring buffer 排空后发送 `TrackEnded`,控制层按播放模式(顺序/循环/单曲/随机)决定下一曲。 ### 4.3 数据层(SQLite) 数据库文件:`{data_dir}/audio-player/library.db`(`dirs` crate 获取平台数据目录)。 ```sql PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS tracks ( id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT NOT NULL UNIQUE, -- 绝对路径 title TEXT, artist TEXT, album TEXT, duration_ms INTEGER, added_at INTEGER NOT NULL -- unix 时间戳 ); CREATE TABLE IF NOT EXISTS playlists ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS playlist_tracks ( playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, position INTEGER NOT NULL, -- 列表内排序 PRIMARY KEY (playlist_id, track_id) ); CREATE TABLE IF NOT EXISTS settings ( -- 音量/上次列表/播放模式/律动样式/面板显隐/托盘行为 key TEXT PRIMARY KEY, value TEXT ); ``` Repo 接口(示意): ```rust trait PlaylistRepo { fn create(&self, name: &str) -> Result; fn rename(&self, id: i64, name: &str) -> Result<()>; fn delete(&self, id: i64) -> Result<()>; fn list_all(&self) -> Result>; fn tracks_of(&self, id: i64) -> Result>; // 按 position 排序 fn add_tracks(&self, id: i64, tracks: &[i64]) -> Result<()>; fn remove_track(&self, id: i64, track_id: i64) -> Result<()>; fn reorder(&self, id: i64, track_id: i64, new_pos: i32) -> Result<()>; } ``` DB 操作量小且都是用户触发,直接在主线程同步执行(rusqlite 单连接 + `Mutex`),无需额外线程。 ### 4.4 律动动画(spectrum.rs + visualizer/*.slint) 数据流: ``` cpal 回调 ──copy──▶ 采样缓冲(2048 samples) ──UI Timer(~30fps)──▶ ├─ 频谱路径:加汉宁窗 → rustfft(2048点) → 幅度谱 → 对数频率聚合为 N=64 频段 │ → dB 映射到 0~1 → 指数平滑(attack 快/decay 慢) → bands 属性 └─ 波形路径:降采样至 256 点(min/max 包络) → waveform 属性 ``` Rust 端同时产出两组数据(`bands: [float]` 频谱、`waveform: [float]` 波形), 各样式组件按需消费,切换样式无需改动数据管线。 #### 律动样式(可切换) | 样式 | 组件 | 数据源 | 渲染方式 | |------|------|--------|----------| | 柱状图 Bars | `bars.slint` | bands | 64 根渐变圆角柱,`for` + Rectangle | | 圆形 Circular | `circular.slint` | bands | 柱体沿圆周径向排列(`Path`/旋转 Rectangle),中心可放封面 | | 波形 Waveform | `waveform.slint` | waveform | `Path` 折线/镜像填充波形 | | 点阵 Dots | `dots.slint` | bands | N×M 点阵,按频段能量自下而上点亮,模拟 LED 矩阵 | | 关闭 Off | — | — | 隐藏组件并停止 FFT 计算(省 CPU) | 统一封装: ```slint export enum VisualizerStyle { bars, circular, waveform, dots, off } export component Visualizer inherits Rectangle { in property style; in property <[float]> bands; in property <[float]> waveform; // 内部按 style 用 if 条件实例化对应子组件 } ``` - 样式通过律动区右上角的小按钮循环切换或下拉选择,选择持久化到 `settings` 表; - 暂停/停止时数值平滑衰减到 0; - `style == off` 时 Rust 端跳过 FFT,降低空闲开销; - 频段数、点阵行列数、平滑系数做成常量便于调参。 ### 4.5 UI 设计(app.slint) 布局原则:**简洁、模块化**。除底部控制栏常驻外,其余面板均可独立开关, 关闭后主窗口自适应收缩(`if` 条件布局),面板开关状态持久化到 `settings` 表。 ``` ┌─────────────────────────────────────────────────────┐ │ 顶栏:[☰列表] [♫曲目] [∿律动] [—] [□] [×] │ ← 面板开关 + 窗口按钮 ├──────────┬──────────────────────────────────────────┤ │ 播放列表 │ 当前列表曲目 │ ← 两个面板均可关闭 │ ▸ 默认 │ 01 曲名 - 艺术家 03:45 │ │ ▸ 摇滚 │ 02 ...(双击播放,右键菜单删除) │ │ [+新建] │ │ ├──────────┴──────────────────────────────────────────┤ │ 律动动画 (Visualizer) [样式▾] │ ← 可关闭、样式可切换 ├─────────────────────────────────────────────────────┤ │ 曲名 - 艺术家 │ │ 01:23 ━━━━━━●─────────────── 03:45 ← 可拖动/点击 │ │ [模式] [⏮] [⏯] [⏭] 🔊 ━━●── │ ← 控制栏常驻 └─────────────────────────────────────────────────────┘ ``` #### 面板开关机制 - 顶栏三个 toggle 按钮分别控制:播放列表面板、曲目面板、律动面板; - 全部关闭时窗口退化为"迷你条"模式(仅顶栏 + 控制栏),适合挂角落听歌; - 面板显隐使用 Slint `if` 条件元素 + 布局权重,配合 `animate` 做展开/收起过渡; - 律动面板关闭时同步停掉 FFT 计算。 #### 系统托盘(最小化到托盘) - 使用 `tray-icon` crate,与 Slint winit 后端共用事件循环(Windows 下托盘消息由 win32 消息泵驱动,兼容良好); - 行为设计: - 点击"最小化"按钮或关闭按钮(可配置)→ `window.hide()` 隐藏到托盘,播放不中断; - 单击/双击托盘图标 → 恢复并前置窗口; - 托盘右键菜单:`显示主界面 / 播放‑暂停 / 上一曲 / 下一曲 / 退出`; - 托盘 tooltip 显示当前曲目"标题 - 艺术家"; - "关闭时最小化到托盘"开关存入 `settings` 表; - 退出路径统一走托盘菜单"退出"或设置为直接关闭:停止引擎线程 → 保存状态 → `slint::quit_event_loop()`。 关键交互: - **进度条**:拖动中只更新 UI 预览时间(`seeking` 标志位挂起 Timer 刷新),松手才发 `Seek` 命令,避免拖动时进度条跳动; - **播放列表**:左侧列表 CRUD;"添加文件/文件夹"用 `rfd` 弹窗,文件夹递归扫描按扩展名过滤; - **播放模式**:顺序 / 列表循环 / 单曲循环 / 随机,状态存入 `settings` 表; - **曲目列表**:`slint::VecModel` 绑定,双击播放,高亮当前播放行。 ### 4.6 UI ↔ Rust 接口(app.slint 全局) ```slint export global PlayerBridge { // 状态(Rust → UI) in property playing; in property position; // 秒 in property duration; // 秒 in property current-title; in property current-artist; in property <[float]> spectrum; // 频谱数据(bands) in property <[float]> waveform; // 波形数据 in property <[PlaylistItem]> playlists; in property <[TrackItem]> tracks; // 布局/外观(双向,Rust 端负责持久化) in-out property show-playlist-panel; in-out property show-track-panel; in-out property show-visualizer; in-out property visualizer-style; // 命令(UI → Rust) callback toggle-play(); callback next(); callback prev(); callback seek(float); // 秒 callback set-volume(float); callback play-track(int); // 索引 callback create-playlist(string); callback delete-playlist(int); callback select-playlist(int); callback add-files(); callback add-folder(); callback remove-track(int); callback cycle-play-mode(); callback set-visualizer-style(VisualizerStyle); callback toggle-panel(string); // "playlist" | "tracks" | "visualizer" callback minimize-to-tray(); callback quit-app(); } ``` ## 5. 关键流程 ### 5.1 播放一首曲目 1. UI 双击曲目 → `play-track(idx)` 回调; 2. Controller 查出 Track.path,发送 `PlayerCommand::Load(path)`; 3. 引擎线程:probe 格式 → 建解码器 → 读取时长/元数据 → 发 `TrackLoaded` 事件 → 进入解码循环; 4. Controller 收到事件后更新 UI 属性(标题、时长、高亮行); 5. UI Timer 每 100ms 从 `SharedState` 读进度刷新进度条,每 33ms 刷新频谱。 ### 5.2 拖动进度条 1. 按下:`seeking = true`,Timer 暂停覆盖进度值; 2. 拖动:UI 本地更新预览时间文本; 3. 松开:`seek(pos)` → `PlayerCommand::Seek` → 引擎 seek + 清 ring buffer → `seeking = false`。 ## 6. 错误处理 - 解码失败/文件缺失:发 `PlayerEvent::Error`,UI 弹 toast 并自动跳下一曲;曲目行标灰; - DB 错误:`anyhow` 包装,记录日志并提示; - 设备热拔插:cpal 流错误回调中重建输出流。 ## 7. 开发里程碑 | 阶段 | 内容 | 验收标准 | |------|------|----------| | M1 骨架 | 工程搭建、slint-build、主窗口静态 UI | 窗口可运行,布局完整 | | M2 播放核心 | symphonia+cpal 引擎,播放/暂停/音量 | 常见格式可播放 | | M3 进度控制 | 进度显示、精确 seek、拖动交互 | 拖动无跳变、seek 准确 | | M4 播放列表 | SQLite 建库、列表 CRUD、文件导入、切歌与播放模式 | 重启后数据保留 | | M5 律动动画 | FFT/波形数据管线、四种样式(柱状/圆形/波形/点阵)+ 样式切换 | 30fps 流畅律动,样式可选 | | M6 布局与托盘 | 面板独立开关、迷你条模式、系统托盘(隐藏/恢复/托盘菜单) | 面板状态持久化,托盘功能完整 | | M7 打磨 | 元数据/封面、设置持久化、错误处理、主题美化 | 整体体验完整 | ## 8. 主要依赖(Cargo.toml 摘要) ```toml [dependencies] slint = "1.8" symphonia = { version = "0.5", features = ["all"] } cpal = "0.15" rubato = "0.15" rustfft = "6" rusqlite = { version = "0.31", features = ["bundled"] } lofty = "0.21" rfd = "0.14" tray-icon = "0.19" image = "0.25" # 托盘图标解码 crossbeam-channel = "0.5" ringbuf = "0.4" dirs = "5" anyhow = "1" tracing = "0.1" tracing-subscriber = "0.3" rand = "0.8" # 随机播放 [build-dependencies] slint-build = "1.8" ```