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 双实现。