diff --git "a/.cursor/plans/v0.2_ui\350\277\255\344\273\243\350\247\204\345\210\222_abf59389.plan.md" "b/.cursor/plans/v0.2_ui\350\277\255\344\273\243\350\247\204\345\210\222_abf59389.plan.md"
new file mode 100644
index 0000000..3aa5e55
--- /dev/null
+++ "b/.cursor/plans/v0.2_ui\350\277\255\344\273\243\350\247\204\345\210\222_abf59389.plan.md"
@@ -0,0 +1,110 @@
+---
+name: v0.2 UI迭代规划
+overview: 围绕 `todo.md` 的 v0.2.0 条目,优先完成高观感可视化与 UI 升级,同时为后续扩展更多音频面板预留可扩展接口(不在本迭代落地用户配置文件)。
+todos:
+ - id: frame-contract
+ content: 扩展 VisualFrame 数据契约并在 AppController frame_provider 注入新增指标
+ status: completed
+ - id: ui-panelization
+ content: 将 TuiRenderer 重构为面板化布局并移除固定行号鼠标映射耦合
+ status: completed
+ - id: visual-upgrades
+ content: 实现频谱峰值保持/衰减与波形双模式显示,并新增至少两类音频信息面板
+ status: completed
+ - id: theme-v1
+ content: 实现内置主题系统与运行时切换,预留配置接口不落地文件解析
+ status: completed
+ - id: qa-doc-sync
+ content: 补齐测试并完成构建测试、双语文档与 changelog 同步
+ status: completed
+isProject: false
+---
+
+# v0.2.0 可视化与 UI 综合迭代计划
+
+## 目标与边界
+
+- 目标:在一次迭代内显著提升频谱/波形的观感与可读性,并引入“可扩展面板框架”以支持更多音频信息可视化。
+- 你已确认的范围:
+ - 尽量丰富可视化类型与面板(`full_explore`)。
+ - 主题系统先做可扩展接口与内置能力,用户配置文件后置(`decide_later`)。
+- 非目标:不实现用户主题配置文件解析;不展开长期 CUDA/Rust 方向。
+
+## 现状基线(将复用的代码)
+
+- UI 与交互主入口在 [`/home/virtualguard/vg101/dev/vocalplayer/src/ui/tui_renderer.cpp`](/home/virtualguard/vg101/dev/vocalplayer/src/ui/tui_renderer.cpp) 与 [`/home/virtualguard/vg101/dev/vocalplayer/src/ui/tui_renderer.hpp`](/home/virtualguard/vg101/dev/vocalplayer/src/ui/tui_renderer.hpp)。
+- 可视化数据快照由 [`/home/virtualguard/vg101/dev/vocalplayer/src/shared/types.hpp`](/home/virtualguard/vg101/dev/vocalplayer/src/shared/types.hpp) 的 `VisualFrame` 承载。
+- 数据生成与注入由 [`/home/virtualguard/vg101/dev/vocalplayer/src/app/app_controller.cpp`](/home/virtualguard/vg101/dev/vocalplayer/src/app/app_controller.cpp) 的 `frame_provider` 完成。
+- 频谱/波形算法在 [`/home/virtualguard/vg101/dev/vocalplayer/src/analysis/spectrum_analyzer.cpp`](/home/virtualguard/vg101/dev/vocalplayer/src/analysis/spectrum_analyzer.cpp)。
+
+## 方案总览(先稳接口,再做重视觉)
+
+```mermaid
+flowchart LR
+ audioEngine[AudioEngineWindow] --> analyzerLayer[SpectrumAndMetricAnalyzer]
+ analyzerLayer --> visualFrame[VisualFrameExtended]
+ visualFrame --> tuiRenderer[TuiRendererPanels]
+ tuiRenderer --> uiIntent[UiIntent]
+ uiIntent --> appController[AppControllerState]
+```
+
+- 先扩展 `VisualFrame` 的“数据契约”,再重构 `TuiRenderer` 为面板化渲染,最后叠加主题与动画。
+- 保持 `UiIntent -> AppController` 的控制链不变,避免交互回归。
+
+## 实施阶段
+
+### 阶段 A:数据契约扩展(低风险)
+
+- 在 `VisualFrame` 增加可选/默认字段,用于承载新增可视化数据:
+ - 电平类:`rms_level`、`peak_level`。
+ - 频段类:`band_energy`(如低/中/高三段)。
+ - 视图控制类:`visual_mode`(后续面板切换状态)。
+- 在 `AppController` 的 `frame_provider` 中填充新字段,先用轻量算法(基于现有 mono window 的 O(n) 统计)。
+- 保持所有新字段“缺省可渲染”,保证旧布局兼容。
+
+### 阶段 B:TUI 面板化与布局升级(核心价值)
+
+- 在 `TuiRenderer` 中将当前单体 `vbox` 拆为稳定区块:
+ - 顶栏(曲目状态/时间/进度)。
+ - 主可视化区(频谱 + 波形 + 新音频信息面板)。
+ - 播放列表区(含状态提示)。
+ - 底栏(快捷键与当前模式)。
+- 去除与鼠标映射强耦合的固定行常量(如 `kPlaylistStartY`),改为基于组件结构的相对命中策略,避免后续加行导致点击偏移。
+- 新增显示模式切换(例如 `Overview` / `SpectrumFocus` / `WaveFocus` / `Meters`),先用键盘切换并显示当前模式。
+
+### 阶段 C:可视化效果升级(观感提升)
+
+- 频谱:加入峰值保持(peak hold)与平滑衰减;可选对数分桶(至少保留一个模式)。
+- 波形:提供“原始波形 + 包络(RMS/平滑)”两种显示态。
+- 音频信息面板:至少接入 2 类新指标(建议 RMS 与 peak),并提供统一标尺显示。
+
+### 阶段 D:主题系统 v1(不含用户文件)
+
+- 引入 `Theme` 数据结构与内置主题枚举(如 `Default` / `Neon` / `Mono`)。
+- 渲染逻辑只通过 `Theme` 取色,不在组件内写死颜色。
+- 增加运行时切换主题的交互入口(键位或模式内切换),并在状态栏可见当前主题名。
+- 预留配置扩展点:仅定义 `LoadThemeFromConfig` 接口占位,不实现文件解析。
+
+### 阶段 E:质量门禁与文档同步
+
+- 测试补充:
+ - `analysis`:新增 RMS/peak/band 计算单测。
+ - `ui`:关键纯函数(如模式切换、可视化映射)可测试部分补单测。
+- 工程验证:`clang-format`、CMake 构建、`ctest` 全量通过。
+- 文档同步:更新中英文 README、架构文档与 `changelog.md` 的 `Changed/Added`。
+
+## 交付拆分(建议 PR/提交粒度)
+
+- 切片 1:`VisualFrame` 扩展 + `AppController` 数据注入。
+- 切片 2:`TuiRenderer` 面板化重构(保持旧功能等价)。
+- 切片 3:新增指标与可视化效果。
+- 切片 4:主题系统 v1 与交互入口。
+- 切片 5:测试、文档、回归修复。
+
+## 验收标准
+
+- 功能:频谱/波形视觉增强可见;至少 2 个新增音频信息可视化项可稳定显示。
+- 交互:现有键位行为不回退;新增模式/主题切换行为清晰可控。
+- 稳定性:播放/切歌/暂停场景无明显闪烁、错位或崩溃。
+- 性能:默认刷新下交互流畅,无显著卡顿;CPU 占用相较基线可接受。
+- 文档:`README.md`、`README_zh-CN.md`、`docs/dev/architecture.md`、`docs/dev/architecture_zh-CN.md`、`changelog.md` 同步更新。
diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc
index 1c4b22d..0ca9431 100644
--- a/.cursor/rules/general.mdc
+++ b/.cursor/rules/general.mdc
@@ -3,7 +3,7 @@ description: VocalPlayer iteration standards (first-cycle learnings)
alwaysApply: true
---
-# VocalPlayer General Rules
+# vocalplayer General Rules
## 1) Requirements and Planning
@@ -13,6 +13,8 @@ alwaysApply: true
mode before implementation.
- For UI interaction work, define an intent layer (`UiIntent`) before wiring
rendering and control logic to avoid tight coupling.
+- When iterating, focus on iteration plans instead of overly focusing on longterm / debatable plans, unless they are beneficial
+ to the current iteration plan to be executed.
## 2) Implementation Order (Recommended)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index ceebaad..569809d 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,9 +1,9 @@
cmake_minimum_required(VERSION 3.20)
project(
- VocalPlayer
- VERSION 0.1.1
- LANGUAGES C CXX)
+ VocalPlayer
+ VERSION 0.2.0
+ LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -19,27 +19,27 @@ set(KISSFFT_TEST OFF CACHE BOOL "" FORCE)
set(KISSFFT_TOOLS OFF CACHE BOOL "" FORCE)
FetchContent_Declare(
- miniaudio
- GIT_REPOSITORY https://github.com/mackron/miniaudio.git
- GIT_TAG 0.11.23)
+ miniaudio
+ GIT_REPOSITORY https://github.com/mackron/miniaudio.git
+ GIT_TAG 0.11.23)
FetchContent_MakeAvailable(miniaudio)
FetchContent_Declare(
- kissfft
- GIT_REPOSITORY https://github.com/mborgerding/kissfft.git
- GIT_TAG 131.1.0)
+ kissfft
+ GIT_REPOSITORY https://github.com/mborgerding/kissfft.git
+ GIT_TAG 131.1.0)
FetchContent_MakeAvailable(kissfft)
FetchContent_Declare(
- ftxui
- GIT_REPOSITORY https://github.com/ArthurSonzogni/FTXUI.git
- GIT_TAG v5.0.0)
+ ftxui
+ GIT_REPOSITORY https://github.com/ArthurSonzogni/FTXUI.git
+ GIT_TAG v5.0.0)
FetchContent_MakeAvailable(ftxui)
add_library(
- kissfft_lib
- STATIC
- ${kissfft_SOURCE_DIR}/kiss_fft.c)
+ kissfft_lib
+ STATIC
+ ${kissfft_SOURCE_DIR}/kiss_fft.c)
target_include_directories(kissfft_lib PUBLIC ${kissfft_SOURCE_DIR})
find_package(PkgConfig QUIET)
@@ -48,29 +48,29 @@ if(PkgConfig_FOUND)
endif()
add_library(
- vocalplayer_core
- src/app/app_controller.cpp
- src/app/playlist.cpp
- src/audio/audio_engine.cpp
- src/audio/decoder.cpp
- src/audio/metadata.cpp
- src/analysis/spectrum_analyzer.cpp
- src/ui/tui_renderer.cpp)
+ vocalplayer_core
+ src/app/app_controller.cpp
+ src/app/playlist.cpp
+ src/audio/audio_engine.cpp
+ src/audio/decoder.cpp
+ src/audio/metadata.cpp
+ src/analysis/spectrum_analyzer.cpp
+ src/ui/tui_renderer.cpp)
target_include_directories(
- vocalplayer_core
- PUBLIC
- ${PROJECT_SOURCE_DIR}/src
- ${miniaudio_SOURCE_DIR}
- ${kissfft_SOURCE_DIR})
+ vocalplayer_core
+ PUBLIC
+ ${PROJECT_SOURCE_DIR}/src
+ ${miniaudio_SOURCE_DIR}
+ ${kissfft_SOURCE_DIR})
target_link_libraries(
- vocalplayer_core
- PUBLIC
- kissfft_lib
- ftxui::screen
- ftxui::dom
- ftxui::component)
+ vocalplayer_core
+ PUBLIC
+ kissfft_lib
+ ftxui::screen
+ ftxui::dom
+ ftxui::component)
if(TAGLIB_FOUND)
target_compile_definitions(vocalplayer_core PUBLIC VOCALPLAYER_HAS_TAGLIB=1)
@@ -83,15 +83,16 @@ target_link_libraries(vocalplayer PRIVATE vocalplayer_core)
if(VOCALPLAYER_BUILD_TESTS)
set(BUILD_TESTING ON CACHE BOOL "" FORCE)
enable_testing()
+
add_executable(
- test_spectrum_analyzer
- tests/test_spectrum_analyzer.cpp
- src/analysis/spectrum_analyzer.cpp)
+ test_spectrum_analyzer
+ tests/test_spectrum_analyzer.cpp
+ src/analysis/spectrum_analyzer.cpp)
target_include_directories(
- test_spectrum_analyzer
- PRIVATE
- ${PROJECT_SOURCE_DIR}/src
- ${kissfft_SOURCE_DIR})
+ test_spectrum_analyzer
+ PRIVATE
+ ${PROJECT_SOURCE_DIR}/src
+ ${kissfft_SOURCE_DIR})
target_link_libraries(test_spectrum_analyzer PRIVATE kissfft_lib)
add_test(NAME spectrum_analyzer_test COMMAND test_spectrum_analyzer)
@@ -102,4 +103,9 @@ if(VOCALPLAYER_BUILD_TESTS)
add_executable(test_keybindings tests/test_keybindings.cpp)
target_include_directories(test_keybindings PRIVATE ${PROJECT_SOURCE_DIR}/src)
add_test(NAME keybindings_test COMMAND test_keybindings)
+
+ add_executable(test_theme tests/test_theme.cpp)
+ target_include_directories(test_theme PRIVATE ${PROJECT_SOURCE_DIR}/src)
+ target_link_libraries(test_theme PRIVATE ftxui::screen)
+ add_test(NAME theme_test COMMAND test_theme)
endif()
diff --git a/README.md b/README.md
index f9d1ed3..026d13d 100644
--- a/README.md
+++ b/README.md
@@ -6,18 +6,22 @@ English | [简体中文](README_zh-CN.md)
A creative C++ CLI music player with real-time rhythm visualization in the terminal.
+
+
-vocalplayer is a creative CLI music player built with C++, focused on
-real-time rhythm visualization in terminal environments.
+vocalplayer is a creative CLI music player built with C++, focused on real-time rhythm visualization in terminal environments.
## Features
- Local audio playback (`wav` and formats supported by the miniaudio decoder).
- Directory scan + simple playlist sorted by name.
-- Real-time spectrum bars and waveform rendering in TUI mode.
+- Real-time spectrum bars with peak-hold markers and dual waveform modes.
+- Additional audio meters (RMS, Peak, and low/mid/high band energy).
- Track metadata display (`title`, `artist`, and duration; TagLib optional).
- Vim-style playlist interaction (`h/l/j/k`) and Enter-to-play confirmation.
+- Panel layout mode switching (`Overview/Spectrum/Waveform/Meters`) and
+ runtime built-in theme cycling.
## Usage
@@ -37,6 +41,9 @@ Press `q` in the TUI to quit the current session.
- `Space`: pause/resume current track
- `j`: move playlist selection down
- `k`: move playlist selection up
+- `m`: cycle visualization layout mode
+- `v`: toggle waveform style (`Raw` / `Envelope`)
+- `t`: cycle built-in theme (`Default` / `Miku` / `Teto`)
- Mouse wheel: scroll playlist viewport
- Left click on a playlist item: select that track only
- `Enter`: play the currently selected track
diff --git a/README_zh-CN.md b/README_zh-CN.md
index 470c12b..7a7e883 100644
--- a/README_zh-CN.md
+++ b/README_zh-CN.md
@@ -6,18 +6,21 @@
基于C++的创意型CLI音乐播放器,在终端中实现实时节奏可视化。
+
+
-vocalplayer 是一个使用 C++ 构建的创意型 CLI 音乐播放器,重点在于
-终端中的实时节奏可视化。
+vocalplayer 是一个使用 C++ 构建的创意型 CLI 音乐播放器,重点在于终端中的实时节奏可视化。
## 特性
- 本地音频播放(`wav` 及 miniaudio 解码器支持的格式)。
- 目录扫描 + 简单的按名称排序播放列表。
-- TUI 实时频谱柱与波形渲染。
+- TUI 实时频谱柱(含峰值保持)与双模式波形渲染。
+- 增加音频信息仪表(RMS、Peak、低/中/高频段能量)。
- 曲目信息展示(`title`、`artist`、时长;TagLib 可选)。
- 支持 Vim 风格播放列表交互(`h/l/j/k`)与回车确认切歌。
+- 支持可视化布局模式切换与内置主题运行时切换。
## 运行
@@ -37,6 +40,9 @@ vocalplayer 是一个使用 C++ 构建的创意型 CLI 音乐播放器,重点
- `Space`:暂停/恢复当前曲目
- `j`:播放列表选中下移
- `k`:播放列表选中上移
+- `m`:循环切换可视化布局模式
+- `v`:切换波形样式(`Raw` / `Envelope`)
+- `t`:循环切换内置主题(`Default` / `Miku` / `Teto`)
- 鼠标滚轮:滚动播放列表视窗
- 鼠标左键点击列表项:仅选中该曲目
- `Enter`:播放当前选中曲目
diff --git a/assets/vocalplayer.webm b/assets/vocalplayer.webm
new file mode 100644
index 0000000..9e94c26
Binary files /dev/null and b/assets/vocalplayer.webm differ
diff --git a/changelog.md b/changelog.md
index 628a173..771065a 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,8 +1,37 @@
# Changelog
-本文件用于记录 VocalPlayer 的迭代历史,格式参考
+本文件用于记录 vocalplayer 的迭代历史,格式参考
[Keep a Changelog](https://keepachangelog.com/)。
+## [0.2.0] - 2026-05-12
+
+### Added
+- 新增可视化扩展数据契约:
+ - `VisualFrame` 增加频谱峰值保持、包络波形、`rms_level`、`peak_level`、`band_energies` 与 `visual_mode` 字段。
+- 新增分析能力:
+ - `SpectrumAnalyzer::ComputeWaveformEnvelope()`
+ - `SpectrumAnalyzer::ComputeLevels()`
+ - `SpectrumAnalyzer::ComputeBandEnergies()`
+- 新增内置主题系统:
+ - `src/ui/theme.hpp` 定义 `ThemeId`、`Theme`、`GetBuiltinTheme()`、`NextThemeId()`。
+ - 预留 `LoadThemeFromConfig()` 接口(暂不实现配置文件解析)。
+- 新增主题测试:`tests/test_theme.cpp`(并接入 `theme_test`)。
+
+### Changed
+- `TuiRenderer` 重构为面板化布局:顶栏、主可视化区、播放列表区、底栏。
+- 频谱渲染增加峰值保持标记;波形支持 `Raw/Envelope` 双模式切换。
+- 增加音频仪表展示:RMS、Peak、低中高频段能量。
+- 增加运行时交互键位:
+ - `m`:切换可视化布局模式
+ - `v`:切换波形样式
+ - `t`:切换内置主题
+- 播放列表鼠标命中逻辑从固定行号偏移改为基于渲染区域 `Box` 反射定位,降低布局改动带来的点击偏移风险。
+- `CMakeLists.txt` 重新格式化并补齐 `test_theme` 链接依赖(`ftxui::screen`)。
+
+### Docs
+- 同步更新 `README.md` 与 `README_zh-CN.md`,补充新增可视化、主题和交互说明。
+- 同步更新 `docs/dev/architecture.md` 与 `docs/dev/architecture_zh-CN.md`,反映新的数据契约、分析接口与 UI 架构。
+
## [0.1.1] - 2026-05-10
### Added
diff --git a/contributing.md b/contributing.md
index 2d3f55b..9889477 100644
--- a/contributing.md
+++ b/contributing.md
@@ -94,3 +94,11 @@ git push --tags
```
It builds, tests, and publishes artifacts for Linux, macOS, and Windows.
+
+### Release Process
+
+1. Develop and document in `dev` branch, update `changelog.md` and project metadata, and submit PR.
+2. Maintainer merges PR to `main`, CI automatically runs `clang-tidy` + Linux build/test pipeline.
+3. Switch to `main` branch locally, and execute `git pull` to fetch latest code.
+4. Execute `git tag -a v*.*.*` to create a new version tag.
+5. Execute `git push --tags` to push the tag to the remote repository, which triggers the GitHub Actions release workflow, building and uploading Linux/macOS/Windows triple-platform artifacts.
diff --git a/contributing_zh-CN.md b/contributing_zh-CN.md
index b85f3f8..c950299 100644
--- a/contributing_zh-CN.md
+++ b/contributing_zh-CN.md
@@ -86,3 +86,15 @@ PR / push 到 `main` 时,CI 会自动运行 `clang-tidy` + Linux 构建测试
git tag -a v0.1.0 -m "Release version 0.1.0"
git push --tags
```
+
+### 发布流程
+
+1. 在`dev`分支进行开发、文档工作,更新 `changelog.md` 与项目元信息并提交 PR。
+
+2. 维护者合并 PR 到 `main`,CI 会自动进行 `clang-tidy` + Linux 构建测试流水线。
+
+3. 在本地切换到 `main` 分支,并执行 `git pull` 拉取最新代码。
+
+4. 执行 `git tag -a v*.*.*` 创建新版本标签。
+
+5. 执行 `git push --tags` 推送标签到远程仓库,这会触发 GitHub Actions 发布流程,构建并上传 Linux/macOS/Windows 三平台二进制产物。
diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md
index 44f093e..f39c36b 100644
--- a/docs/dev/architecture.md
+++ b/docs/dev/architecture.md
@@ -9,8 +9,10 @@ Implemented scope:
- File or directory input.
- Decoding + playback via miniaudio.
-- Real-time spectrum and waveform rendering in terminal.
+- Real-time spectrum (with peak-hold) and dual waveform rendering in terminal.
+- Meter panels for RMS/Peak and low/mid/high frequency band energy.
- Playlist interactions (`h/l`, `j/k`, `Space`, `Enter`, mouse select/scroll).
+- View-mode switching (`m`), waveform style toggle (`v`), and theme cycling (`t`).
- Optional metadata enrichment via TagLib.
## Component Diagram
@@ -53,9 +55,10 @@ flowchart LR
- Streams PCM to output device and tracks playback cursor/state.
- Exposes pause/resume and analysis window extraction.
- `SpectrumAnalyzer`
- - Converts mono windows into spectrum bars and waveform points.
+ - Converts mono windows into spectrum bars, peak-hold hints, waveform points,
+ envelope waveform points, and meter values.
- `TuiRenderer`
- - Renders the terminal view.
+ - Renders panelized terminal layout (header/visual area/playlist/footer).
- Maps key/mouse input into `UiIntent` events.
## Interface Inventory
@@ -72,10 +75,14 @@ flowchart LR
- Analysis interface
- `std::vector SpectrumAnalyzer::ComputeBars(...)`
- `std::vector SpectrumAnalyzer::ComputeWaveform(...) const`
+ - `std::vector SpectrumAnalyzer::ComputeWaveformEnvelope(...) const`
+ - `AudioLevels SpectrumAnalyzer::ComputeLevels(...) const`
+ - `std::vector SpectrumAnalyzer::ComputeBandEnergies(...) const`
- UI interfaces
- `void TuiRenderer::Run(...)`
- `UiIntent` enum for playback/navigation intents
- `Keybindings` + `DefaultKeybindings()` for configurable key mapping
+ - `ThemeId` / `Theme` for runtime built-in theme palettes
## Overall Architecture Diagram
@@ -210,7 +217,8 @@ flowchart LR
- `DecodedTrack`: interleaved float PCM plus stream format.
- `TrackInfo`: source and display metadata for active track.
- `PlaybackState`: elapsed time, duration, and runtime flags.
-- `VisualFrame`: complete UI tick payload.
+- `VisualFrame`: complete UI tick payload, including spectrum peaks, envelope
+ waveform, RMS/Peak levels, band energies, and preferred visual mode.
## Planned Evolution
diff --git a/docs/dev/architecture_zh-CN.md b/docs/dev/architecture_zh-CN.md
index ed90675..b06b7d3 100644
--- a/docs/dev/architecture_zh-CN.md
+++ b/docs/dev/architecture_zh-CN.md
@@ -9,9 +9,11 @@
- 输入:支持单个音频文件或音频目录。
- 播放:通过 miniaudio 执行本地解码缓冲播放。
-- 可视化:通过 FTXUI 实现实时频谱柱与波形渲染。
+- 可视化:通过 FTXUI 实现频谱柱(含峰值保持)与双模式波形渲染。
+- 仪表:支持 RMS/Peak 与低中高频段能量显示。
- 元数据:优先通过 TagLib 读取标题与艺术家(可选),并提供回退策略。
- 交互:支持 `h/l`、`j/k`、`Space`、`Enter` 及鼠标选择/滚轮滚动。
+- 模式:支持 `m` 切换可视化布局、`v` 切换波形样式、`t` 切换主题。
## 组件图
@@ -140,9 +142,9 @@ flowchart TB
- 驱动音频输出设备并维护播放游标/状态。
- 提供暂停恢复与分析窗口提取能力。
- `SpectrumAnalyzer`
- - 将单声道窗口转换为频谱柱与波形点。
+ - 将单声道窗口转换为频谱柱、峰值保持提示、波形点、包络波形和仪表指标。
- `TuiRenderer`
- - 渲染终端界面。
+ - 以面板化布局渲染终端界面(顶栏/主可视化区/播放列表/底栏)。
- 将键鼠输入转换为 `UiIntent`。
## 接口清单
@@ -159,10 +161,14 @@ flowchart TB
- 分析层接口
- `std::vector SpectrumAnalyzer::ComputeBars(...)`
- `std::vector SpectrumAnalyzer::ComputeWaveform(...) const`
+ - `std::vector SpectrumAnalyzer::ComputeWaveformEnvelope(...) const`
+ - `AudioLevels SpectrumAnalyzer::ComputeLevels(...) const`
+ - `std::vector SpectrumAnalyzer::ComputeBandEnergies(...) const`
- 表现层接口
- `void TuiRenderer::Run(...)`
- `UiIntent`(播放与导航意图枚举)
- `Keybindings` + `DefaultKeybindings()`(键位映射入口)
+ - `ThemeId` / `Theme`(内置主题与配色约定)
## 数据流图
@@ -206,7 +212,7 @@ flowchart LR
- `DecodedTrack`:交错存储的浮点采样与流格式信息。
- `TrackInfo`:来源路径、标题、艺术家、时长和采样相关元信息。
- `PlaybackState`:已播放时间、总时长及运行态标记。
-- `VisualFrame`:每个刷新周期生成的 UI 只读快照。
+- `VisualFrame`:每个刷新周期生成的 UI 只读快照,包含频谱峰值、包络波形、RMS/Peak 和频段能量等扩展字段。
这种“数据契约优先”的设计,使后续迁移到 Rust 时可以在保持边界稳定的前提下,
替换具体模块实现。
diff --git a/docs/visual.md b/docs/visual.md
new file mode 100644
index 0000000..e1c27be
--- /dev/null
+++ b/docs/visual.md
@@ -0,0 +1,71 @@
+# VocalPlayer Visualization Metrics
+
+This document describes the audio features currently visualized by VocalPlayer,
+plus a quick-reference table for how they typically change across sections in
+the same song.
+
+## Currently Visualized Metrics
+
+### 1) Spectrum Bars (`spectrum_bars`)
+
+- Concept: Frequency-domain energy distribution over multiple bins.
+- Intuition: Shows where energy is concentrated (low vs high frequency).
+- Current behavior: FFT-based bars with smoothing and log compression.
+
+### 2) Spectrum Peak Hold (`spectrum_peak_bars`)
+
+- Concept: Recent short-term peak marker per spectrum bin.
+- Intuition: Prevents transient peaks from disappearing immediately.
+- Current behavior: Peaks decay gradually to improve readability.
+
+### 3) Raw Waveform (`waveform_points`)
+
+- Concept: Time-domain amplitude trace sampled into fixed-width points.
+- Intuition: Reflects instantaneous signal shape and transients.
+- Current behavior: Used for direct waveform rendering.
+
+### 4) Envelope Waveform (`waveform_envelope_points`)
+
+- Concept: Smoothed amplitude contour (mean absolute magnitude per window).
+- Intuition: Highlights macro loudness contour over micro oscillation.
+- Current behavior: Better for compact UI readability than raw waveform.
+
+### 5) RMS Level (`rms_level`)
+
+- Concept: Root-mean-square amplitude over the analysis window.
+- Intuition: Represents average energy (perceived loudness trend).
+- Current behavior: Normalized to [0, 1] and shown as a meter.
+
+### 6) Peak Level (`peak_level`)
+
+- Concept: Maximum absolute sample amplitude over the analysis window.
+- Intuition: Captures short transient spikes.
+- Current behavior: Normalized to [0, 1] and shown as a meter.
+
+### 7) Band Energies (`band_energies`: Low/Mid/High)
+
+- Concept: Coarse energy split over three frequency bands.
+- Intuition: Quickly indicates tonal balance and orchestration emphasis.
+- Current behavior: Normalized per band and shown as three meters.
+
+## Quick Reference: Typical Changes Across Song Sections
+
+This table is a practical heuristic, not a strict rule. Real tracks can vary by
+genre, mixing style, and mastering.
+
+| Song Section | Spectrum Bars | Peak Hold | Raw Waveform | Envelope | RMS | Peak | Low/Mid/High Bands |
+| --- | --- | --- | --- | --- | --- | --- | --- |
+| Intro (sparse) | Fewer active bins, narrower distribution | Moderate, less dense | More gaps, isolated transients | Slow rise, lower baseline | Low to medium | Occasional spikes | Depends on arrangement; often mid/high-focused for pads or arps |
+| Verse | Moderate spread, rhythm-dependent movement | Stable periodic peaks | Clear rhythmic pattern | Moderate undulation | Medium | Medium to high | Vocal-forward mixes often mid-dominant |
+| Pre-chorus build | Increasing activity and width | Denser and gradually higher | Increasing density | Continuous climb | Rising | Rising, more frequent transients | Low and high often increase together during build |
+| Chorus / Drop | Broad, high-energy distribution | Dense and frequent high peaks | Dense and saturated | High sustained contour | High | High | Low (kick/bass) and high (cymbals/air) often both elevated |
+| Bridge / Break | Reduced density or shifted emphasis | Fewer peaks, slower decay perception | More space between events | Dip or re-shape | Medium to low | Medium | Can shift to mids for vocals/instruments, lows reduced |
+| Outro | Gradual reduction in active bins | Peaks thin out | Less dense, trailing patterns | Descending contour | Falling | Falling | Returns to a narrower balance as arrangement thins |
+
+## Practical Reading Tips
+
+- RMS high + Peak high + dense spectrum usually means high-impact sections.
+- Peak high but RMS moderate often means transient-heavy but less sustained
+ energy.
+- Envelope trend is useful for macro section transitions (build, drop, fade).
+- Band balance often reveals arrangement focus faster than full spectrum.
diff --git a/docs/visual_zh-CN.md b/docs/visual_zh-CN.md
new file mode 100644
index 0000000..3d26151
--- /dev/null
+++ b/docs/visual_zh-CN.md
@@ -0,0 +1,69 @@
+# VocalPlayer 可视化指标说明
+
+本文档说明 VocalPlayer 当前已可视化的音频信息,并提供“同一首歌不同
+片段的典型变化规律”速查表,便于调参与 UI 设计。
+
+## 当前可视化指标
+
+### 1) 频谱柱 (`spectrum_bars`)
+
+- 概念:将音频在频域上分桶后得到各频段能量分布。
+- 直觉:反映低频/中频/高频的能量强弱。
+- 当前实现:FFT + 对数压缩 + 平滑后归一化显示。
+
+### 2) 频谱峰值保持 (`spectrum_peak_bars`)
+
+- 概念:每个频谱柱在短时间内的峰值标记。
+- 直觉:防止瞬态峰值“闪一下就消失”。
+- 当前实现:峰值保持后缓慢衰减,增强可读性。
+
+### 3) 原始波形 (`waveform_points`)
+
+- 概念:时域振幅轨迹按固定点数采样后的结果。
+- 直觉:体现瞬时形状与瞬态冲击。
+- 当前实现:用于直接波形绘制。
+
+### 4) 包络波形 (`waveform_envelope_points`)
+
+- 概念:窗口内绝对值平均得到的平滑振幅外轮廓。
+- 直觉:更强调整体响度走势,弱化微小抖动。
+- 当前实现:在紧凑终端界面中通常比原始波形更易读。
+
+### 5) RMS 电平 (`rms_level`)
+
+- 概念:分析窗口内的均方根幅度。
+- 直觉:反映平均能量(体感响度趋势)。
+- 当前实现:归一化到 [0, 1],以仪表方式展示。
+
+### 6) Peak 电平 (`peak_level`)
+
+- 概念:分析窗口内的绝对峰值幅度。
+- 直觉:捕捉鼓点、爆破音等瞬态尖峰。
+- 当前实现:归一化到 [0, 1],以仪表方式展示。
+
+### 7) 三段频带能量 (`band_energies`: Low/Mid/High)
+
+- 概念:将频域粗分为低/中/高三段并计算各段能量。
+- 直觉:快速判断编配与音色重心。
+- 当前实现:每段归一化后以独立仪表展示。
+
+## 速查表:同一首歌不同片段的典型变化规律
+
+说明:下表是工程经验型规律,不是硬性标准。具体表现会受曲风、编曲、
+混音和母带处理影响。
+
+| 片段 | 频谱柱 | 峰值保持 | 原始波形 | 包络 | RMS | Peak | 低/中/高频段 |
+| --- | --- | --- | --- | --- | --- | --- | --- |
+| 前奏(较稀疏) | 活跃频桶较少,分布较窄 | 峰值数量中等 | 间隔明显,瞬态分散 | 基线较低、缓慢上升 | 低到中 | 偶发尖峰 | 常见中高频先出现(Pad/琶音等) |
+| 主歌 | 频谱分布中等,随节奏起伏 | 周期性峰值较稳定 | 节奏轮廓清晰 | 中等幅度起伏 | 中 | 中到高 | 人声主导时中频通常更突出 |
+| 预副歌(推进) | 活跃频段变宽、能量增加 | 峰值更密集且逐步抬高 | 波形密度提高 | 包络持续上行 | 上升 | 上升且更频繁 | 低频与高频常同步增强 |
+| 副歌/Drop | 频谱覆盖更宽且能量高 | 高峰值密集出现 | 波形更密、更“满” | 高位持续 | 高 | 高 | 低频(鼓/贝斯)与高频(镲片/空气感)常同时偏强 |
+| 桥段/间奏 | 整体密度下降或重心转移 | 峰值减少、视觉衰减感更明显 | 留白增多 | 回落或重新塑形 | 中到低 | 中 | 常转向中频表达,低频相对收敛 |
+| 尾奏 | 活跃频段逐步减少 | 峰值逐步变稀 | 波形密度下降 | 包络下行 | 下降 | 下降 | 随编配减法回到较窄频带分布 |
+
+## 实用解读建议
+
+- RMS 高 + Peak 高 + 频谱密集:通常是高冲击段落。
+- Peak 高但 RMS 中等:常见于瞬态强、持续能量不高的素材。
+- 包络趋势更适合判断段落级结构变化(推进、爆发、收尾)。
+- 三段频带比全频谱更适合快速判断“重心偏向”。
diff --git a/src/analysis/spectrum_analyzer.cpp b/src/analysis/spectrum_analyzer.cpp
index 80e7d67..8702c96 100644
--- a/src/analysis/spectrum_analyzer.cpp
+++ b/src/analysis/spectrum_analyzer.cpp
@@ -128,4 +128,91 @@ std::vector SpectrumAnalyzer::ComputeWaveform(
return waveform;
}
+// Build a compact amplitude envelope suitable for low-resolution waveform UI.
+std::vector SpectrumAnalyzer::ComputeWaveformEnvelope(
+ const std::vector& mono_window, uint32_t points) const {
+ std::vector envelope(points, 0.0f);
+ if (mono_window.empty() || points == 0) {
+ return envelope;
+ }
+
+ for (uint32_t i = 0; i < points; ++i) {
+ uint32_t start = (i * static_cast(mono_window.size())) / points;
+ uint32_t end =
+ ((i + 1) * static_cast(mono_window.size())) / points;
+ end = std::max(end, start + 1);
+ end = std::min(end, static_cast(mono_window.size()));
+
+ float sum = 0.0f;
+ for (uint32_t idx = start; idx < end; ++idx) {
+ sum += std::abs(mono_window[idx]);
+ }
+ const float mean_abs = sum / static_cast(end - start);
+ envelope[i] = Clamp01(mean_abs);
+ }
+ return envelope;
+}
+
+// Compute RMS and peak levels from normalized mono samples.
+AudioLevels SpectrumAnalyzer::ComputeLevels(
+ const std::vector& mono_window) const {
+ AudioLevels levels;
+ if (mono_window.empty()) {
+ return levels;
+ }
+
+ float sum_squares = 0.0f;
+ float peak_abs = 0.0f;
+ for (float sample : mono_window) {
+ const float abs_sample = std::abs(sample);
+ peak_abs = std::max(peak_abs, abs_sample);
+ sum_squares += sample * sample;
+ }
+
+ levels.rms_level =
+ Clamp01(std::sqrt(sum_squares / static_cast(mono_window.size())));
+ levels.peak_level = Clamp01(peak_abs);
+ return levels;
+}
+
+// Compute coarse per-band energies from FFT magnitudes.
+std::vector SpectrumAnalyzer::ComputeBandEnergies(
+ const std::vector& mono_window, uint32_t band_count) const {
+ std::vector energies(band_count, 0.0f);
+ if (mono_window.empty() || band_count == 0) {
+ return energies;
+ }
+
+ std::vector padded(fft_size_, 0.0f);
+ uint32_t copy_count = std::min(mono_window.size(), fft_size_);
+ std::copy_n(mono_window.end() - copy_count, copy_count,
+ padded.end() - copy_count);
+
+ for (uint32_t i = 0; i < fft_size_; ++i) {
+ const float hann =
+ 0.5f * (1.0f - std::cos((2.0f * kPi * i) / (fft_size_ - 1)));
+ impl_->input[i].r = padded[i] * hann;
+ impl_->input[i].i = 0.0f;
+ }
+ kiss_fft(impl_->cfg, impl_->input.data(), impl_->output.data());
+
+ const uint32_t nyquist_bins = fft_size_ / 2;
+ for (uint32_t band = 0; band < band_count; ++band) {
+ uint32_t start = (band * nyquist_bins) / band_count;
+ uint32_t end = ((band + 1) * nyquist_bins) / band_count;
+ end = std::max(end, start + 1);
+
+ float magnitude = 0.0f;
+ for (uint32_t bin = start; bin < end; ++bin) {
+ const float real = impl_->output[bin].r;
+ const float imag = impl_->output[bin].i;
+ magnitude += std::sqrt(real * real + imag * imag);
+ }
+
+ magnitude /= static_cast(end - start);
+ energies[band] = Clamp01(std::log1p(magnitude) / 8.0f);
+ }
+ return energies;
+}
+
} // namespace vocalplayer
diff --git a/src/analysis/spectrum_analyzer.hpp b/src/analysis/spectrum_analyzer.hpp
index 75cf9ae..b817609 100644
--- a/src/analysis/spectrum_analyzer.hpp
+++ b/src/analysis/spectrum_analyzer.hpp
@@ -15,6 +15,16 @@
namespace vocalplayer {
+/**
+ * @brief Basic level metrics derived from a mono audio window.
+ */
+struct AudioLevels {
+ /// Root-mean-square level in [0, 1].
+ float rms_level = 0.0f;
+ /// Peak absolute level in [0, 1].
+ float peak_level = 0.0f;
+};
+
/**
* @brief FFT-based analyzer that converts mono windows to UI features.
*/
@@ -52,6 +62,31 @@ class SpectrumAnalyzer {
*/
std::vector ComputeWaveform(const std::vector& mono_window,
uint32_t points) const;
+ /**
+ * @brief Build a smoothed envelope waveform from absolute mono amplitudes.
+ *
+ * @param mono_window Input mono samples.
+ * @param points Number of output points.
+ * @return Normalized envelope points in [0, 1].
+ */
+ std::vector ComputeWaveformEnvelope(
+ const std::vector& mono_window, uint32_t points) const;
+ /**
+ * @brief Compute RMS and peak levels from mono samples.
+ *
+ * @param mono_window Input mono samples.
+ * @return AudioLevels with normalized RMS/peak metrics.
+ */
+ AudioLevels ComputeLevels(const std::vector& mono_window) const;
+ /**
+ * @brief Compute coarse normalized frequency-band energies.
+ *
+ * @param mono_window Input mono samples.
+ * @param band_count Number of output bands.
+ * @return Vector of normalized energies in [0, 1].
+ */
+ std::vector ComputeBandEnergies(const std::vector& mono_window,
+ uint32_t band_count) const;
private:
uint32_t fft_size_;
diff --git a/src/app/app_controller.cpp b/src/app/app_controller.cpp
index a7dae78..0f7b6ba 100644
--- a/src/app/app_controller.cpp
+++ b/src/app/app_controller.cpp
@@ -20,6 +20,11 @@
namespace vocalplayer {
namespace {
+constexpr uint32_t kAnalysisWindowSize = 2048;
+constexpr uint32_t kWaveformPointCount = 96;
+constexpr uint32_t kBandEnergyCount = 3;
+constexpr float kPeakDecayPerFrame = 0.035f;
+
// Build display-friendly short names from absolute playlist paths.
std::vector BuildTrackDisplayNames(
const std::vector& playlist) {
@@ -49,6 +54,7 @@ int AppController::Run(const std::string& input_path) {
// Generate the display names for UI.
std::vector track_names = BuildTrackDisplayNames(playlist);
const int total_tracks = static_cast(playlist.size());
+ UiSessionState ui_session_state;
int current_index = 0;
// Playback loop for each track in the playlist.
@@ -61,6 +67,7 @@ int AppController::Run(const std::string& input_path) {
std::atomic requested_index{current_index};
std::atomic switch_requested{false};
std::atomic exit_requested{false};
+ std::vector spectrum_peaks(48, 0.0f);
// Singal track playback process
try {
@@ -88,9 +95,29 @@ int AppController::Run(const std::string& input_path) {
frame.track_info = audio_engine_.GetTrackInfo();
frame.playback_state = audio_engine_.GetPlaybackState();
std::vector window =
- audio_engine_.GetRecentMonoWindow(2048);
+ audio_engine_.GetRecentMonoWindow(kAnalysisWindowSize);
frame.spectrum_bars = analyzer_.ComputeBars(window);
- frame.waveform_points = analyzer_.ComputeWaveform(window, 96);
+ if (spectrum_peaks.size() != frame.spectrum_bars.size()) {
+ spectrum_peaks.assign(frame.spectrum_bars.size(), 0.0f);
+ }
+ for (size_t i = 0; i < frame.spectrum_bars.size(); ++i) {
+ const float decayed_peak =
+ std::max(0.0f, spectrum_peaks[i] - kPeakDecayPerFrame);
+ spectrum_peaks[i] =
+ std::max(frame.spectrum_bars[i], decayed_peak);
+ }
+ frame.spectrum_peak_bars = spectrum_peaks;
+ frame.waveform_points =
+ analyzer_.ComputeWaveform(window, kWaveformPointCount);
+ frame.waveform_envelope_points =
+ analyzer_.ComputeWaveformEnvelope(window,
+ kWaveformPointCount);
+ const AudioLevels levels = analyzer_.ComputeLevels(window);
+ frame.rms_level = levels.rms_level;
+ frame.peak_level = levels.peak_level;
+ frame.band_energies =
+ analyzer_.ComputeBandEnergies(window, kBandEnergyCount);
+ frame.visual_mode = VisualMode::kOverview;
return frame;
},
// Build the playlist view model (tracks/current/selection).
@@ -139,7 +166,8 @@ int AppController::Run(const std::string& input_path) {
[&] {
PlaybackState state = audio_engine_.GetPlaybackState();
return state.is_finished || switch_requested.load();
- });
+ },
+ &ui_session_state);
} catch (const std::exception& ex) {
audio_engine_.Stop();
std::cerr << "Warning: skip track due to error: " << ex.what()
diff --git a/src/shared/types.hpp b/src/shared/types.hpp
index 9399e70..e2ba7ed 100644
--- a/src/shared/types.hpp
+++ b/src/shared/types.hpp
@@ -48,6 +48,20 @@ struct PlaybackState {
bool is_finished = false;
};
+/**
+ * @brief Renderer layout mode for visualization panels.
+ */
+enum class VisualMode {
+ /// Balanced overview with all major panels visible.
+ kOverview,
+ /// Focus on spectrum rendering.
+ kSpectrumFocus,
+ /// Focus on waveform rendering.
+ kWaveformFocus,
+ /// Focus on meter-style numeric visualization.
+ kMeterFocus,
+};
+
/**
* @brief Fully decoded interleaved PCM buffer.
*/
@@ -72,8 +86,20 @@ struct VisualFrame {
PlaybackState playback_state;
/// Spectrum bar amplitudes in [0, 1].
std::vector spectrum_bars;
+ /// Peak-hold spectrum marker values in [0, 1].
+ std::vector spectrum_peak_bars;
/// Waveform points in [0, 1].
std::vector waveform_points;
+ /// Envelope waveform points in [0, 1].
+ std::vector waveform_envelope_points;
+ /// Root-mean-square level in [0, 1].
+ float rms_level = 0.0f;
+ /// Peak level in [0, 1].
+ float peak_level = 0.0f;
+ /// Coarse band energies in [0, 1].
+ std::vector band_energies;
+ /// Preferred visualization mode (renderer may override by local user action).
+ VisualMode visual_mode = VisualMode::kOverview;
};
} // namespace vocalplayer
diff --git a/src/ui/keybindings.hpp b/src/ui/keybindings.hpp
index 2f837ed..b804b7d 100644
--- a/src/ui/keybindings.hpp
+++ b/src/ui/keybindings.hpp
@@ -29,6 +29,12 @@ struct Keybindings {
char move_selection_up = 'k';
/// Move playlist selection down.
char move_selection_down = 'j';
+ /// Cycle visualization layout mode.
+ char cycle_visual_mode = 'm';
+ /// Toggle waveform raw/envelope rendering style.
+ char toggle_waveform_style = 'v';
+ /// Cycle built-in theme palette.
+ char cycle_theme = 't';
/// Quit session key.
char quit = 'q';
};
diff --git a/src/ui/theme.hpp b/src/ui/theme.hpp
new file mode 100644
index 0000000..c52c39b
--- /dev/null
+++ b/src/ui/theme.hpp
@@ -0,0 +1,127 @@
+#ifndef VOCALPLAYER_SRC_UI_THEME_HPP_
+#define VOCALPLAYER_SRC_UI_THEME_HPP_
+
+#include
+#include
+
+#include "ftxui/screen/color.hpp"
+
+namespace vocalplayer {
+
+/**
+ * @brief Built-in theme identifiers for terminal rendering.
+ */
+enum class ThemeId {
+ kDefault,
+ kMiku,
+ kTeto,
+};
+
+/**
+ * @brief Color palette contract used by TUI renderer panels.
+ */
+struct Theme {
+ ftxui::Color title_color = ftxui::Color::Cyan;
+ ftxui::Color text_color = ftxui::Color::White;
+ ftxui::Color accent_color = ftxui::Color::BlueLight;
+ ftxui::Color spectrum_color = ftxui::Color::Cyan;
+ ftxui::Color peak_color = ftxui::Color::YellowLight;
+ ftxui::Color waveform_color = ftxui::Color::Magenta;
+ ftxui::Color meter_color = ftxui::Color::GreenLight;
+ ftxui::Color warning_color = ftxui::Color::Yellow;
+ ftxui::Color border_color = ftxui::Color::GrayLight;
+};
+
+/**
+ * @brief Return a built-in theme palette by ID.
+ *
+ * @param theme_id Built-in theme enum value.
+ * @return const Theme& Immutable theme palette.
+ */
+inline const Theme& GetBuiltinTheme(ThemeId theme_id) {
+ static const Theme kDefaultTheme{};
+ static const Theme kMikuTheme{
+ .title_color = ftxui::Color::RGB(57, 197, 187),
+ .text_color = ftxui::Color::White,
+ .accent_color = ftxui::Color::RGB(137, 248, 226),
+ .spectrum_color = ftxui::Color::RGB(57, 197, 187),
+ .peak_color = ftxui::Color::RGB(223, 255, 246),
+ .waveform_color = ftxui::Color::RGB(0, 221, 192),
+ .meter_color = ftxui::Color::RGB(98, 255, 210),
+ .warning_color = ftxui::Color::RGB(255, 220, 122),
+ .border_color = ftxui::Color::RGB(57, 197, 187),
+ };
+ static const Theme kTetoTheme{
+ .title_color = ftxui::Color::RGB(234, 84, 141),
+ .text_color = ftxui::Color::White,
+ .accent_color = ftxui::Color::RGB(255, 165, 198),
+ .spectrum_color = ftxui::Color::RGB(234, 84, 141),
+ .peak_color = ftxui::Color::RGB(255, 218, 232),
+ .waveform_color = ftxui::Color::RGB(255, 109, 165),
+ .meter_color = ftxui::Color::RGB(255, 129, 179),
+ .warning_color = ftxui::Color::RGB(255, 212, 92),
+ .border_color = ftxui::Color::RGB(234, 84, 141),
+ };
+
+ switch (theme_id) {
+ case ThemeId::kMiku:
+ return kMikuTheme;
+ case ThemeId::kTeto:
+ return kTetoTheme;
+ case ThemeId::kDefault:
+ default:
+ return kDefaultTheme;
+ }
+}
+
+/**
+ * @brief Return the next built-in theme for runtime cycling.
+ *
+ * @param current Current theme ID.
+ * @return ThemeId Next theme ID.
+ */
+inline ThemeId NextThemeId(ThemeId current) {
+ switch (current) {
+ case ThemeId::kDefault:
+ return ThemeId::kMiku;
+ case ThemeId::kMiku:
+ return ThemeId::kTeto;
+ case ThemeId::kTeto:
+ default:
+ return ThemeId::kDefault;
+ }
+}
+
+/**
+ * @brief Build a user-friendly display name for a built-in theme.
+ *
+ * @param theme_id Built-in theme enum value.
+ * @return std::string Theme display label.
+ */
+inline std::string GetThemeDisplayName(ThemeId theme_id) {
+ switch (theme_id) {
+ case ThemeId::kMiku:
+ return "Miku";
+ case ThemeId::kTeto:
+ return "Teto";
+ case ThemeId::kDefault:
+ default:
+ return "Default";
+ }
+}
+
+/**
+ * @brief Reserved extension point for future user-defined theme loading.
+ *
+ * @param config_path Path to a future theme config file.
+ * @return std::optional No value for now (parsing deferred).
+ */
+inline std::optional LoadThemeFromConfig(
+ const std::string& config_path) {
+ (void)config_path;
+ return std::nullopt;
+}
+
+} // namespace vocalplayer
+
+#endif // VOCALPLAYER_SRC_UI_THEME_HPP_
diff --git a/src/ui/tui_renderer.cpp b/src/ui/tui_renderer.cpp
index 3571340..fe71c43 100644
--- a/src/ui/tui_renderer.cpp
+++ b/src/ui/tui_renderer.cpp
@@ -12,6 +12,9 @@
#include
#include
#include
+#include
+#include
+#include
#include
#include
#include
@@ -21,6 +24,7 @@
#include "ftxui/component/mouse.hpp"
#include "ftxui/component/screen_interactive.hpp"
#include "ftxui/dom/elements.hpp"
+#include "ui/theme.hpp"
namespace vocalplayer {
namespace {
@@ -28,38 +32,94 @@ namespace {
using namespace ftxui; // NOLINT
constexpr int kPlaylistVisibleRows = 10;
-constexpr int kPlaylistStartY = 16;
+constexpr int kSpectrumCanvasHeight = 32;
+constexpr int kWaveformCanvasHeight = 20;
+constexpr int kMeterGaugeWidth = 18;
+constexpr float kSpectrumPeakThreshold = 0.03f;
-// Build a one-column spectrum bar using repeated characters.
-std::string BuildBar(float value, int height) {
- int filled = static_cast(value * static_cast(height));
- return std::string(std::max(filled, 0), '#');
+// Format seconds as mm:ss for compact top status line.
+std::string FormatTime(double total_seconds) {
+ const int rounded = std::max(0, static_cast(std::round(total_seconds)));
+ const int minutes = rounded / 60;
+ const int seconds = rounded % 60;
+ std::ostringstream oss;
+ oss << std::setfill('0') << std::setw(2) << minutes << ":" << std::setw(2)
+ << seconds;
+ return oss.str();
}
-// Render normalized spectrum bars into a fixed-height row block.
-Element RenderSpectrum(const std::vector& bars) {
- std::vector cols;
- cols.reserve(bars.size());
- constexpr int kMaxBarHeight = 8;
- for (float value : bars) {
- cols.push_back(vbox({
- filler(),
- text(BuildBar(value, kMaxBarHeight)) | color(Color::Cyan),
- }));
+// Render normalized spectrum bars and peak-hold markers.
+Element RenderSpectrum(const std::vector& bars,
+ const std::vector& peaks, const Theme& theme) {
+ if (bars.empty()) {
+ return text("(no spectrum data)");
}
- return hbox(std::move(cols)) | size(HEIGHT, EQUAL, kMaxBarHeight);
+ const int canvas_width = static_cast(bars.size() * 2);
+ return canvas(
+ canvas_width, kSpectrumCanvasHeight,
+ [bars, peaks, theme, canvas_width](Canvas& c) {
+ const int baseline = kSpectrumCanvasHeight - 1;
+ for (size_t idx = 0; idx < bars.size(); ++idx) {
+ const int bar_x = static_cast(idx * 2);
+ const float value = std::clamp(bars[idx], 0.0F, 1.0F);
+ const float peak_value =
+ idx < peaks.size() ? std::clamp(peaks[idx], 0.0F, 1.0F) : value;
+ const int filled_height = static_cast(
+ value * static_cast(kSpectrumCanvasHeight - 1));
+ const int bar_top = baseline - filled_height;
+ c.DrawPointLine(bar_x, baseline, bar_x, bar_top,
+ theme.spectrum_color);
+ if (bar_x + 1 < canvas_width) {
+ c.DrawPointLine(bar_x + 1, baseline, bar_x + 1, bar_top,
+ theme.spectrum_color);
+ }
+
+ const bool should_draw_peak =
+ peak_value > value + kSpectrumPeakThreshold && peak_value > 0.0F;
+ if (should_draw_peak) {
+ const int peak_y =
+ baseline -
+ static_cast(peak_value *
+ static_cast(kSpectrumCanvasHeight - 1));
+ c.DrawPoint(bar_x, peak_y, true, theme.peak_color);
+ if (bar_x + 1 < canvas_width) {
+ c.DrawPoint(bar_x + 1, peak_y, true, theme.peak_color);
+ }
+ }
+ }
+ });
}
-// Render waveform points as lightweight ASCII intensity glyphs.
-Element RenderWaveform(const std::vector& wave) {
- std::vector points;
- points.reserve(wave.size());
- for (float v : wave) {
- const int level = static_cast(v * 7.0f);
- const char glyph = " .:-=+*#"[std::clamp(level, 0, 7)];
- points.push_back(text(std::string(1, glyph)) | color(Color::Magenta));
+// Render waveform points as a connected canvas line graph.
+Element RenderWaveform(const std::vector& wave, const Theme& theme) {
+ if (wave.empty()) {
+ return text("(no waveform data)");
}
- return hbox(std::move(points));
+ const int canvas_width = static_cast(wave.size());
+ return canvas(
+ canvas_width, kWaveformCanvasHeight,
+ [wave, theme, canvas_width](Canvas& c) {
+ const int max_y = kWaveformCanvasHeight - 1;
+ int previous_x = 0;
+ int previous_y = max_y;
+ for (size_t idx = 0; idx < wave.size(); ++idx) {
+ const int current_x = static_cast(idx);
+ const float value = std::clamp(wave[idx], 0.0F, 1.0F);
+ const int current_y =
+ static_cast((1.0F - value) * static_cast(max_y));
+ if (idx == 0) {
+ c.DrawPoint(current_x, current_y, true, theme.waveform_color);
+ } else {
+ c.DrawPointLine(previous_x, previous_y, current_x, current_y,
+ theme.waveform_color);
+ }
+ previous_x = current_x;
+ previous_y = current_y;
+ }
+ if (canvas_width > 0) {
+ c.DrawPointLine(0, max_y, canvas_width - 1, max_y, Color::GrayDark);
+ }
+ });
}
// Clamp playlist viewport start offset into valid range.
@@ -117,22 +177,25 @@ Element RenderPlaylist(const PlaylistViewModel& playlist, int view_offset,
}
// Render highlighted selection status line for pending vs live target.
-Element RenderSelectionStatus(const PlaylistViewModel& playlist) {
+Element RenderSelectionStatus(const PlaylistViewModel& playlist,
+ const Theme& theme) {
if (playlist.tracks.empty()) {
- return text("Selected: none (playlist is empty)") | color(Color::GrayDark);
+ return text("Selected: none (playlist is empty)") |
+ color(theme.warning_color);
}
const bool is_pending =
playlist.selected_track_index != playlist.current_track_index;
const std::string status_label = is_pending ? "PENDING" : "LIVE";
- const Color status_color = is_pending ? Color::Yellow : Color::Green;
+ const Color status_color =
+ is_pending ? theme.warning_color : theme.meter_color;
const std::string selected_text =
std::to_string(playlist.selected_track_index + 1) + "/" +
std::to_string(playlist.tracks.size());
return hbox({
text("Selected "),
- text(selected_text) | bold | color(Color::Cyan),
+ text(selected_text) | bold | color(theme.accent_color),
text(" "),
text("[" + status_label + "]") | bold | color(status_color),
text(" "),
@@ -140,6 +203,41 @@ Element RenderSelectionStatus(const PlaylistViewModel& playlist) {
});
}
+// Cycle layout mode for the main visualization region.
+VisualMode NextVisualMode(VisualMode mode) {
+ switch (mode) {
+ case VisualMode::kOverview:
+ return VisualMode::kSpectrumFocus;
+ case VisualMode::kSpectrumFocus:
+ return VisualMode::kWaveformFocus;
+ case VisualMode::kWaveformFocus:
+ return VisualMode::kMeterFocus;
+ case VisualMode::kMeterFocus:
+ default:
+ return VisualMode::kOverview;
+ }
+}
+
+// Build a concise mode label shown in footer/status.
+std::string VisualModeName(VisualMode mode) {
+ switch (mode) {
+ case VisualMode::kSpectrumFocus:
+ return "Spectrum";
+ case VisualMode::kWaveformFocus:
+ return "Waveform";
+ case VisualMode::kMeterFocus:
+ return "Meters";
+ case VisualMode::kOverview:
+ default:
+ return "Overview";
+ }
+}
+
+// Detect whether a mouse event occurred inside the given box.
+bool IsInsideBox(const Box& box, int x, int y) {
+ return x >= box.x_min && x <= box.x_max && y >= box.y_min && y <= box.y_max;
+}
+
} // namespace
// Run one interactive TUI session and map raw input to playback intents.
@@ -148,11 +246,19 @@ void TuiRenderer::Run(
const std::function& playlist_provider,
const std::function& on_intent,
const std::function& on_selection_changed,
- const std::function& should_stop) {
+ const std::function& should_stop, UiSessionState* session_state) {
VisualFrame latest_frame = frame_provider();
PlaylistViewModel latest_playlist = playlist_provider();
std::atomic keep_running = true;
Keybindings keybindings = DefaultKeybindings();
+ ThemeId active_theme_id = ThemeId::kDefault;
+ VisualMode active_visual_mode = VisualMode::kOverview;
+ bool use_envelope_waveform = false;
+ if (session_state != nullptr) {
+ active_theme_id = session_state->theme_id;
+ active_visual_mode = session_state->visual_mode;
+ use_envelope_waveform = session_state->use_envelope_waveform;
+ }
auto screen = ScreenInteractive::Fullscreen();
int track_count = static_cast(latest_playlist.tracks.size());
@@ -167,6 +273,7 @@ void TuiRenderer::Run(
ClampOffset(initial_offset, track_count, kPlaylistVisibleRows);
std::atomic selected_index{initial_selected};
std::atomic view_offset{initial_offset};
+ Box playlist_rows_box;
auto select_delta = [&](int delta) {
if (latest_playlist.tracks.empty()) {
@@ -198,38 +305,117 @@ void TuiRenderer::Run(
};
auto component = Renderer([&] {
+ const Theme& theme = GetBuiltinTheme(active_theme_id);
const PlaybackState& state = latest_frame.playback_state;
float ratio = 0.0f;
if (state.duration_sec > 0.0) {
ratio = static_cast(state.elapsed_sec / state.duration_sec);
}
+ const std::vector& wave_source =
+ use_envelope_waveform ? latest_frame.waveform_envelope_points
+ : latest_frame.waveform_points;
+ Element spectrum_panel =
+ window(text("Spectrum") | color(theme.title_color),
+ RenderSpectrum(latest_frame.spectrum_bars,
+ latest_frame.spectrum_peak_bars, theme));
+ Element waveform_panel = window(
+ text(use_envelope_waveform ? "Waveform (Envelope)" : "Waveform (Raw)") |
+ color(theme.title_color),
+ RenderWaveform(wave_source, theme));
+ Element meters_panel = window(
+ text("Meters") | color(theme.title_color),
+ vbox({
+ hbox({text("RMS ") | color(theme.text_color),
+ gauge(latest_frame.rms_level) | color(theme.meter_color) |
+ size(WIDTH, EQUAL, kMeterGaugeWidth)}),
+ hbox({text("Peak ") | color(theme.text_color),
+ gauge(latest_frame.peak_level) | color(theme.warning_color) |
+ size(WIDTH, EQUAL, kMeterGaugeWidth)}),
+ separator(),
+ text("Bands") | color(theme.accent_color),
+ hbox({text("Low ") | color(theme.text_color),
+ gauge(latest_frame.band_energies.size() > 0
+ ? latest_frame.band_energies[0]
+ : 0.0F) |
+ color(theme.spectrum_color) |
+ size(WIDTH, EQUAL, kMeterGaugeWidth)}),
+ hbox({text("Mid ") | color(theme.text_color),
+ gauge(latest_frame.band_energies.size() > 1
+ ? latest_frame.band_energies[1]
+ : 0.0F) |
+ color(theme.accent_color) |
+ size(WIDTH, EQUAL, kMeterGaugeWidth)}),
+ hbox({text("High ") | color(theme.text_color),
+ gauge(latest_frame.band_energies.size() > 2
+ ? latest_frame.band_energies[2]
+ : 0.0F) |
+ color(theme.peak_color) |
+ size(WIDTH, EQUAL, kMeterGaugeWidth)}),
+ }));
+
+ Element visual_area;
+ switch (active_visual_mode) {
+ case VisualMode::kSpectrumFocus:
+ visual_area = hbox({spectrum_panel | flex, meters_panel | flex});
+ break;
+ case VisualMode::kWaveformFocus:
+ visual_area = hbox({waveform_panel | flex, meters_panel | flex});
+ break;
+ case VisualMode::kMeterFocus:
+ visual_area = hbox({meters_panel | flex, spectrum_panel | flex});
+ break;
+ case VisualMode::kOverview:
+ default:
+ visual_area = hbox({spectrum_panel | flex, waveform_panel | flex,
+ meters_panel | flex});
+ break;
+ }
+
+ Element playlist_rows = RenderPlaylist(latest_playlist, view_offset.load(),
+ kPlaylistVisibleRows) |
+ reflect(playlist_rows_box);
+ Element playlist_panel =
+ window(text("Playlist") | color(theme.title_color),
+ vbox({
+ text("h/l prev/next Space pause j/k select Enter play") |
+ color(theme.text_color),
+ RenderSelectionStatus(latest_playlist, theme),
+ separator(),
+ playlist_rows,
+ }));
+
+ Element header_panel =
+ window(hcenter(text("Now Playing") | color(theme.title_color)),
+ vbox({
+ hcenter(text(latest_frame.track_info.title) |
+ color(theme.text_color)),
+ hcenter(text(latest_frame.track_info.artist) |
+ color(theme.text_color)),
+ hcenter(text(state.is_playing ? "Playing" : "Paused") |
+ color(theme.accent_color)),
+ hcenter(text(BuildProgressBar(ratio, 36)) |
+ color(theme.text_color)),
+ hcenter(text(FormatTime(state.elapsed_sec) + " / " +
+ FormatTime(state.duration_sec)) |
+ color(theme.text_color)),
+ }));
+
+ Element footer =
+ text("Mode[m]: " + VisualModeName(active_visual_mode) + " | Wave[v]: " +
+ std::string(use_envelope_waveform ? "Envelope" : "Raw") +
+ " | Theme[t]: " + GetThemeDisplayName(active_theme_id) +
+ " | q quit") |
+ color(theme.accent_color);
+
return vbox({
- text("VocalPlayer MVP") | bold,
- separator(),
- text("Title: " + latest_frame.track_info.title),
- text("Artist: " + latest_frame.track_info.artist),
- text(std::string("State: ") +
- (state.is_playing ? "Playing" : "Paused")),
- text("Progress: " + BuildProgressBar(ratio, 40)),
- text("Time: " + std::to_string(state.elapsed_sec) + " / " +
- std::to_string(state.duration_sec) + " sec"),
- separator(),
- text("Spectrum"),
- RenderSpectrum(latest_frame.spectrum_bars),
- separator(),
- text("Waveform"),
- RenderWaveform(latest_frame.waveform_points),
- separator(),
- text("Playlist (h/l prev/next, Space pause/resume, j/k move, "
- "click select, Enter play)"),
- RenderSelectionStatus(latest_playlist),
- RenderPlaylist(latest_playlist, view_offset.load(),
- kPlaylistVisibleRows),
- separator(),
- text("Press q to quit."),
+ hcenter(text("vocalplayer") | bold | color(theme.title_color)),
+ header_panel,
+ visual_area,
+ playlist_panel,
+ footer,
}) |
- border;
+ border | color(theme.border_color);
});
component |= CatchEvent([&](Event event) {
if (event == Event::Character(keybindings.quit)) {
@@ -262,6 +448,18 @@ void TuiRenderer::Run(
select_delta(1);
return true;
}
+ if (event == Event::Character(keybindings.cycle_visual_mode)) {
+ active_visual_mode = NextVisualMode(active_visual_mode);
+ return true;
+ }
+ if (event == Event::Character(keybindings.toggle_waveform_style)) {
+ use_envelope_waveform = !use_envelope_waveform;
+ return true;
+ }
+ if (event == Event::Character(keybindings.cycle_theme)) {
+ active_theme_id = NextThemeId(active_theme_id);
+ return true;
+ }
if (event == Event::Return) {
play_selected();
return true;
@@ -285,7 +483,10 @@ void TuiRenderer::Run(
return true;
}
if (mouse.button == Mouse::Left && mouse.motion == Mouse::Released) {
- int local_row = mouse.y - kPlaylistStartY;
+ if (!IsInsideBox(playlist_rows_box, mouse.x, mouse.y)) {
+ return false;
+ }
+ int local_row = mouse.y - playlist_rows_box.y_min;
int track_count = static_cast(latest_playlist.tracks.size());
int offset = view_offset.load();
int visible_count =
@@ -343,6 +544,11 @@ void TuiRenderer::Run(
if (refresh_thread.joinable()) {
refresh_thread.join();
}
+ if (session_state != nullptr) {
+ session_state->theme_id = active_theme_id;
+ session_state->visual_mode = active_visual_mode;
+ session_state->use_envelope_waveform = use_envelope_waveform;
+ }
}
// Build textual progress bar from playback ratio.
diff --git a/src/ui/tui_renderer.hpp b/src/ui/tui_renderer.hpp
index 9c67e63..e8e9657 100644
--- a/src/ui/tui_renderer.hpp
+++ b/src/ui/tui_renderer.hpp
@@ -16,6 +16,7 @@
#include "shared/types.hpp"
#include "ui/keybindings.hpp"
+#include "ui/theme.hpp"
namespace vocalplayer {
@@ -47,6 +48,18 @@ struct PlaylistViewModel {
int selected_track_index = 0;
};
+/**
+ * @brief Cross-track UI session preferences persisted by AppController.
+ */
+struct UiSessionState {
+ /// Active built-in color theme.
+ ThemeId theme_id = ThemeId::kDefault;
+ /// Active visualization layout mode.
+ VisualMode visual_mode = VisualMode::kOverview;
+ /// Whether envelope waveform rendering is enabled.
+ bool use_envelope_waveform = false;
+};
+
/**
* @brief Terminal renderer and input event bridge.
*
@@ -71,7 +84,8 @@ class TuiRenderer {
const std::function& playlist_provider,
const std::function& on_intent,
const std::function& on_selection_changed,
- const std::function& should_stop);
+ const std::function& should_stop,
+ UiSessionState* session_state = nullptr);
private:
/**
diff --git a/tests/test_keybindings.cpp b/tests/test_keybindings.cpp
index 8ccd54f..3b46a54 100644
--- a/tests/test_keybindings.cpp
+++ b/tests/test_keybindings.cpp
@@ -10,6 +10,9 @@ int main() {
assert(keybindings.toggle_pause == ' ');
assert(keybindings.move_selection_up == 'k');
assert(keybindings.move_selection_down == 'j');
+ assert(keybindings.cycle_visual_mode == 'm');
+ assert(keybindings.toggle_waveform_style == 'v');
+ assert(keybindings.cycle_theme == 't');
assert(keybindings.quit == 'q');
vocalplayer::Keybindings custom;
diff --git a/tests/test_spectrum_analyzer.cpp b/tests/test_spectrum_analyzer.cpp
index aea358c..656a0ff 100644
--- a/tests/test_spectrum_analyzer.cpp
+++ b/tests/test_spectrum_analyzer.cpp
@@ -16,9 +16,14 @@ int main() {
vocalplayer::SpectrumAnalyzer analyzer(2048, 48, 0.85f);
std::vector bars = analyzer.ComputeBars(signal);
std::vector waveform = analyzer.ComputeWaveform(signal, 64);
+ std::vector envelope = analyzer.ComputeWaveformEnvelope(signal, 64);
+ vocalplayer::AudioLevels levels = analyzer.ComputeLevels(signal);
+ std::vector band_energies = analyzer.ComputeBandEnergies(signal, 3);
assert(!bars.empty());
assert(!waveform.empty());
+ assert(!envelope.empty());
+ assert(band_energies.size() == 3);
bool has_energy = false;
for (float v : bars) {
@@ -29,6 +34,20 @@ int main() {
}
}
assert(has_energy);
+ assert(levels.rms_level >= 0.0f);
+ assert(levels.rms_level <= 1.0f);
+ assert(levels.peak_level >= 0.0f);
+ assert(levels.peak_level <= 1.0f);
+ assert(levels.peak_level >= levels.rms_level);
+
+ for (float value : envelope) {
+ assert(value >= 0.0f);
+ assert(value <= 1.0f);
+ }
+ for (float value : band_energies) {
+ assert(value >= 0.0f);
+ assert(value <= 1.0f);
+ }
return 0;
}
diff --git a/tests/test_theme.cpp b/tests/test_theme.cpp
new file mode 100644
index 0000000..61b455b
--- /dev/null
+++ b/tests/test_theme.cpp
@@ -0,0 +1,34 @@
+#include
+#include
+#include
+
+#include "ui/theme.hpp"
+
+int main() {
+ using vocalplayer::GetBuiltinTheme;
+ using vocalplayer::GetThemeDisplayName;
+ using vocalplayer::LoadThemeFromConfig;
+ using vocalplayer::NextThemeId;
+ using vocalplayer::Theme;
+ using vocalplayer::ThemeId;
+
+ const Theme& default_theme = GetBuiltinTheme(ThemeId::kDefault);
+ const Theme& miku_theme = GetBuiltinTheme(ThemeId::kMiku);
+ const Theme& teto_theme = GetBuiltinTheme(ThemeId::kTeto);
+
+ assert(GetThemeDisplayName(ThemeId::kDefault) == "Default");
+ assert(GetThemeDisplayName(ThemeId::kMiku) == "Miku");
+ assert(GetThemeDisplayName(ThemeId::kTeto) == "Teto");
+
+ assert(NextThemeId(ThemeId::kDefault) == ThemeId::kMiku);
+ assert(NextThemeId(ThemeId::kMiku) == ThemeId::kTeto);
+ assert(NextThemeId(ThemeId::kTeto) == ThemeId::kDefault);
+
+ assert(default_theme.title_color != miku_theme.title_color);
+ assert(teto_theme.border_color != miku_theme.border_color);
+
+ std::optional maybe_theme = LoadThemeFromConfig("theme.toml");
+ assert(!maybe_theme.has_value());
+
+ return 0;
+}
diff --git a/todo.md b/todo.md
index b10db41..874578d 100644
--- a/todo.md
+++ b/todo.md
@@ -2,7 +2,7 @@
## Iteration Plans
-- v0.1.0: MVP 实现(Almost by Cursor)
+- [x] v0.1.*: MVP 实现(Almost by Cursor)
- [x] 实现最小可播放链路(解码+播放+时间进度)
@@ -14,9 +14,31 @@
- [x] Code Review
-- v0.2.0: 频谱/波形可视化UI增强
+- [x] v0.2.*: 频谱/波形可视化UI增强
-## Project Engineering Practices:
+ - [x] 增强频谱/波形可视化UI(增加颜色、动画、交互等)
+
+ - [x] 引入颜色主题系统(默认、预留用户自定义配置接口)
+
+ - [x] 引入更丰富的可视化效果(如波形、频谱、波形图、频谱图等)
+
+ - [x] 引入音频信息仪表(RMS、Peak、低/中/高频段能量)
+
+ - [x] 引入播放器UI会话缓存机制
+
+- [ ] v0.3.*: 引入字符艺术动态背景引擎
+
+ - [ ] 引入UTF-8/ASCII字符艺术(主推泛vocaloid主题:Miku、Gumi、IA、Teto等)
+
+ - [ ] 参考开源项目实现播放时动态字符艺术背景引擎(实现图片/视频等渲染<实时渲染还是预渲染待定>,支持从工作目录读取视频并进行解码,支持用户自定义视频源,默认携带一个teto主题和一个miku主题的ascii动态背景)
+
+- [ ] v0.4.*: 引入ncmdump API;实现自动读取网易云本地音乐库
+
+- [ ] v1.0.*:
+
+ - [ ] 将列表播放会话逻辑重构为单次UI会话,切换播放曲目时不退出UI循环,仅触发控制器换曲
+
+## Project Engineering Practices
- [x] Formatted code with clang-format (in `v0.1.0`)
@@ -26,7 +48,15 @@
- [x] Add clang-tidy, tests and release builds in GitHub CI/CD pipeline (in `v0.1.1`)
-## Longterm Plan
+- [ ] Add xmake build config support
+
+## Longterm / Debatable Plan
+
+- [ ] 用户自定义颜色主题系统实现
+
+- [ ] 音频解码层 FFmpeg API 并行调用实现
+
+- [ ] Rust Version: Rust并行实现
- [ ] Analyzer Backend 抽象:将现有 `SpectrumAnalyzer` 拆分为前端调度层 + 后端接口(`IAnalyzerBackend`),支持 CPU/CUDA 双实现。