Ver Fonte

新增频谱效果

chendeben há 6 meses atrás
pai
commit
222aa6c1f5

+ 29 - 7
entry/src/main/ets/view/LocalMusic.ets

@@ -9967,9 +9967,6 @@ export struct LocalMusic {
 
 
           Swiper() {
           Swiper() {
             this.CoverInfo()
             this.CoverInfo()
-            SpectrumView({
-              isVisible: this.currentSwiperIndex === 1
-            })
             this.PlayerLyrics()
             this.PlayerLyrics()
           }
           }
           .onChange((index: number) => {
           .onChange((index: number) => {
@@ -9979,10 +9976,10 @@ export struct LocalMusic {
           .indicator(false)
           .indicator(false)
           .displayCount(new BreakpointType({
           .displayCount(new BreakpointType({
             sm: 1,
             sm: 1,
-            md: 1,
-            lg: 1,
-            xl: 1,
-            xxl: 1
+            md: 2,
+            lg: 2,
+            xl: 2,
+            xxl: 2
           }).getValue(this.currentBreakpoint))
           }).getValue(this.currentBreakpoint))
           .clip(false)
           .clip(false)
           .loop(false)
           .loop(false)
@@ -11403,6 +11400,10 @@ export struct LocalMusic {
         top: this.isCoverOpacity() ? 0 : 20
         top: this.isCoverOpacity() ? 0 : 20
       })
       })
 
 
+      if (this.isPhonePortrait()) {
+        this.InlineSpectrumView()
+      }
+
       if (this.isPhonePortrait() && this.isShowSingleLineLyric) {
       if (this.isPhonePortrait() && this.isShowSingleLineLyric) {
         this.SingleLyricView()
         this.SingleLyricView()
       }
       }
@@ -11421,6 +11422,8 @@ export struct LocalMusic {
     .clip(true)
     .clip(true)
 
 
   }
   }
+
+
   //方形封面布局
   //方形封面布局
   @Builder
   @Builder
   RectangleCoverView(){
   RectangleCoverView(){
@@ -15288,6 +15291,25 @@ export struct LocalMusic {
     return duration;
     return duration;
   }
   }
 
 
+  @Builder
+  InlineSpectrumView() {
+    SpectrumView({
+      isVisible: this.currentSwiperIndex === 0,
+      isPlaying: this.CONTROL_PlayStatus === PlayStatus.PLAY
+    })
+      .width(this.isLandscape ? '82%' : '90%')
+      .height(this.isCoverOpacity() ? 120 : 150)
+      .margin({ top: 8, bottom: 12 })
+      .borderRadius(16)
+      .clip(true)
+      .backgroundColor('rgba(0,0,0,0.24)')
+      .shadow({
+        radius: 14,
+        type: ShadowType.BLUR,
+        color: 'rgba(0,0,0,0.35)'
+      })
+  }
+
   private getSongDurationMs(song?: VideoItem): number {
   private getSongDurationMs(song?: VideoItem): number {
     if (!song || !song.duration) {
     if (!song || !song.duration) {
       return 0;
       return 0;

+ 1 - 1
entry/src/main/ets/view/spectrum/SpectrumView.ets

@@ -24,7 +24,7 @@ const MODE_NAMES: string[] = ['柱状', '波形', '圆形', '粒子']
 @Component
 @Component
 export struct SpectrumView {
 export struct SpectrumView {
   @StorageLink('spectrumMode') currentMode: number = 0
   @StorageLink('spectrumMode') currentMode: number = 0
-  @StorageProp('isPlaying') @Watch('onPlayingChange') isPlaying: boolean = false
+  @Prop @Watch('onPlayingChange') isPlaying: boolean = false
   @StorageProp('themeColor') themeColor: string = '#FF0050'
   @StorageProp('themeColor') themeColor: string = '#FF0050'
   @Prop @Watch('onVisibilityChange') isVisible: boolean = false
   @Prop @Watch('onVisibilityChange') isVisible: boolean = false
   @State showModePanel: boolean = false
   @State showModePanel: boolean = false

+ 0 - 0
openspec/changes/add-audio-spectrum-animation/.openspec.yaml → openspec/changes/archive/2026-02-07-add-audio-spectrum-animation/.openspec.yaml


+ 0 - 0
openspec/changes/add-audio-spectrum-animation/design.md → openspec/changes/archive/2026-02-07-add-audio-spectrum-animation/design.md


+ 0 - 0
openspec/changes/add-audio-spectrum-animation/proposal.md → openspec/changes/archive/2026-02-07-add-audio-spectrum-animation/proposal.md


+ 0 - 0
openspec/changes/add-audio-spectrum-animation/specs/audio-pcm-bridge/spec.md → openspec/changes/archive/2026-02-07-add-audio-spectrum-animation/specs/audio-pcm-bridge/spec.md


+ 0 - 0
openspec/changes/add-audio-spectrum-animation/specs/fft-analyzer/spec.md → openspec/changes/archive/2026-02-07-add-audio-spectrum-animation/specs/fft-analyzer/spec.md


+ 0 - 0
openspec/changes/add-audio-spectrum-animation/specs/spectrum-visualizer/spec.md → openspec/changes/archive/2026-02-07-add-audio-spectrum-animation/specs/spectrum-visualizer/spec.md


+ 0 - 0
openspec/changes/add-audio-spectrum-animation/tasks.md → openspec/changes/archive/2026-02-07-add-audio-spectrum-animation/tasks.md


+ 45 - 0
openspec/specs/audio-pcm-bridge/spec.md

@@ -0,0 +1,45 @@
+## ADDED Requirements
+
+### Requirement: PCM 环形缓冲区捕获
+系统 SHALL 在原生音频输出回调 `AudioRendererOnWriteData` 中,于均衡器处理完成后,将 PCM 数据拷贝到一个固定大小的环形缓冲区中。缓冲区 SHALL 使用单写单读无锁模式,写端为音频线程,读端为 NAPI 调用线程。
+
+#### Scenario: 正常播放时捕获 PCM 数据
+- **WHEN** 音频正在播放,均衡器处理完成后
+- **THEN** 系统将当前帧的 PCM 数据(int16 格式)写入环形缓冲区,并更新写入位置标记和 `has_data` 标志为 true
+
+#### Scenario: 播放暂停时停止捕获
+- **WHEN** 音频处于暂停状态(`pause_on == true`)
+- **THEN** `AudioRendererOnWriteData` 直接返回,不向缓冲区写入任何数据
+
+#### Scenario: 缓冲区写满时覆盖旧数据
+- **WHEN** 环形缓冲区写满
+- **THEN** 系统 SHALL 从头覆盖旧数据,保证缓冲区始终包含最近的 PCM 数据
+
+### Requirement: 缓冲区记录音频元信息
+环形缓冲区 SHALL 同时记录当前音频的采样率(`sample_rate`)和声道数(`channels`),供 ArkTS 侧读取时使用。
+
+#### Scenario: 音频初始化时设置元信息
+- **WHEN** 音频渲染器通过 `AoutOpenAudio` 初始化成功
+- **THEN** 系统 SHALL 将 `desired->freq`(采样率)和 `desired->channels`(声道数)写入缓冲区的元信息字段
+
+### Requirement: NAPI 接口获取频谱数据
+系统 SHALL 提供名为 `_getSpectrumData` 的 NAPI 函数,ArkTS 侧可通过此函数同步获取当前环形缓冲区中的 PCM 数据快照。
+
+#### Scenario: 播放中调用获取数据
+- **WHEN** ArkTS 侧在音频播放中调用 `_getSpectrumData(id)`
+- **THEN** 系统 SHALL 返回一个包含 PCM 样本数据的 `number[]` 数组(1024 个样本,单声道),以及当前采样率
+
+#### Scenario: 无可用数据时返回空
+- **WHEN** ArkTS 侧调用 `_getSpectrumData(id)` 但缓冲区 `has_data` 为 false(未开始播放或已停止)
+- **THEN** 系统 SHALL 返回空数组 `[]`
+
+#### Scenario: 立体声转单声道
+- **WHEN** 音频为立体声(`channels == 2`)
+- **THEN** NAPI 函数 SHALL 将左右声道取平均值合并为单声道后返回
+
+### Requirement: ArkTS 层接口声明
+`IjkMediaPlayer` 类 SHALL 新增 `getSpectrumData(): number[]` 公开方法,封装对原生 `_getSpectrumData` NAPI 的调用。
+
+#### Scenario: ArkTS 调用频谱数据接口
+- **WHEN** 外部组件调用 `IjkMediaPlayer.getInstance().getSpectrumData()`
+- **THEN** 系统 SHALL 返回当前 PCM 样本数组,格式为 `number[]`,长度为 1024 或 0(无数据时)

+ 52 - 0
openspec/specs/fft-analyzer/spec.md

@@ -0,0 +1,52 @@
+## ADDED Requirements
+
+### Requirement: FFT 频谱变换
+系统 SHALL 提供 `FFTAnalyzer` 工具类,接收 1024 个 PCM 时域样本(`number[]`),通过 Cooley-Tukey 基 2 快速傅立叶变换算法计算出 512 个频率 bin 的幅值数组。
+
+#### Scenario: 正常 FFT 计算
+- **WHEN** 调用 `FFTAnalyzer.analyze(pcmData: number[], sampleRate: number)` 且 `pcmData` 长度为 1024
+- **THEN** 系统 SHALL 返回 `SpectrumData` 对象,包含 512 个频率 bin 的归一化幅值(0.0 ~ 1.0 范围)
+
+#### Scenario: 输入数据为空
+- **WHEN** 调用 `FFTAnalyzer.analyze(pcmData, sampleRate)` 且 `pcmData` 长度为 0
+- **THEN** 系统 SHALL 返回全零的 `SpectrumData` 对象
+
+### Requirement: Hann 窗函数预处理
+FFT 计算前 SHALL 对输入 PCM 数据应用 Hann 窗函数,以减少频谱泄漏。
+
+#### Scenario: 窗函数应用
+- **WHEN** 1024 个 PCM 样本进入 FFT 分析流程
+- **THEN** 系统 SHALL 将每个样本乘以对应位置的 Hann 窗系数 `0.5 * (1 - cos(2π * n / N))`,然后再执行 FFT
+
+### Requirement: 对数频率映射与频段聚合
+系统 SHALL 将 512 个线性频率 bin 按对数刻度映射并聚合为 32 个可视化频段。
+
+#### Scenario: 频段聚合输出
+- **WHEN** FFT 计算完成后调用 `SpectrumData.getBands(bandCount: number)`
+- **THEN** 系统 SHALL 返回 `number[]`,长度等于 `bandCount`,每个元素为对应频率范围内所有 bin 幅值的平均值,归一化到 0.0 ~ 1.0
+
+#### Scenario: 低频到高频覆盖
+- **WHEN** 采样率为 44100Hz,请求 32 个频段
+- **THEN** 频段 SHALL 按对数分布覆盖约 32Hz ~ 16000Hz 范围,低频区每个频段覆盖较窄的频率范围,高频区每个频段覆盖较宽的频率范围
+
+### Requirement: 指数平滑处理
+系统 SHALL 对连续帧的频谱数据进行指数移动平均平滑,避免视觉跳变。
+
+#### Scenario: 平滑衰减
+- **WHEN** 新一帧频谱数据到达
+- **THEN** 每个频段的输出值 SHALL 计算为 `output = newValue * smoothUp + previousOutput * (1 - smoothUp)`(上升时),或 `output = newValue * smoothDown + previousOutput * (1 - smoothDown)`(下降时),其中上升系数约 0.7,下降系数约 0.3,实现快速响应、缓慢衰减的视觉效果
+
+#### Scenario: 首帧无历史数据
+- **WHEN** 第一帧频谱数据到达(无历史数据)
+- **THEN** 系统 SHALL 直接使用当前帧数据作为输出,不进行平滑
+
+### Requirement: 总体能量值计算
+`SpectrumData` SHALL 提供 `getEnergy(): number` 方法,返回当前帧所有频段幅值的均方根值,归一化到 0.0 ~ 1.0,代表当前音频的整体响度/能量。
+
+#### Scenario: 静音时能量为零
+- **WHEN** 所有 PCM 样本值均为 0
+- **THEN** `getEnergy()` SHALL 返回 0.0
+
+#### Scenario: 正常播放时计算能量
+- **WHEN** 音频正常播放,频谱数据有效
+- **THEN** `getEnergy()` SHALL 返回一个介于 0.0 ~ 1.0 之间的值,反映当前帧的整体响度

+ 109 - 0
openspec/specs/spectrum-visualizer/spec.md

@@ -0,0 +1,109 @@
+## ADDED Requirements
+
+### Requirement: 频谱可视化组件
+系统 SHALL 提供 `SpectrumView` ArkUI 自定义组件,使用 Canvas 实时渲染频谱动画。该组件 SHALL 接收频谱数据并根据当前选中的特效模式进行绘制。
+
+#### Scenario: 组件正常渲染
+- **WHEN** `SpectrumView` 组件挂载且音频正在播放
+- **THEN** 组件 SHALL 以 30fps 的频率从 `IjkMediaPlayer` 获取 PCM 数据,经 FFT 分析后通过 Canvas 绘制当前特效
+
+#### Scenario: 组件不可见时停止渲染
+- **WHEN** 用户滑动 Swiper 离开频谱页面(组件不可见)
+- **THEN** 组件 SHALL 停止定时器、停止获取 PCM 数据和 Canvas 绑帧渲染,释放 CPU 资源
+
+#### Scenario: 音频暂停时冻结画面
+- **WHEN** 音频从播放切换到暂停状态
+- **THEN** 组件 SHALL 停止定时器,保留最后一帧的渲染画面不清除
+
+### Requirement: 柱状频谱特效
+系统 SHALL 提供柱状频谱渲染模式,以等间距垂直矩形条展示各频段的实时幅值。
+
+#### Scenario: 柱状条高度随频谱变化
+- **WHEN** 柱状频谱模式激活且频谱数据更新
+- **THEN** 每个矩形条的高度 SHALL 与对应频段的幅值成正比,底部对齐 Canvas 底边
+
+#### Scenario: 柱状条颜色由频段决定
+- **WHEN** 绘制柱状频谱
+- **THEN** 矩形条 SHALL 使用从低频到高频的渐变色(如蓝→青→绿→黄→红),颜色亮度 SHALL 随幅值增大而提高
+
+#### Scenario: 柱状条带下落动画
+- **WHEN** 某频段幅值从高值下降
+- **THEN** 矩形条顶部 SHALL 有一个小方块(峰值指示器),以较慢的速度缓慢下落,模拟重力效果
+
+### Requirement: 波形频谱特效
+系统 SHALL 提供波形频谱渲染模式,以平滑曲线展示频率分布。
+
+#### Scenario: 波形曲线绘制
+- **WHEN** 波形频谱模式激活且频谱数据更新
+- **THEN** 系统 SHALL 使用贝塞尔曲线连接各频段数据点,形成平滑的波形曲线
+
+#### Scenario: 波形填充与透明度
+- **WHEN** 绘制波形频谱
+- **THEN** 曲线下方区域 SHALL 使用半透明渐变填充,透明度从曲线处的 0.6 渐变到底部的 0.1
+
+#### Scenario: 线宽随能量变化
+- **WHEN** 当前帧总体能量值变化
+- **THEN** 波形曲线的线宽 SHALL 在 2px ~ 5px 范围内随能量值成正比变化
+
+### Requirement: 圆形频谱特效
+系统 SHALL 提供圆形频谱渲染模式,以环形排列的放射线展示频谱数据。
+
+#### Scenario: 放射线长度随幅值变化
+- **WHEN** 圆形频谱模式激活且频谱数据更新
+- **THEN** 以 Canvas 中心为圆心,每个频段对应一条从内圆向外延伸的放射线,线长 SHALL 与频段幅值成正比
+
+#### Scenario: 圆形频谱旋转
+- **WHEN** 音频持续播放
+- **THEN** 整个圆形频谱 SHALL 以恒定速度缓慢旋转(约 10 秒一圈),旋转速度 SHALL 随总体能量值在 0.5x ~ 1.5x 范围内变化
+
+#### Scenario: 放射线颜色映射
+- **WHEN** 绘制放射线
+- **THEN** 每条放射线的颜色 SHALL 根据其角度位置从色相环映射(0° = 红,120° = 绿,240° = 蓝),形成彩虹色环效果
+
+### Requirement: 粒子频谱特效
+系统 SHALL 提供粒子频谱渲染模式,使用粒子系统以粒子的运动、大小和颜色表达频谱变化。
+
+#### Scenario: 粒子发射由频谱驱动
+- **WHEN** 粒子频谱模式激活且频谱数据更新
+- **THEN** 系统 SHALL 从 Canvas 底部区域发射粒子,发射速率与总体能量值成正比,高能量时密集发射、低能量时稀疏发射
+
+#### Scenario: 粒子属性由频段驱动
+- **WHEN** 新粒子生成
+- **THEN** 粒子的初始大小 SHALL 在 2px ~ 8px 范围内由对应频段幅值决定,初始颜色 SHALL 由频段映射(低频=暖色,高频=冷色),向上运动速度 SHALL 与幅值成正比
+
+#### Scenario: 粒子池复用
+- **WHEN** 粒子超出 Canvas 边界或透明度衰减到 0
+- **THEN** 该粒子 SHALL 被标记为不活跃并回收到粒子池中等待复用,而非销毁和重新创建。粒子池总量 SHALL 固定为 200 个
+
+### Requirement: 特效切换交互
+系统 SHALL 在频谱页面提供特效切换 UI,用户可在柱状、波形、圆形、粒子四种特效间自由切换。
+
+#### Scenario: 点击切换特效
+- **WHEN** 用户点击频谱页面上的特效切换按钮
+- **THEN** 系统 SHALL 显示特效选择面板(显示 4 种特效的名称和图标预览),用户选择后立即切换到对应特效
+
+#### Scenario: 切换时平滑过渡
+- **WHEN** 用户切换特效模式
+- **THEN** 系统 SHALL 在切换瞬间清除 Canvas 并立即以新特效开始渲染,无需过渡动画
+
+#### Scenario: 记忆用户选择
+- **WHEN** 用户选择了某种特效后退出播放界面
+- **THEN** 下次进入频谱页面时 SHALL 默认使用上次选择的特效模式
+
+### Requirement: Swiper 集成
+播放界面的 Swiper 容器 SHALL 在封面页面和歌词页面之间插入频谱页面,页面顺序为:封面 → 频谱 → 歌词。
+
+#### Scenario: 滑动到频谱页面
+- **WHEN** 用户在播放界面从封面页面向左滑动
+- **THEN** Swiper SHALL 切换到频谱页面,频谱动画开始渲染
+
+#### Scenario: 从频谱页面继续滑动
+- **WHEN** 用户在频谱页面继续向左滑动
+- **THEN** Swiper SHALL 切换到歌词页面,频谱动画停止渲染
+
+### Requirement: 颜色主题适配
+频谱可视化 SHALL 支持跟随应用当前主题的主色调。
+
+#### Scenario: 主题色影响频谱配色
+- **WHEN** 应用当前主题为某一预设主题(默认、暮色、森林、珊瑚、极夜)
+- **THEN** 频谱特效的基础色调 SHALL 使用当前主题的品牌色 `$r('app.color.brand')` 作为主色,其他颜色基于此生成