Parcourir la source

新增频谱效果

chendeben il y a 6 mois
Parent
commit
887d756e9d
33 fichiers modifiés avec 1505 ajouts et 10 suppressions
  1. 204 0
      entry/src/main/ets/common/util/FFTAnalyzer.ets
  2. 8 4
      entry/src/main/ets/view/LocalMusic.ets
  3. 86 0
      entry/src/main/ets/view/spectrum/BarSpectrumRenderer.ets
  4. 83 0
      entry/src/main/ets/view/spectrum/CircleSpectrumRenderer.ets
  5. 117 0
      entry/src/main/ets/view/spectrum/ParticleSpectrumRenderer.ets
  6. 127 0
      entry/src/main/ets/view/spectrum/SpectrumRenderer.ets
  7. 176 0
      entry/src/main/ets/view/spectrum/SpectrumView.ets
  8. 99 0
      entry/src/main/ets/view/spectrum/WaveSpectrumRenderer.ets
  9. 5 1
      entry/src/main/resources/base/element/color.json
  10. 1 0
      ijkplayer/oh_modules/@types/libijkplayer_audio_napi.so/index.d.ts
  11. 13 0
      ijkplayer/src/main/cpp/ijkplayer/ijkplayer_android.c
  12. 4 0
      ijkplayer/src/main/cpp/ijkplayer/ijkplayer_android.h
  13. 29 0
      ijkplayer/src/main/cpp/ijksdl/audio/ijksdl_aout_android_opensles.c
  14. 12 0
      ijkplayer/src/main/cpp/ijksdl/audio/ijksdl_equalizer.h
  15. 3 0
      ijkplayer/src/main/cpp/ijksdl/ijksdl_aout.c
  16. 4 0
      ijkplayer/src/main/cpp/ijksdl/ijksdl_aout.h
  17. 58 4
      ijkplayer/src/main/cpp/napi/ijkplayer_napi.cpp
  18. 1 0
      ijkplayer/src/main/cpp/napi/ijkplayer_napi.h
  19. 2 1
      ijkplayer/src/main/cpp/napi/ijkplayer_napi_audio_init.cpp
  20. 10 0
      ijkplayer/src/main/cpp/proxy/ijkplayer_napi_proxy.cpp
  21. 2 0
      ijkplayer/src/main/cpp/proxy/ijkplayer_napi_proxy.h
  22. 1 0
      ijkplayer/src/main/cpp/types/ijkplayer_audio_napi/index.d.ts
  23. 8 0
      ijkplayer/src/main/ets/ijkplayer/IjkMediaPlayer.ets
  24. 1 0
      ijkplayer/src/main/ets/ijkplayer/utils/IjkPlayerNapi.d.ets
  25. 2 0
      openspec/changes/add-audio-spectrum-animation/.openspec.yaml
  26. 128 0
      openspec/changes/add-audio-spectrum-animation/design.md
  27. 35 0
      openspec/changes/add-audio-spectrum-animation/proposal.md
  28. 45 0
      openspec/changes/add-audio-spectrum-animation/specs/audio-pcm-bridge/spec.md
  29. 52 0
      openspec/changes/add-audio-spectrum-animation/specs/fft-analyzer/spec.md
  30. 109 0
      openspec/changes/add-audio-spectrum-animation/specs/spectrum-visualizer/spec.md
  31. 58 0
      openspec/changes/add-audio-spectrum-animation/tasks.md
  32. 2 0
      openspec/changes/improve-git-collaboration-workflow/.openspec.yaml
  33. 20 0
      openspec/config.yaml

+ 204 - 0
entry/src/main/ets/common/util/FFTAnalyzer.ets

@@ -0,0 +1,204 @@
+/**
+ * FFT 频谱分析器
+ * 基于 Cooley-Tukey 基2 FFT 算法,将时域 PCM 数据转换为频域频谱数据
+ */
+
+const FFT_SIZE: number = 1024
+const HALF_FFT: number = 512
+
+// 预计算 Hann 窗系数
+const hannWindow: number[] = initHannWindow()
+
+function initHannWindow(): number[] {
+  const window: number[] = new Array<number>(FFT_SIZE)
+  for (let i = 0; i < FFT_SIZE; i++) {
+    window[i] = 0.5 * (1.0 - Math.cos(2.0 * Math.PI * i / FFT_SIZE))
+  }
+  return window
+}
+
+/**
+ * 频谱数据封装类
+ */
+export class SpectrumData {
+  private magnitudes: number[] // 512 个频率 bin 的归一化幅值
+  private sampleRate: number
+
+  constructor(magnitudes: number[], sampleRate: number) {
+    this.magnitudes = magnitudes
+    this.sampleRate = sampleRate
+  }
+
+  /**
+   * 按对数频率映射聚合为指定数量的频段
+   */
+  getBands(bandCount: number): number[] {
+    const bands: number[] = new Array<number>(bandCount)
+    const minFreq: number = 32.0
+    const maxFreq: number = Math.min(16000.0, this.sampleRate / 2.0)
+    const logMin: number = Math.log(minFreq)
+    const logMax: number = Math.log(maxFreq)
+    const freqPerBin: number = this.sampleRate / FFT_SIZE
+
+    for (let i = 0; i < bandCount; i++) {
+      const lowFreq: number = Math.exp(logMin + (logMax - logMin) * i / bandCount)
+      const highFreq: number = Math.exp(logMin + (logMax - logMin) * (i + 1) / bandCount)
+
+      const lowBin: number = Math.max(1, Math.floor(lowFreq / freqPerBin))
+      const highBin: number = Math.min(HALF_FFT - 1, Math.ceil(highFreq / freqPerBin))
+
+      let sum: number = 0.0
+      let count: number = 0
+      for (let b = lowBin; b <= highBin; b++) {
+        sum += this.magnitudes[b]
+        count++
+      }
+      bands[i] = count > 0 ? sum / count : 0.0
+    }
+    return bands
+  }
+
+  /**
+   * 获取总体能量值 (RMS)
+   */
+  getEnergy(): number {
+    let sumSquared: number = 0.0
+    for (let i = 0; i < this.magnitudes.length; i++) {
+      sumSquared += this.magnitudes[i] * this.magnitudes[i]
+    }
+    return Math.sqrt(sumSquared / this.magnitudes.length)
+  }
+}
+
+/**
+ * FFT 分析器
+ */
+export class FFTAnalyzer {
+  private previousBands: number[] | null = null
+
+  /**
+   * 对 PCM 数据执行 FFT 分析
+   */
+  analyze(pcmData: number[], sampleRate: number): SpectrumData {
+    if (pcmData.length === 0) {
+      const zeros: number[] = new Array<number>(HALF_FFT)
+      for (let i = 0; i < HALF_FFT; i++) {
+        zeros[i] = 0.0
+      }
+      return new SpectrumData(zeros, sampleRate)
+    }
+
+    // 应用 Hann 窗
+    const real: number[] = new Array<number>(FFT_SIZE)
+    const imag: number[] = new Array<number>(FFT_SIZE)
+    const len: number = Math.min(pcmData.length, FFT_SIZE)
+    for (let i = 0; i < len; i++) {
+      real[i] = pcmData[i] * hannWindow[i] / 32768.0 // 归一化 int16 到 [-1, 1]
+      imag[i] = 0.0
+    }
+    for (let i = len; i < FFT_SIZE; i++) {
+      real[i] = 0.0
+      imag[i] = 0.0
+    }
+
+    // Cooley-Tukey FFT (in-place, 基2)
+    FFTAnalyzer.fftInPlace(real, imag, FFT_SIZE)
+
+    // 计算幅值并归一化
+    let maxMag: number = 0.0
+    const magnitudes: number[] = new Array<number>(HALF_FFT)
+    for (let i = 0; i < HALF_FFT; i++) {
+      magnitudes[i] = Math.sqrt(real[i] * real[i] + imag[i] * imag[i])
+      if (magnitudes[i] > maxMag) {
+        maxMag = magnitudes[i]
+      }
+    }
+
+    // 归一化到 [0, 1]
+    if (maxMag > 0.0) {
+      for (let i = 0; i < HALF_FFT; i++) {
+        magnitudes[i] = magnitudes[i] / maxMag
+      }
+    }
+
+    return new SpectrumData(magnitudes, sampleRate)
+  }
+
+  /**
+   * 指数平滑处理
+   * 上升系数 0.7(快速响应),下降系数 0.3(缓慢衰减)
+   */
+  smooth(bands: number[]): number[] {
+    if (this.previousBands === null || this.previousBands.length !== bands.length) {
+      this.previousBands = bands.slice()
+      return bands.slice()
+    }
+
+    const smoothed: number[] = new Array<number>(bands.length)
+    for (let i = 0; i < bands.length; i++) {
+      if (bands[i] >= this.previousBands[i]) {
+        // 上升:快速响应
+        smoothed[i] = bands[i] * 0.7 + this.previousBands[i] * 0.3
+      } else {
+        // 下降:缓慢衰减
+        smoothed[i] = bands[i] * 0.3 + this.previousBands[i] * 0.7
+      }
+    }
+    this.previousBands = smoothed.slice()
+    return smoothed
+  }
+
+  /**
+   * 重置平滑状态
+   */
+  reset(): void {
+    this.previousBands = null
+  }
+
+  /**
+   * Cooley-Tukey 基2 FFT (in-place)
+   */
+  private static fftInPlace(real: number[], imag: number[], n: number): void {
+    // 位逆序排列
+    let j: number = 0
+    for (let i = 0; i < n; i++) {
+      if (i < j) {
+        let tempR: number = real[i]
+        let tempI: number = imag[i]
+        real[i] = real[j]
+        imag[i] = imag[j]
+        real[j] = tempR
+        imag[j] = tempI
+      }
+      let m: number = n >> 1
+      while (m >= 1 && j >= m) {
+        j -= m
+        m >>= 1
+      }
+      j += m
+    }
+
+    // 蝶形运算
+    let step: number = 2
+    while (step <= n) {
+      const halfStep: number = step >> 1
+      const angleStep: number = -2.0 * Math.PI / step
+      for (let k = 0; k < n; k += step) {
+        for (let m2 = 0; m2 < halfStep; m2++) {
+          const angle: number = angleStep * m2
+          const cosA: number = Math.cos(angle)
+          const sinA: number = Math.sin(angle)
+          const idx1: number = k + m2
+          const idx2: number = k + m2 + halfStep
+          const tR: number = cosA * real[idx2] - sinA * imag[idx2]
+          const tI: number = sinA * real[idx2] + cosA * imag[idx2]
+          real[idx2] = real[idx1] - tR
+          imag[idx2] = imag[idx1] - tI
+          real[idx1] = real[idx1] + tR
+          imag[idx1] = imag[idx1] + tI
+        }
+      }
+      step <<= 1
+    }
+  }
+}

+ 8 - 4
entry/src/main/ets/view/LocalMusic.ets

@@ -63,6 +63,7 @@ import { MessageEvents, util, worker, ErrorEvent, JSON } from '@kit.ArkTS';
 import { Verify } from './Verify';
 import { taskpool } from '@kit.ArkTS';
 import { RotatingCover } from './RotatingCover';
+import { SpectrumView } from './spectrum/SpectrumView';
 import { PointLightButton } from './PointLight/PointLightButton';
 import { PlayConstants } from '../common/constants/PlayConstants';
 import { effectKit } from '@kit.ArkGraphics2D';
@@ -9966,6 +9967,9 @@ export struct LocalMusic {
 
           Swiper() {
             this.CoverInfo()
+            SpectrumView({
+              isVisible: this.currentSwiperIndex === 1
+            })
             this.PlayerLyrics()
           }
           .onChange((index: number) => {
@@ -9975,10 +9979,10 @@ export struct LocalMusic {
           .indicator(false)
           .displayCount(new BreakpointType({
             sm: 1,
-            md: 2,
-            lg: 2,
-            xl: 2,
-            xxl: 2
+            md: 1,
+            lg: 1,
+            xl: 1,
+            xxl: 1
           }).getValue(this.currentBreakpoint))
           .clip(false)
           .loop(false)

+ 86 - 0
entry/src/main/ets/view/spectrum/BarSpectrumRenderer.ets

@@ -0,0 +1,86 @@
+import { SpectrumRenderer, buildSpectrumPalette, hslToHex } from './SpectrumRenderer'
+
+/**
+ * 柱状频谱渲染器
+ * 经典频率柱状条跳动,带峰值下落指示器
+ */
+export class BarSpectrumRenderer implements SpectrumRenderer {
+  private peaks: number[] = []
+  private peakVelocities: number[] = []
+
+  render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
+    width: number, height: number, brandColor: string): void {
+    const bandCount: number = bands.length
+    if (bandCount === 0) return
+
+    // 初始化峰值数组
+    if (this.peaks.length !== bandCount) {
+      this.peaks = new Array<number>(bandCount)
+      this.peakVelocities = new Array<number>(bandCount)
+      for (let i = 0; i < bandCount; i++) {
+        this.peaks[i] = 0
+        this.peakVelocities[i] = 0
+      }
+    }
+
+    // 轻微拖影,接近参考图中的荧光残影
+    ctx.fillStyle = 'rgba(0, 0, 0, 0.30)'
+    ctx.fillRect(0, 0, width, height)
+
+    const gap: number = 2.2
+    const barWidth: number = (width - gap * (bandCount + 1)) / bandCount
+    const usableHeight: number = height * 0.80
+    const segmentHeight: number = Math.max(2, height * 0.012)
+    const segmentGap: number = Math.max(1, height * 0.004)
+    const segmentCount: number = Math.max(10, Math.floor(usableHeight / (segmentHeight + segmentGap)))
+    const palette = buildSpectrumPalette(brandColor)
+    const hueSpan: number = (palette.complementaryHue + 360 - palette.primaryHue) % 360
+
+    // 地平线
+    ctx.fillStyle = hslToHex(palette.accentHue, 0.7, 0.45)
+    ctx.globalAlpha = 0.25
+    ctx.fillRect(0, height - usableHeight, width, 1)
+    ctx.globalAlpha = 1.0
+
+    for (let i = 0; i < bandCount; i++) {
+      const level: number = Math.max(0, Math.min(1, bands[i]))
+      const activeSegments: number = Math.floor(level * segmentCount)
+      const x: number = gap + i * (barWidth + gap)
+      const hue: number = (palette.primaryHue + (i / bandCount) * hueSpan) % 360
+
+      for (let seg = 0; seg < segmentCount; seg++) {
+        const segY: number = height - (seg + 1) * (segmentHeight + segmentGap)
+        const lit: boolean = seg < activeSegments
+        const t: number = seg / segmentCount
+        const lightness: number = lit ? 0.34 + t * 0.34 : 0.10
+        const alpha: number = lit ? (0.55 + level * 0.45) : 0.14
+        ctx.globalAlpha = alpha
+        ctx.fillStyle = hslToHex(hue, 0.86, lightness)
+        ctx.fillRect(x, segY, barWidth, segmentHeight)
+      }
+      ctx.globalAlpha = 1.0
+
+      // 峰值下落指示器(亮点)
+      if (level > this.peaks[i]) {
+        this.peaks[i] = level
+        this.peakVelocities[i] = 0
+      } else {
+        this.peakVelocities[i] += 0.0018
+        this.peaks[i] -= this.peakVelocities[i]
+        if (this.peaks[i] < 0) this.peaks[i] = 0
+      }
+
+      const peakSeg: number = Math.floor(this.peaks[i] * segmentCount)
+      const peakY: number = height - (peakSeg + 1) * (segmentHeight + segmentGap)
+      ctx.fillStyle = hslToHex(palette.accentHue, 0.95, 0.72)
+      ctx.globalAlpha = 0.95
+      ctx.fillRect(x, peakY, barWidth, segmentHeight + 1)
+      ctx.globalAlpha = 1.0
+    }
+  }
+
+  reset(): void {
+    this.peaks = []
+    this.peakVelocities = []
+  }
+}

+ 83 - 0
entry/src/main/ets/view/spectrum/CircleSpectrumRenderer.ets

@@ -0,0 +1,83 @@
+import { SpectrumRenderer, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
+
+/**
+ * 圆形频谱渲染器
+ * 环形排列的放射线,缓慢旋转,彩虹色环效果
+ */
+export class CircleSpectrumRenderer implements SpectrumRenderer {
+  private rotation: number = 0
+
+  render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
+    width: number, height: number, brandColor: string): void {
+    const bandCount: number = bands.length
+    if (bandCount === 0) return
+
+    ctx.fillStyle = 'rgba(0, 0, 0, 0.20)'
+    ctx.fillRect(0, 0, width, height)
+
+    const centerX: number = width / 2
+    const centerY: number = height / 2
+    const innerRadius: number = Math.min(width, height) * 0.08
+    const maxOuterRadius: number = Math.min(width, height) * 0.48
+    const palette = buildSpectrumPalette(brandColor)
+
+    // 旋转速度随能量变化 (0.5x ~ 1.5x)
+    const rotationSpeed: number = 0.5 + energy * 1.0
+    this.rotation += rotationSpeed * 0.02 // ~10s 一圈基础速度
+    if (this.rotation > Math.PI * 2) {
+      this.rotation -= Math.PI * 2
+    }
+
+    const angleStep: number = (Math.PI * 2) / bandCount
+
+    // 中央光晕
+    const coreGradient: CanvasGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, maxOuterRadius * 0.7)
+    coreGradient.addColorStop(0, rgbaFromHex(hslToHex(palette.primaryHue, 0.9, 0.55), 0.28 + energy * 0.25))
+    coreGradient.addColorStop(1, 'rgba(0, 0, 0, 0)')
+    ctx.fillStyle = coreGradient
+    ctx.beginPath()
+    ctx.arc(centerX, centerY, maxOuterRadius * 0.7, 0, Math.PI * 2)
+    ctx.fill()
+
+    for (let i = 0; i < bandCount; i++) {
+      const angle: number = i * angleStep + this.rotation
+      const lineLength: number = innerRadius + Math.pow(bands[i], 0.85) * (maxOuterRadius - innerRadius)
+
+      const x1: number = centerX + Math.cos(angle) * innerRadius
+      const y1: number = centerY + Math.sin(angle) * innerRadius
+      const x2: number = centerX + Math.cos(angle) * lineLength
+      const y2: number = centerY + Math.sin(angle) * lineLength
+
+      // 品牌色驱动的色相映射
+      const hue: number = (palette.primaryHue + (i / bandCount) * 220) % 360
+      const lightness: number = 0.4 + bands[i] * 0.3
+      const lineColor: string = hslToHex(hue, 0.85, lightness)
+
+      ctx.beginPath()
+      ctx.strokeStyle = rgbaFromHex(lineColor, 0.76)
+      ctx.lineWidth = 1 + bands[i] * 2.6
+      ctx.moveTo(x1, y1)
+      ctx.lineTo(x2, y2)
+      ctx.stroke()
+    }
+
+    // 外环脉冲
+    const pulseRadius: number = innerRadius + (maxOuterRadius - innerRadius) * (0.35 + energy * 0.55)
+    ctx.beginPath()
+    ctx.arc(centerX, centerY, pulseRadius, 0, Math.PI * 2)
+    ctx.strokeStyle = rgbaFromHex(hslToHex(palette.complementaryHue, 0.85, 0.62), 0.35)
+    ctx.lineWidth = 2
+    ctx.stroke()
+
+    // 中心圆(能量呼吸)
+    const breathRadius: number = innerRadius * (0.9 + energy * 0.8)
+    ctx.beginPath()
+    ctx.arc(centerX, centerY, breathRadius, 0, Math.PI * 2)
+    ctx.fillStyle = rgbaFromHex(hslToHex(palette.coolHue, 0.7, 0.6), 0.18 + energy * 0.24)
+    ctx.fill()
+  }
+
+  reset(): void {
+    this.rotation = 0
+  }
+}

+ 117 - 0
entry/src/main/ets/view/spectrum/ParticleSpectrumRenderer.ets

@@ -0,0 +1,117 @@
+import { SpectrumRenderer, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
+
+const POOL_SIZE: number = 200
+
+class Particle {
+  x: number = 0
+  y: number = 0
+  vx: number = 0
+  vy: number = 0
+  size: number = 2
+  hue: number = 0
+  alpha: number = 1.0
+  active: boolean = false
+}
+
+/**
+ * 粒子频谱渲染器
+ * 频谱驱动粒子发射,粒子池复用
+ */
+export class ParticleSpectrumRenderer implements SpectrumRenderer {
+  private pool: Particle[] = []
+  private initialized: boolean = false
+
+  private initPool(): void {
+    this.pool = new Array<Particle>(POOL_SIZE)
+    for (let i = 0; i < POOL_SIZE; i++) {
+      this.pool[i] = new Particle()
+    }
+    this.initialized = true
+  }
+
+  private getInactiveParticle(): Particle | null {
+    for (let i = 0; i < POOL_SIZE; i++) {
+      if (!this.pool[i].active) {
+        return this.pool[i]
+      }
+    }
+    return null
+  }
+
+  render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
+    width: number, height: number, brandColor: string): void {
+    if (bands.length === 0) return
+
+    if (!this.initialized) {
+      this.initPool()
+    }
+
+    // 粒子模式采用“模糊光斑”风格,保留轨迹
+    ctx.fillStyle = 'rgba(0, 0, 0, 0.16)'
+    ctx.fillRect(0, 0, width, height)
+
+    const palette = buildSpectrumPalette(brandColor)
+    const centerY: number = height * 0.62
+
+    // 发射新粒子,发射量与能量成正比
+    const emitCount: number = Math.floor(2 + energy * 10)
+    for (let e = 0; e < emitCount; e++) {
+      const p: Particle | null = this.getInactiveParticle()
+      if (p === null) break
+
+      // 随机选取一个频段驱动粒子属性
+      const bandIdx: number = Math.floor(Math.random() * bands.length)
+      const bandValue: number = bands[bandIdx]
+
+      p.active = true
+      const spawnSpread: number = width * (0.18 + bandValue * 0.50)
+      p.x = width * 0.5 + (Math.random() - 0.5) * spawnSpread
+      p.y = centerY + (Math.random() - 0.5) * height * 0.08
+      p.vx = (Math.random() - 0.5) * (0.8 + bandValue * 3.2)
+      p.vy = -(0.3 + bandValue * 2.0)
+      p.size = 4 + bandValue * 14 // 更像大光斑
+      p.alpha = 0.4 + bandValue * 0.55
+      // 低频偏暖,高频偏冷(由品牌色生成)
+      const freqRatio: number = bandIdx / bands.length
+      p.hue = (palette.warmHue + freqRatio * ((palette.coolHue + 360 - palette.warmHue) % 360)) % 360
+    }
+
+    // 更新和绘制所有活跃粒子
+    for (let i = 0; i < POOL_SIZE; i++) {
+      const p: Particle = this.pool[i]
+      if (!p.active) continue
+
+      // 物理更新
+      p.x += p.vx
+      p.y += p.vy
+      p.vy *= 0.992
+      p.alpha -= 0.009
+      p.size *= 0.996
+
+      // 回收条件
+      if (p.alpha <= 0 || p.y < -40 || p.x < -40 || p.x > width + 40) {
+        p.active = false
+        continue
+      }
+
+      // 绘制:径向渐变光斑
+      const color: string = hslToHex(p.hue, 0.8, 0.42 + energy * 0.3)
+      const glow: CanvasGradient = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size)
+      glow.addColorStop(0.0, rgbaFromHex(color, p.alpha))
+      glow.addColorStop(0.45, rgbaFromHex(color, p.alpha * 0.55))
+      glow.addColorStop(1.0, rgbaFromHex(color, 0))
+      ctx.fillStyle = glow
+      ctx.beginPath()
+      ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2)
+      ctx.fill()
+    }
+  }
+
+  reset(): void {
+    if (this.initialized) {
+      for (let i = 0; i < POOL_SIZE; i++) {
+        this.pool[i].active = false
+      }
+    }
+  }
+}

+ 127 - 0
entry/src/main/ets/view/spectrum/SpectrumRenderer.ets

@@ -0,0 +1,127 @@
+/**
+ * 频谱渲染器接口
+ * 所有频谱特效均需实现此接口
+ */
+export interface SpectrumRenderer {
+  /**
+   * 渲染一帧频谱动画
+   * @param ctx Canvas 2D 渲染上下文
+   * @param bands 频段幅值数组 (0.0 ~ 1.0)
+   * @param energy 总体能量值 (0.0 ~ 1.0)
+   * @param width Canvas 宽度
+   * @param height Canvas 高度
+   * @param brandColor 主题品牌色 (hex string, e.g. '#FF0050')
+   */
+  render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
+    width: number, height: number, brandColor: string): void
+
+  /**
+   * 重置渲染器状态
+   */
+  reset(): void
+}
+
+export interface SpectrumPalette {
+  primaryHue: number
+  warmHue: number
+  coolHue: number
+  complementaryHue: number
+  accentHue: number
+}
+
+/**
+ * 规范化 hex 颜色字符串,异常时回退到默认值
+ */
+export function normalizeHexColor(color: string, fallback: string = '#FF0050'): string {
+  if (!color || color.length < 7 || color[0] !== '#') {
+    return fallback
+  }
+  const hexBody: string = color.substring(1, 7)
+  const valid: boolean = /^[0-9a-fA-F]{6}$/.test(hexBody)
+  return valid ? '#' + hexBody : fallback
+}
+
+/**
+ * 基于品牌色构建频谱调色板(HSL 色相偏移)
+ */
+export function buildSpectrumPalette(brandColor: string): SpectrumPalette {
+  const baseColor: string = normalizeHexColor(brandColor)
+  const baseHue: number = hexToHue(baseColor)
+  return {
+    primaryHue: baseHue,
+    warmHue: (baseHue - 28 + 360) % 360,
+    coolHue: (baseHue + 28) % 360,
+    complementaryHue: (baseHue + 180) % 360,
+    accentHue: (baseHue + 55) % 360
+  }
+}
+
+/**
+ * 生成 rgba 字符串,便于 Canvas 透明度控制
+ */
+export function rgbaFromHex(hex: string, alpha: number): string {
+  const safeHex: string = normalizeHexColor(hex)
+  const safeAlpha: number = Math.max(0, Math.min(1, alpha))
+  const r: number = parseInt(safeHex.substring(1, 3), 16)
+  const g: number = parseInt(safeHex.substring(3, 5), 16)
+  const b: number = parseInt(safeHex.substring(5, 7), 16)
+  return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`
+}
+
+/**
+ * HSL 转 RGB hex 字符串
+ */
+export function hslToHex(h: number, s: number, l: number): string {
+  const hNorm: number = ((h % 360) + 360) % 360
+  const sNorm: number = Math.max(0, Math.min(1, s))
+  const lNorm: number = Math.max(0, Math.min(1, l))
+  const c: number = (1 - Math.abs(2 * lNorm - 1)) * sNorm
+  const x: number = c * (1 - Math.abs((hNorm / 60) % 2 - 1))
+  const m: number = lNorm - c / 2
+  let r: number = 0
+  let g: number = 0
+  let b: number = 0
+
+  if (hNorm < 60) { r = c; g = x; b = 0 }
+  else if (hNorm < 120) { r = x; g = c; b = 0 }
+  else if (hNorm < 180) { r = 0; g = c; b = x }
+  else if (hNorm < 240) { r = 0; g = x; b = c }
+  else if (hNorm < 300) { r = x; g = 0; b = c }
+  else { r = c; g = 0; b = x }
+
+  const ri: number = Math.round((r + m) * 255)
+  const gi: number = Math.round((g + m) * 255)
+  const bi: number = Math.round((b + m) * 255)
+
+  const toHex = (v: number): string => {
+    const hex: string = v.toString(16)
+    return hex.length < 2 ? '0' + hex : hex
+  }
+  return '#' + toHex(ri) + toHex(gi) + toHex(bi)
+}
+
+/**
+ * 从 hex 字符串解析 HSL 色相
+ */
+export function hexToHue(hex: string): number {
+  let r: number = 0
+  let g: number = 0
+  let b: number = 0
+  if (hex.length >= 7) {
+    r = parseInt(hex.substring(1, 3), 16) / 255
+    g = parseInt(hex.substring(3, 5), 16) / 255
+    b = parseInt(hex.substring(5, 7), 16) / 255
+  }
+  const max: number = Math.max(r, g, b)
+  const min: number = Math.min(r, g, b)
+  const d: number = max - min
+  let h: number = 0
+  if (d !== 0) {
+    if (max === r) h = ((g - b) / d) % 6
+    else if (max === g) h = (b - r) / d + 2
+    else h = (r - g) / d + 4
+    h *= 60
+    if (h < 0) h += 360
+  }
+  return h
+}

+ 176 - 0
entry/src/main/ets/view/spectrum/SpectrumView.ets

@@ -0,0 +1,176 @@
+import { IjkMediaPlayer } from '@ohos/ijkplayer'
+import { FFTAnalyzer } from '../../common/util/FFTAnalyzer'
+import { SpectrumRenderer } from './SpectrumRenderer'
+import { BarSpectrumRenderer } from './BarSpectrumRenderer'
+import { WaveSpectrumRenderer } from './WaveSpectrumRenderer'
+import { CircleSpectrumRenderer } from './CircleSpectrumRenderer'
+import { ParticleSpectrumRenderer } from './ParticleSpectrumRenderer'
+import { normalizeHexColor } from './SpectrumRenderer'
+import Logger from '../../common/util/Logger'
+
+const TAG: string = 'heanup-SpectrumView'
+const BAND_COUNT: number = 32
+const FRAME_INTERVAL: number = 33 // ~30fps
+
+export enum SpectrumMode {
+  BAR = 0,
+  WAVE = 1,
+  CIRCLE = 2,
+  PARTICLE = 3
+}
+
+const MODE_NAMES: string[] = ['柱状', '波形', '圆形', '粒子']
+
+@Component
+export struct SpectrumView {
+  @StorageLink('spectrumMode') currentMode: number = 0
+  @StorageProp('isPlaying') @Watch('onPlayingChange') isPlaying: boolean = false
+  @StorageProp('themeColor') themeColor: string = '#FF0050'
+  @Prop @Watch('onVisibilityChange') isVisible: boolean = false
+  @State showModePanel: boolean = false
+  @State canvasWidth: number = 0
+  @State canvasHeight: number = 0
+  private renderSettings: RenderingContextSettings = new RenderingContextSettings(true)
+  private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.renderSettings)
+  private fftAnalyzer: FFTAnalyzer = new FFTAnalyzer()
+  private renderers: SpectrumRenderer[] = [
+    new BarSpectrumRenderer(),
+    new WaveSpectrumRenderer(),
+    new CircleSpectrumRenderer(),
+    new ParticleSpectrumRenderer()
+  ]
+  private timerId: number = -1
+
+  onVisibilityChange(): void {
+    this.updateRenderingState()
+  }
+
+  onPlayingChange(): void {
+    this.updateRenderingState()
+  }
+
+  aboutToAppear(): void {
+    // 读取持久化的特效偏好
+    const savedMode: number | undefined = AppStorage.get<number>('spectrumMode')
+    if (savedMode !== undefined && savedMode >= 0 && savedMode <= 3) {
+      this.currentMode = savedMode
+    }
+    this.updateRenderingState()
+    Logger.info(TAG, 'SpectrumView aboutToAppear, mode=' + this.currentMode)
+  }
+
+  aboutToDisappear(): void {
+    this.stopRendering()
+  }
+
+  private startRendering(): void {
+    if (this.timerId !== -1) return
+    Logger.info(TAG, 'startRendering')
+    this.timerId = setInterval(() => {
+      this.renderFrame()
+    }, FRAME_INTERVAL)
+  }
+
+  private stopRendering(): void {
+    if (this.timerId !== -1) {
+      clearInterval(this.timerId)
+      this.timerId = -1
+      Logger.info(TAG, 'stopRendering')
+    }
+  }
+
+  private renderFrame(): void {
+    const player: IjkMediaPlayer = IjkMediaPlayer.getInstance()
+    const pcmData: number[] = player.getSpectrumData()
+
+    if (pcmData.length === 0) return
+
+    const spectrumData = this.fftAnalyzer.analyze(pcmData, 44100)
+    const rawBands: number[] = spectrumData.getBands(BAND_COUNT)
+    const bands: number[] = this.fftAnalyzer.smooth(rawBands)
+    const energy: number = spectrumData.getEnergy()
+
+    const ctx: CanvasRenderingContext2D = this.canvasContext
+    const renderer: SpectrumRenderer = this.renderers[this.currentMode]
+    const brandColor: string = normalizeHexColor(this.themeColor, '#FF0050')
+    renderer.render(ctx, bands, energy, this.canvasWidth, this.canvasHeight, brandColor)
+  }
+
+  private updateRenderingState(): void {
+    if (this.isVisible && this.isPlaying) {
+      this.startRendering()
+    } else {
+      this.stopRendering()
+    }
+  }
+
+  private switchMode(mode: number): void {
+    if (mode === this.currentMode) return
+    this.renderers[this.currentMode].reset()
+    this.currentMode = mode
+    AppStorage.setOrCreate<number>('spectrumMode', mode)
+    this.fftAnalyzer.reset()
+    // 清空 Canvas
+    this.canvasContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
+    Logger.info(TAG, 'switchMode to ' + MODE_NAMES[mode])
+  }
+
+  build() {
+    Stack({ alignContent: Alignment.TopEnd }) {
+      Canvas(this.canvasContext)
+        .width('100%')
+        .height('100%')
+        .backgroundColor(Color.Transparent)
+        .onReady(() => {
+          this.canvasContext.imageSmoothingEnabled = true
+          this.canvasContext.imageSmoothingQuality = 'medium'
+        })
+        .onAreaChange((_oldVal: Area, newVal: Area) => {
+          this.canvasWidth = newVal.width as number
+          this.canvasHeight = newVal.height as number
+        })
+
+      // 特效切换按钮
+      Column() {
+        Text('🎵')
+          .fontSize(22)
+          .fontColor('#FFFFFF')
+          .opacity(0.7)
+          .onClick(() => {
+            this.showModePanel = !this.showModePanel
+          })
+          .padding(8)
+
+        if (this.showModePanel) {
+          Column() {
+            ForEach(MODE_NAMES, (name: string, index: number) => {
+              Text(name)
+                .fontSize(14)
+                .fontColor(index === this.currentMode ? $r('app.color.brand') : '#FFFFFF')
+                .fontWeight(index === this.currentMode ? FontWeight.Bold : FontWeight.Normal)
+                .opacity(index === this.currentMode ? 1.0 : 0.7)
+                .padding({ top: 8, bottom: 8, left: 16, right: 16 })
+                .onClick(() => {
+                  this.switchMode(index)
+                  this.showModePanel = false
+                })
+            })
+          }
+          .backgroundColor('rgba(0,0,0,0.6)')
+          .borderRadius(12)
+          .padding(4)
+        }
+      }
+      .margin({ top: 16, right: 16 })
+    }
+    .width('100%')
+    .height('100%')
+    .onVisibleAreaChange([0.0, 1.0], (_isVisible: boolean, currentRatio: number) => {
+      if (currentRatio <= 0.5) {
+        this.stopRendering()
+        return
+      }
+      this.updateRenderingState()
+    })
+  }
+}

+ 99 - 0
entry/src/main/ets/view/spectrum/WaveSpectrumRenderer.ets

@@ -0,0 +1,99 @@
+import { SpectrumRenderer, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
+
+/**
+ * 波形频谱渲染器
+ * 平滑曲线展示频率分布,带半透明渐变填充
+ */
+export class WaveSpectrumRenderer implements SpectrumRenderer {
+  render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
+    width: number, height: number, brandColor: string): void {
+    const bandCount: number = bands.length
+    if (bandCount === 0) return
+
+    ctx.fillStyle = 'rgba(0, 0, 0, 0.26)'
+    ctx.fillRect(0, 0, width, height)
+
+    const palette = buildSpectrumPalette(brandColor)
+    const centerY: number = height * 0.5
+    const maxAmp: number = height * 0.30
+    const lineWidth: number = 1.6 + energy * 2.8
+    const stepX: number = width / (bandCount - 1)
+    const coreColor: string = hslToHex(palette.coolHue, 0.9, 0.56)
+    const sideColor: string = hslToHex(palette.primaryHue, 0.78, 0.48)
+
+    // 中轴线
+    ctx.strokeStyle = rgbaFromHex(sideColor, 0.45)
+    ctx.lineWidth = 1
+    ctx.beginPath()
+    ctx.moveTo(0, centerY)
+    ctx.lineTo(width, centerY)
+    ctx.stroke()
+
+    // 构造顶部曲线
+    ctx.beginPath()
+    ctx.moveTo(0, centerY)
+    for (let i = 1; i < bandCount; i++) {
+      const prevX: number = (i - 1) * stepX
+      const currX: number = i * stepX
+      const prevAmp: number = Math.pow(bands[i - 1], 0.9) * maxAmp
+      const currAmp: number = Math.pow(bands[i], 0.9) * maxAmp
+      const prevY: number = centerY - prevAmp
+      const currY: number = centerY - currAmp
+      const cpX: number = (prevX + currX) * 0.5
+      ctx.bezierCurveTo(cpX, prevY, cpX, currY, currX, currY)
+    }
+
+    // 镜像到底部,形成“纺锤形”波包
+    for (let i = bandCount - 1; i >= 0; i--) {
+      const x: number = i * stepX
+      const y: number = centerY + Math.pow(bands[i], 0.9) * maxAmp
+      ctx.lineTo(x, y)
+    }
+    ctx.closePath()
+
+    const bodyGradient: CanvasGradient = ctx.createLinearGradient(0, centerY - maxAmp, 0, centerY + maxAmp)
+    bodyGradient.addColorStop(0.0, rgbaFromHex(sideColor, 0.20))
+    bodyGradient.addColorStop(0.35, rgbaFromHex(coreColor, 0.70))
+    bodyGradient.addColorStop(0.65, rgbaFromHex(coreColor, 0.70))
+    bodyGradient.addColorStop(1.0, rgbaFromHex(sideColor, 0.20))
+    ctx.fillStyle = bodyGradient
+    ctx.fill()
+
+    // 外轮廓辉光
+    ctx.beginPath()
+    ctx.moveTo(0, centerY)
+    for (let i = 1; i < bandCount; i++) {
+      const prevX: number = (i - 1) * stepX
+      const currX: number = i * stepX
+      const prevAmp: number = Math.pow(bands[i - 1], 0.9) * maxAmp
+      const currAmp: number = Math.pow(bands[i], 0.9) * maxAmp
+      const prevY: number = centerY - prevAmp
+      const currY: number = centerY - currAmp
+      const cpX: number = (prevX + currX) * 0.5
+      ctx.bezierCurveTo(cpX, prevY, cpX, currY, currX, currY)
+    }
+    ctx.strokeStyle = rgbaFromHex(coreColor, 0.92)
+    ctx.lineWidth = lineWidth
+    ctx.stroke()
+
+    ctx.beginPath()
+    ctx.moveTo(0, centerY)
+    for (let i = 1; i < bandCount; i++) {
+      const prevX: number = (i - 1) * stepX
+      const currX: number = i * stepX
+      const prevAmp: number = Math.pow(bands[i - 1], 0.9) * maxAmp
+      const currAmp: number = Math.pow(bands[i], 0.9) * maxAmp
+      const prevY: number = centerY + prevAmp
+      const currY: number = centerY + currAmp
+      const cpX: number = (prevX + currX) * 0.5
+      ctx.bezierCurveTo(cpX, prevY, cpX, currY, currX, currY)
+    }
+    ctx.strokeStyle = rgbaFromHex(coreColor, 0.92)
+    ctx.lineWidth = lineWidth
+    ctx.stroke()
+  }
+
+  reset(): void {
+    // 无状态需要重置
+  }
+}

+ 5 - 1
entry/src/main/resources/base/element/color.json

@@ -227,6 +227,10 @@
     {
       "name": "common_fill_color",
       "value": "#FFFFFF"
+    },
+    {
+      "name": "brand",
+      "value": "#007DFF"
     }
   ]
-}
+}

+ 1 - 0
ijkplayer/oh_modules/@types/libijkplayer_audio_napi.so/index.d.ts

@@ -47,4 +47,5 @@ export declare class newIjkPlayerAudio {
   _native_setup(xcomponentId: string): void;
   _setEqualizerEnabled(xcomponentId: string, enabled: boolean): void;
   _setEqualizerGains(xcomponentId: string, gains: number[]): void;
+  _getSpectrumData(xcomponentId: string): number[];
 }

+ 13 - 0
ijkplayer/src/main/cpp/ijkplayer/ijkplayer_android.c

@@ -26,6 +26,7 @@
 #include "ff_fferror.h"
 #include "ff_ffplay.h"
 #include "ijkplayer_internal.h"
+#include "../ijksdl/ijksdl_aout.h"
 #include <stddef.h>
 #include <stdbool.h>
 #include <string.h>
@@ -128,3 +129,15 @@ void ijkmp_android_set_equalizer_gains(IjkMediaPlayer *mp, const float *gains)
     }
     pthread_mutex_unlock(&mp->mutex);
 }
+
+SpectrumBuffer *ijkmp_android_get_spectrum_buffer(IjkMediaPlayer *mp)
+{
+    if (!mp) return NULL;
+    SpectrumBuffer *buf = NULL;
+    pthread_mutex_lock(&mp->mutex);
+    if (mp->ffplayer && mp->ffplayer->aout) {
+        buf = SDL_AoutGetSpectrumBuffer(mp->ffplayer->aout);
+    }
+    pthread_mutex_unlock(&mp->mutex);
+    return buf;
+}

+ 4 - 0
ijkplayer/src/main/cpp/ijkplayer/ijkplayer_android.h

@@ -51,6 +51,10 @@ int  ijkmp_android_get_audio_session_id(IjkMediaPlayer *mp);
 void ijkmp_android_set_equalizer_enabled(IjkMediaPlayer *mp, bool enabled);
 void ijkmp_android_set_equalizer_gains(IjkMediaPlayer *mp, const float *gains);
 
+// Spectrum data access
+#include "../ijksdl/audio/ijksdl_equalizer.h"
+SpectrumBuffer *ijkmp_android_get_spectrum_buffer(IjkMediaPlayer *mp);
+
 #ifdef __cplusplus
 }
 #endif

+ 29 - 0
ijkplayer/src/main/cpp/ijksdl/audio/ijksdl_aout_android_opensles.c

@@ -93,6 +93,9 @@ typedef struct SDL_Aout_Opaque {
 
     // Equalizer
     Equalizer      equalizer;
+
+    // Spectrum buffer for visualization
+    SpectrumBuffer spectrum_buffer;
 } SDL_Aout_Opaque;
 
 static void AudioCalcFramesWrittenNeedTime(OH_AudioRenderer *renderer, SDL_Aout_Opaque *opaque)
@@ -145,6 +148,17 @@ static int32_t AudioRendererOnWriteData(OH_AudioRenderer *renderer, void *userDa
         equalizer_process_s16(&opaque->equalizer, (int16_t *)buffer, bufferLen);
     }
 
+    // 频谱数据捕获 - 拷贝 PCM 到环形缓冲区
+    if (buffer != NULL && bufferLen > 0) {
+        int copy_bytes = bufferLen;
+        if (copy_bytes > (int)sizeof(opaque->spectrum_buffer.data)) {
+            copy_bytes = (int)sizeof(opaque->spectrum_buffer.data);
+        }
+        memcpy(opaque->spectrum_buffer.data, (int16_t *)buffer, copy_bytes);
+        opaque->spectrum_buffer.write_pos = copy_bytes / (int)sizeof(int16_t);
+        opaque->spectrum_buffer.has_data = true;
+    }
+
     opaque->writtenLen += bufferLen;
     return 0;
 }
@@ -246,6 +260,13 @@ static int AoutOpenAudio(SDL_Aout *aout, const SDL_AudioSpec *desired, SDL_Audio
     opaque->need_flush = false;
     opaque->writtenLen = 0;
 
+    // 初始化频谱缓冲区
+    memset(&opaque->spectrum_buffer, 0, sizeof(SpectrumBuffer));
+    opaque->spectrum_buffer.sample_rate = desired->freq;
+    opaque->spectrum_buffer.channels = desired->channels;
+    opaque->spectrum_buffer.has_data = false;
+    opaque->spectrum_buffer.write_pos = 0;
+
     // 初始化均衡器
     equalizer_init(&opaque->equalizer, desired->freq, desired->channels);
     if (opaque->ffp) {
@@ -420,6 +441,14 @@ SDL_Aout *SDLAoutCreateForOpenSLES(FFPlayer *ffp) // 实现音频播放功能;
     return aout;
 }
 
+SpectrumBuffer *SDL_AoutGetSpectrumBuffer(SDL_Aout *aout)
+{
+    if (aout && aout->opaque) {
+        return &aout->opaque->spectrum_buffer;
+    }
+    return NULL;
+}
+
 bool SDL_AoutAndroid_IsObjectOfOpenSLES(SDL_Aout * aout)
 {
     LOGI("audio->SDL_AoutAndroid_IsObjectOfOpenSLES");

+ 12 - 0
ijkplayer/src/main/cpp/ijksdl/audio/ijksdl_equalizer.h

@@ -101,6 +101,18 @@ void equalizer_process_s16(Equalizer *eq, int16_t *buffer, int buffer_size);
  */
 void equalizer_reset(Equalizer *eq);
 
+// 频谱数据环形缓冲区
+#define SPECTRUM_SAMPLES 2048
+#define SPECTRUM_BUFFER_SIZE (SPECTRUM_SAMPLES * 2) // max 2 channels * int16
+
+typedef struct SpectrumBuffer {
+    int16_t data[SPECTRUM_BUFFER_SIZE];
+    volatile int write_pos;
+    volatile bool has_data;
+    int sample_rate;
+    int channels;
+} SpectrumBuffer;
+
 #ifdef __cplusplus
 }
 #endif

+ 3 - 0
ijkplayer/src/main/cpp/ijksdl/ijksdl_aout.c

@@ -147,3 +147,6 @@ void SDL_AoutSetEqualizerGains(SDL_Aout *aout, const float *gains)
         aout->set_equalizer_gains(aout, gains);
     }
 }
+
+// SDL_AoutGetSpectrumBuffer is implemented in ijksdl_aout_android_opensles.c
+// because it needs access to the SDL_Aout_Opaque struct definition

+ 4 - 0
ijkplayer/src/main/cpp/ijksdl/ijksdl_aout.h

@@ -30,6 +30,7 @@
 #include "ijksdl_audio.h"
 #include "ijksdl_class.h"
 #include "ijksdl_mutex.h"
+#include "audio/ijksdl_equalizer.h"
 
 typedef struct SDL_Aout_Opaque SDL_Aout_Opaque;
 typedef struct SDL_Aout SDL_Aout;
@@ -85,4 +86,7 @@ int    SDL_AoutGetAudioSessionId(SDL_Aout *aout);
 void   SDL_AoutSetEqualizerEnabled(SDL_Aout *aout, bool enabled);
 void   SDL_AoutSetEqualizerGains(SDL_Aout *aout, const float *gains);
 
+// spectrum
+SpectrumBuffer *SDL_AoutGetSpectrumBuffer(SDL_Aout *aout);
+
 #endif

+ 58 - 4
ijkplayer/src/main/cpp/napi/ijkplayer_napi.cpp

@@ -562,7 +562,7 @@ napi_value IJKPlayerNapi::getVideoCodecInfo(napi_env env, napi_callback_info inf
     if (xcomponentId == "") {
         xcomponentId = IJKPlayerNapi::getXComponentId(env, info);
     }
-    char *result = IJKPlayerNapi::getInstance(xcomponentId)->ijkPlayerNapiProxy_->IjkMediaPlayer_getVideoCodecInfo();
+    const char *result = IJKPlayerNapi::getInstance(xcomponentId)->ijkPlayerNapiProxy_->IjkMediaPlayer_getVideoCodecInfo();
     if (result == nullptr) {
         result = "";
     }
@@ -582,7 +582,7 @@ napi_value IJKPlayerNapi::getAudioCodecInfo(napi_env env, napi_callback_info inf
     if (xcomponentId == "") {
         xcomponentId = IJKPlayerNapi::getXComponentId(env, info);
     }
-    char *result = IJKPlayerNapi::getInstance(xcomponentId)->ijkPlayerNapiProxy_->IjkMediaPlayer_getAudioCodecInfo();
+    const char *result = IJKPlayerNapi::getInstance(xcomponentId)->ijkPlayerNapiProxy_->IjkMediaPlayer_getAudioCodecInfo();
     if (result == nullptr) {
         result = "";
     }
@@ -669,6 +669,59 @@ napi_value IJKPlayerNapi::setEqualizerEnabled(napi_env env, napi_callback_info i
     return nullptr;
 }
 
+napi_value IJKPlayerNapi::getSpectrumData(napi_env env, napi_callback_info info) {
+    size_t argc = PARAM_COUNT_1;
+    napi_value args[PARAM_COUNT_1] = {nullptr};
+    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
+    std::string xcomponentId;
+    NapiUtil::JsValueToString(env, args[INDEX_0], STR_DEFAULT_SIZE, xcomponentId);
+    if (xcomponentId == "") {
+        xcomponentId = IJKPlayerNapi::getXComponentId(env, info);
+    }
+
+    SpectrumBuffer *buf = IJKPlayerNapi::getInstance(xcomponentId)->ijkPlayerNapiProxy_->IjkMediaPlayer_getSpectrumBuffer();
+
+    napi_value result;
+    if (buf == NULL || !buf->has_data) {
+        napi_create_array_with_length(env, 0, &result);
+        return result;
+    }
+
+    const int OUTPUT_SAMPLES = 1024;
+    napi_create_array_with_length(env, OUTPUT_SAMPLES, &result);
+
+    int channels = buf->channels;
+    int available = buf->write_pos;
+    if (channels <= 0) channels = 1;
+    int sampleCount = available / channels;
+    if (sampleCount > OUTPUT_SAMPLES) sampleCount = OUTPUT_SAMPLES;
+
+    for (int i = 0; i < sampleCount; i++) {
+        double value = 0.0;
+        if (channels >= 2) {
+            int idx = i * 2;
+            if (idx + 1 < available) {
+                value = ((double)buf->data[idx] + (double)buf->data[idx + 1]) / 2.0;
+            }
+        } else {
+            if (i < available) {
+                value = (double)buf->data[i];
+            }
+        }
+        napi_value element;
+        napi_create_double(env, value, &element);
+        napi_set_element(env, result, i, element);
+    }
+
+    for (int i = sampleCount; i < OUTPUT_SAMPLES; i++) {
+        napi_value element;
+        napi_create_double(env, 0.0, &element);
+        napi_set_element(env, result, i, element);
+    }
+
+    return result;
+}
+
 napi_value IJKPlayerNapi::setEqualizerGains(napi_env env, napi_callback_info info) {
     size_t argc = PARAM_COUNT_2;
     napi_value args[PARAM_COUNT_2] = {nullptr};
@@ -691,9 +744,9 @@ napi_value IJKPlayerNapi::setEqualizerGains(napi_env env, napi_callback_info inf
     uint32_t length = 0;
     napi_get_array_length(env, gainsArray, &length);
     
-    const int EQ_BAND_COUNT = 10;
+    constexpr uint32_t kEqBandCount = EQ_BAND_COUNT;
     float gains[EQ_BAND_COUNT] = {0.0f};
-    for (uint32_t i = 0; i < length && i < EQ_BAND_COUNT; i++) {
+    for (uint32_t i = 0; i < length && i < kEqBandCount; i++) {
         napi_value element;
         napi_get_element(env, gainsArray, i, &element);
         double value = 0.0;
@@ -925,6 +978,7 @@ napi_value IJKPlayerNapi::Export(napi_env env, napi_value exports) {
         DECLARE_NAPI_FUNCTION("_native_setup", IJKPlayerNapi::native_setup),
         DECLARE_NAPI_FUNCTION("_setEqualizerEnabled", IJKPlayerNapi::setEqualizerEnabled),
         DECLARE_NAPI_FUNCTION("_setEqualizerGains", IJKPlayerNapi::setEqualizerGains),
+        DECLARE_NAPI_FUNCTION("_getSpectrumData", IJKPlayerNapi::getSpectrumData),
     };
     NAPI_CALL(env, napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc));
     return exports;

+ 1 - 0
ijkplayer/src/main/cpp/napi/ijkplayer_napi.h

@@ -57,6 +57,7 @@ class IJKPlayerNapi {
     static napi_value nativeOpenlog(napi_env env, napi_callback_info info);
     static napi_value setEqualizerEnabled(napi_env env, napi_callback_info info);
     static napi_value setEqualizerGains(napi_env env, napi_callback_info info);
+    static napi_value getSpectrumData(napi_env env, napi_callback_info info);
     static napi_value native_setup(napi_env env, napi_callback_info info);
     static napi_value native_setup_audio(napi_env env, napi_callback_info info);
     static napi_value JsConstructor(napi_env env, napi_callback_info info);

+ 2 - 1
ijkplayer/src/main/cpp/napi/ijkplayer_napi_audio_init.cpp

@@ -48,7 +48,8 @@ static napi_value Init(napi_env env, napi_value exports) {
         DECLARE_NAPI_FUNCTION("_native_setup", IJKPlayerNapi::native_setup),
         DECLARE_NAPI_FUNCTION("_native_setup_audio", IJKPlayerNapi::native_setup_audio),
         DECLARE_NAPI_FUNCTION("_setEqualizerEnabled", IJKPlayerNapi::setEqualizerEnabled),
-        DECLARE_NAPI_FUNCTION("_setEqualizerGains", IJKPlayerNapi::setEqualizerGains)
+        DECLARE_NAPI_FUNCTION("_setEqualizerGains", IJKPlayerNapi::setEqualizerGains),
+        DECLARE_NAPI_FUNCTION("_getSpectrumData", IJKPlayerNapi::getSpectrumData)
     };
     napi_value ijkAudio = nullptr;
     const char *classBindName = "IJKPlayerNapi";

+ 10 - 0
ijkplayer/src/main/cpp/proxy/ijkplayer_napi_proxy.cpp

@@ -610,4 +610,14 @@ void IJKPlayerNapiProxy::IjkMediaPlayer_setEqualizerGains(const float *gains) {
     }
     ijkmp_android_set_equalizer_gains(mp, gains);
     ijkmp_dec_ref_p(&mp);
+}
+
+SpectrumBuffer *IJKPlayerNapiProxy::IjkMediaPlayer_getSpectrumBuffer() {
+    IjkMediaPlayer *mp = IJKPlayerNapiProxy::get_media_player(id_);
+    if (!mp) {
+        return NULL;
+    }
+    SpectrumBuffer *buf = ijkmp_android_get_spectrum_buffer(mp);
+    ijkmp_dec_ref_p(&mp);
+    return buf;
 }

+ 2 - 0
ijkplayer/src/main/cpp/proxy/ijkplayer_napi_proxy.h

@@ -25,6 +25,7 @@ extern "C" {
 #include "../utils/hashmap/data_struct.h"
 #include "../ijkplayer/ijkplayer_android.h"
 #include "../utils/ffmpeg/custom_ffmpeg_log.h"
+#include "../ijksdl/audio/ijksdl_equalizer.h"
 #ifdef __cplusplus
 }
 #endif
@@ -65,6 +66,7 @@ class IJKPlayerNapiProxy {
     void IjkMediaPlayer_native_openlog();
     void IjkMediaPlayer_setEqualizerEnabled(bool enabled);
     void IjkMediaPlayer_setEqualizerGains(const float *gains);
+    SpectrumBuffer *IjkMediaPlayer_getSpectrumBuffer();
     IjkMediaPlayer *set_media_player(std::string id, IjkMediaPlayer *mp);
     IjkMediaPlayer *get_media_player(std::string id);
     void delete_media_player(std::string id);

+ 1 - 0
ijkplayer/src/main/cpp/types/ijkplayer_audio_napi/index.d.ts

@@ -47,4 +47,5 @@ export declare class newIjkPlayerAudio {
   _native_setup(xcomponentId: string): void;
   _setEqualizerEnabled(xcomponentId: string, enabled: boolean): void;
   _setEqualizerGains(xcomponentId: string, gains: number[]): void;
+  _getSpectrumData(xcomponentId: string): number[];
 }

+ 8 - 0
ijkplayer/src/main/ets/ijkplayer/IjkMediaPlayer.ets

@@ -267,6 +267,14 @@ export class IjkMediaPlayer {
     }
   }
 
+  getSpectrumData(): number[] {
+    if (this.ijkplayer_audio_napi) {
+      return this.ijkplayer_audio_napi._getSpectrumData(this.id);
+    } else if (this.ijkplayer_napi) {
+      return this.ijkplayer_napi._getSpectrumData(this.id);
+    }
+    return [];
+  }
 
   public on(type: AudioInterrupt|AudioDeviceChange,  callback: Callback<InterruptEvent>) : void {
     if(type == 'audioInterrupt') {

+ 1 - 0
ijkplayer/src/main/ets/ijkplayer/utils/IjkPlayerNapi.d.ets

@@ -43,4 +43,5 @@ export declare class IjkPlayerNapi {
   _native_setup(xcomponentId: string): void;
   _setEqualizerEnabled(xcomponentId: string, enabled: boolean): void;
   _setEqualizerGains(xcomponentId: string, gains: number[]): void;
+  _getSpectrumData(xcomponentId: string): number[];
 }

+ 2 - 0
openspec/changes/add-audio-spectrum-animation/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-02-07

+ 128 - 0
openspec/changes/add-audio-spectrum-animation/design.md

@@ -0,0 +1,128 @@
+## Context
+
+当前 ijkplayer 的音频管线流程为:FFmpeg 解码 → `AudioRendererOnWriteData` 回调 → PCM 填充 → 均衡器处理 → OH_AudioRenderer 输出。PCM 数据在原生层产生和消费,ArkTS 层完全无法访问。播放界面 (`LocalMusic.ets`) 使用 Swiper 容器承载封面和歌词两个页面,已有 Canvas 使用经验(`ScanFilePage.ets` 中用于 Lottie 动画渲染)。
+
+关键约束:
+- PCM 数据仅存在于 C 层 `AudioRendererOnWriteData` 回调中,生命周期极短
+- 音频回调运行在独立线程,与 JS 线程不同
+- ArkTS 禁止使用 `unknown`/`any` 类型,禁止解构赋值
+- 需控制跨线程数据拷贝和 FFT 计算的性能开销
+
+## Goals / Non-Goals
+
+**Goals:**
+- 在原生层零拷贝方式捕获 PCM 数据到环形缓冲区
+- 通过 NAPI 接口将 PCM 数据高效传递给 ArkTS 层
+- 在 ArkTS 侧实现轻量 FFT,生成频谱数据
+- 提供 4 种可切换的 Canvas 频谱动画特效
+- 所有动效参数(颜色、亮度、大小、速度)完全由频谱数据实时驱动
+- 集成到播放界面 Swiper 中,用户可自由滑动切换
+
+**Non-Goals:**
+- 不实现录音或音频采集功能(仅读取播放中的 PCM)
+- 不在 C 层做 FFT(避免增加原生层复杂度,ArkTS 侧 FFT 性能已足够)
+- 不支持自定义特效插件系统(初版提供内置 4 种)
+- 不修改音频播放逻辑或影响播放质量
+
+## Decisions
+
+### 决策 1:PCM 数据捕获方案 — 原生环形缓冲区
+
+**选择**:在 `SDL_Aout_Opaque` 结构体中新增环形缓冲区,在 `AudioRendererOnWriteData` 均衡器处理之后直接 `memcpy` 到缓冲区。
+
+**备选方案**:
+- A) 使用 OpenHarmony `AudioCapturer` API 从系统层采集 — 需要额外权限,延迟高,且无法获取 app 内部播放的精确 PCM
+- B) 在 FFmpeg 解码层拦截 — 太早,未经均衡器处理,且与解码线程耦合
+- C) 通过 MessageCallback 推送数据 — libuv 回调开销大,不适合高频音频数据
+
+**理由**:直接在 `AudioRendererOnWriteData` 末尾拷贝,代码侵入最小(仅加几行),数据是均衡器处理后的最终 PCM,与用户听到的一致。
+
+**环形缓冲区设计**:
+```c
+// 新增到 SDL_Aout_Opaque
+#define SPECTRUM_BUFFER_SIZE 4096  // 2048 samples * 2 channels * sizeof(int16_t)
+typedef struct SpectrumBuffer {
+    int16_t data[SPECTRUM_BUFFER_SIZE / sizeof(int16_t)];
+    volatile int write_pos;
+    volatile bool has_data;
+    int sample_rate;
+    int channels;
+} SpectrumBuffer;
+```
+
+### 决策 2:NAPI 桥接方案 — 同步拉取模式
+
+**选择**:新增 `_getSpectrumData(id)` NAPI 函数,ArkTS 侧按帧主动调用获取当前缓冲区快照,返回 `number[]` 数组。
+
+**备选方案**:
+- A) 推送模式(原生层定时通过 libuv 回调推送) — 每秒 30-60 次 uv_queue_work 调用会产生大量 GC 压力
+- B) SharedArrayBuffer 共享内存 — OpenHarmony NAPI 对 SharedArrayBuffer 支持有限且复杂
+
+**理由**:拉取模式让 ArkTS 侧控制采样频率,与 Canvas 绑帧渲染天然同步,实现简单。每次调用仅拷贝一个窗口大小的数据(2048 个样本),开销可控。
+
+### 决策 3:FFT 实现方案 — ArkTS 纯计算
+
+**选择**:在 ArkTS 侧实现 Cooley-Tukey 基 2 FFT 算法(1024 点),输出 512 个频率 bin 的幅值。
+
+**备选方案**:
+- A) 在 C 层用 FFmpeg 的 `libavutil/tx.h` 做 FFT — 减少一次数据传递,但增加原生层复杂度,FFT 结果传到 JS 层也需要拷贝
+- B) 引入第三方 FFT 库 — 增加外部依赖,与项目"无新依赖"原则不符
+
+**理由**:1024 点 FFT 计算量极小(约 5000 次乘加),现代处理器在 ArkTS 中也能在 1ms 内完成。放在 ArkTS 侧便于调试和迭代,避免频繁修改原生代码重新编译。
+
+**处理流程**:
+```
+PCM int16[] → 取单声道 → 加窗(Hann) → FFT → 取幅值 → 对数映射 → 分频段 → 平滑
+```
+
+频段划分(对数分布,映射到 32-64 个可视化柱):
+- 低频 (32-250Hz):约 8 柱,覆盖鼓点/贝斯
+- 中频 (250-4000Hz):约 16 柱,覆盖人声/乐器
+- 高频 (4000-16000Hz):约 8 柱,覆盖镲片/泛音
+
+### 决策 4:频谱可视化架构 — 策略模式 + Canvas 统一渲染
+
+**选择**:定义 `SpectrumRenderer` 接口,每种特效实现该接口的 `render(ctx, spectrumData, canvasWidth, canvasHeight)` 方法。主组件持有 Canvas 和定时器,按当前选择的渲染策略绘制。
+
+**理由**:策略模式使新增特效只需实现一个类,不影响其他特效和主逻辑。Canvas 是 ArkUI 内置能力,无需额外依赖。
+
+**四种特效实现要点**:
+
+| 特效 | 渲染方式 | 数据驱动参数 |
+|------|---------|-------------|
+| 柱状频谱 | `fillRect` 绘制等间距矩形条 | 高度=幅值,颜色=频段映射渐变 |
+| 波形频谱 | `bezierCurveTo` 绘制平滑曲线 | Y轴=幅值,线宽=能量强度 |
+| 圆形频谱 | 极坐标变换后 `lineTo` 绘制放射线 | 半径=幅值,颜色=角度映射 |
+| 粒子频谱 | 粒子池 + `arc` 绘制圆点 | 速度/大小/透明度=幅值,颜色=频段 |
+
+### 决策 5:UI 集成方案 — Swiper 第二页
+
+**选择**:在 `LocalMusic.ets` 的 Swiper 中,在封面和歌词之间插入频谱页面,顺序变为:封面 → 频谱 → 歌词。频谱页面上方显示小型特效切换按钮。
+
+**备选方案**:
+- A) 作为封面页面的背景叠加显示 — 与旋转唱片视觉冲突,遮挡封面信息
+- B) 放在底部播放栏 — 空间太小,无法展现频谱细节
+
+**理由**:独立 Swiper 页面提供最大渲染空间,不干扰现有封面和歌词体验。用户可以根据需要滑动查看。
+
+### 决策 6:性能控制策略
+
+- **渲染帧率**:Canvas 绑帧 30fps(使用 `setInterval` 33ms),不追求 60fps 以省电
+- **FFT 窗口**:1024 点,每次渲染前计算一次,复用上一帧数据避免重复计算
+- **平滑算法**:指数移动平均 `current = current * 0.7 + previous * 0.3`,避免跳变
+- **生命周期**:仅在频谱页面可见且播放中时才启动定时器和 NAPI 调用,页面切走或暂停时立即停止
+- **内存**:粒子池预分配固定数量(200 个),复用而不是创建/销毁
+
+## Risks / Trade-offs
+
+- **[PCM 数据延迟]** → 环形缓冲区引入约 1 帧(~23ms@44100Hz)延迟,对视觉频谱影响可忽略
+- **[线程安全]** → 环形缓冲区使用 volatile 标记 + 单写单读模式,避免加锁开销。写端为音频线程,读端为 NAPI 调用线程,天然无竞争
+- **[FFT 精度]** → 1024 点 FFT 频率分辨率约 43Hz(@44100Hz),低频区分辨率较粗,通过对数频率映射缓解
+- **[不同采样率]** → 缓冲区记录 `sample_rate`,FFT 分析器动态适配。主流音频均为 44100/48000Hz
+- **[性能下降]** → 粒子特效较重,在低端设备上可能卡顿。兜底方案:检测帧率下降时自动降级到柱状频谱
+- **[原生层修改风险]** → 仅在 `AudioRendererOnWriteData` 末尾加 memcpy,不影响播放逻辑。环形缓冲区独立于播放状态,写满自动覆盖旧数据
+
+## Open Questions
+
+- 是否需要支持频谱特效偏好的持久化存储(记住用户上次选择的特效)?
+- 粒子特效的粒子数量是否需要根据设备性能动态调整?

+ 35 - 0
openspec/changes/add-audio-spectrum-animation/proposal.md

@@ -0,0 +1,35 @@
+## Why
+
+当前播放界面仅有旋转唱片和歌词两个视图,缺乏对音频本身节奏韵律的可视化表达。添加基于 FFT(快速傅立叶变换)的实时频谱动画,可以让用户直观感受音频的频率分布和节奏变化,同时为播放界面增加丰富的视觉吸引力。多种可切换的频谱特效(柱状图、波形、粒子等)能满足不同用户的审美偏好。
+
+## What Changes
+
+- 在 ijkplayer 原生层(C/NAPI)拦截 `AudioRendererOnWriteData` 回调中的 PCM 数据,通过环形缓冲区暴露给 ArkTS 层
+- 新增 NAPI 接口,ArkTS 侧可按需获取当前 PCM 帧数据
+- 在 ArkTS 侧实现 FFT 算法,将时域 PCM 数据转换为频域频谱数据
+- 新增频谱可视化组件,使用 Canvas 绘制多种实时频谱动画特效:
+  - **柱状频谱**:经典的频率柱状条跳动
+  - **波形频谱**:平滑曲线展示频率变化
+  - **圆形频谱**:环形排列的频谱条,围绕封面旋转
+  - **粒子频谱**:根据频率驱动粒子的颜色、大小和运动轨迹
+- 所有特效的颜色、亮度、大小、动画速度均由实时频谱数据驱动
+- 在播放界面 Swiper 中新增频谱页面,用户可左右滑动切换(封面 / 频谱 / 歌词)
+- 新增频谱特效切换 UI,支持在多种特效间切换
+- 频谱动画仅在播放状态下运行,暂停/停止时冻结或隐藏
+
+## Capabilities
+
+### New Capabilities
+- `audio-pcm-bridge`: 从 ijkplayer 原生音频管线中提取实时 PCM 数据,通过 NAPI 环形缓冲区桥接到 ArkTS 层
+- `fft-analyzer`: ArkTS 侧的 FFT 频谱分析模块,将 PCM 时域数据转换为频域频谱数据,提供平滑、分频段等处理
+- `spectrum-visualizer`: 基于 Canvas 的多特效频谱可视化组件,支持柱状、波形、圆形、粒子等多种动画模式,所有动效参数由频谱数据实时驱动
+
+### Modified Capabilities
+(无已有 spec 需要修改)
+
+## Impact
+
+- **ijkplayer 原生层**:修改 `ijksdl_aout_android_opensles.c` 中的 `AudioRendererOnWriteData`,在均衡器处理后拷贝 PCM 数据到环形缓冲区;新增 NAPI 导出函数
+- **entry 模块**:新增 FFT 分析工具类、频谱可视化视图组件;修改 `LocalMusic.ets` 的 Swiper 容器以接入新的频谱页面
+- **性能影响**:FFT 计算和 Canvas 绑帧渲染会增加 CPU/GPU 开销,需要控制采样率和绘制频率(目标 30-60fps)
+- **依赖**:无新外部依赖,FFT 算法自行实现(Cooley-Tukey 基2算法),Canvas 为 ArkUI 内置能力

+ 45 - 0
openspec/changes/add-audio-spectrum-animation/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/changes/add-audio-spectrum-animation/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/changes/add-audio-spectrum-animation/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')` 作为主色,其他颜色基于此生成

+ 58 - 0
openspec/changes/add-audio-spectrum-animation/tasks.md

@@ -0,0 +1,58 @@
+## 1. 原生层 PCM 环形缓冲区
+
+- [x] 1.1 在 `ijksdl_equalizer.h` 或新建头文件中定义 `SpectrumBuffer` 结构体(data、write_pos、has_data、sample_rate、channels 字段)
+- [x] 1.2 在 `SDL_Aout_Opaque` 结构体中新增 `SpectrumBuffer spectrum_buffer` 成员
+- [x] 1.3 在 `AoutOpenAudio` 函数中,初始化 `spectrum_buffer` 并写入 `sample_rate` 和 `channels`
+- [x] 1.4 在 `AudioRendererOnWriteData` 函数末尾(均衡器处理之后),添加 `memcpy` 将 PCM 数据写入环形缓冲区,更新 `write_pos` 和 `has_data`
+
+## 2. NAPI 接口层
+
+- [x] 2.1 在 `ijkplayer_napi.h` 中声明 `getSpectrumData` 静态方法
+- [x] 2.2 在 `ijkplayer_napi.cpp` 中实现 `getSpectrumData`:从 `spectrum_buffer` 读取 1024 个样本,立体声转单声道,构建 `napi_value` 数组返回
+- [x] 2.3 在 `ijkplayer_napi_audio_init.cpp` 中注册 `_getSpectrumData` NAPI 函数(DECLARE_NAPI_FUNCTION)
+- [x] 2.4 在 `ijkplayer_napi_init.cpp` 的视频播放器模块中也注册 `_getSpectrumData`(如需要)
+- [x] 2.5 在 NAPI 代理层 `ijkplayer_napi_proxy.cpp` 中添加获取 `spectrum_buffer` 指针的辅助函数
+
+## 3. ArkTS 接口声明
+
+- [x] 3.1 在 `IjkPlayerNapi.d.ets` 类型声明中新增 `_getSpectrumData(xcomponentId: string): number[]`
+- [x] 3.2 在 `IjkMediaPlayer.ets` 中新增公开方法 `getSpectrumData(): number[]`,封装对原生 `_getSpectrumData` 的调用
+
+## 4. FFT 分析器模块
+
+- [x] 4.1 新建 `entry/src/main/ets/common/util/FFTAnalyzer.ets`,实现 Cooley-Tukey 基 2 FFT 算法(1024 点)
+- [x] 4.2 实现 Hann 窗函数,预计算窗系数数组并缓存
+- [x] 4.3 实现 `SpectrumData` 类:存储 512 个频率 bin 的幅值,提供 `getBands(bandCount)` 对数频率映射方法和 `getEnergy()` 能量计算方法
+- [x] 4.4 实现指数平滑处理逻辑:上升系数 0.7、下降系数 0.3,首帧直通
+
+## 5. 频谱渲染器接口与特效实现
+
+- [x] 5.1 新建 `entry/src/main/ets/view/spectrum/SpectrumRenderer.ets`,定义渲染器接口(render 方法签名、reset 方法)
+- [x] 5.2 实现 `BarSpectrumRenderer.ets`:柱状频谱特效(fillRect、频段渐变色、峰值下落指示器)
+- [x] 5.3 实现 `WaveSpectrumRenderer.ets`:波形频谱特效(贝塞尔曲线、半透明渐变填充、线宽随能量变化)
+- [x] 5.4 实现 `CircleSpectrumRenderer.ets`:圆形频谱特效(极坐标放射线、缓慢旋转、色相环颜色映射)
+- [x] 5.5 实现 `ParticleSpectrumRenderer.ets`:粒子频谱特效(200 粒子池、频段驱动属性、透明度衰减回收)
+
+## 6. SpectrumView 组件
+
+- [x] 6.1 新建 `entry/src/main/ets/view/spectrum/SpectrumView.ets`,创建 Canvas 容器组件
+- [x] 6.2 实现 30fps 定时器逻辑:获取 PCM → FFT 分析 → 调用当前渲染器 render
+- [x] 6.3 实现生命周期控制:可见性监听(aboutToAppear/aboutToDisappear)、播放状态监听,不可见或暂停时停止定时器
+- [x] 6.4 实现特效切换逻辑:切换时清空 Canvas、重置渲染器、启动新渲染
+- [x] 6.5 实现特效选择偏好持久化(AppStorage 存储用户上次选择的特效模式)
+
+## 7. 特效切换 UI
+
+- [x] 7.1 在 SpectrumView 中添加特效切换按钮(频谱页面右上角或底部)
+- [x] 7.2 实现特效选择面板:显示 4 种特效名称(柱状、波形、圆形、粒子),用户点击选择
+
+## 8. 播放界面 Swiper 集成
+
+- [x] 8.1 在 `LocalMusic.ets` 的 Swiper 中,在 `CoverInfo()` 和 `PlayerLyrics()` 之间插入 `SpectrumView` 组件
+- [x] 8.2 更新 Swiper 的 `onChange` 回调,index=1 时通知 SpectrumView 可见,其他 index 时通知不可见
+- [x] 8.3 调整 `currentSwiperIndex` 相关逻辑,适配三页 Swiper(原有的模糊效果、背景样式等条件判断)
+
+## 9. 主题色适配
+
+- [x] 9.1 在各渲染器中读取当前主题的品牌色 `$r('app.color.brand')`,作为频谱特效的基础色调
+- [x] 9.2 实现基于主色的配色生成逻辑(HSL 色相偏移生成互补色和渐变色)

+ 2 - 0
openspec/changes/improve-git-collaboration-workflow/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-02-06

+ 20 - 0
openspec/config.yaml

@@ -0,0 +1,20 @@
+schema: spec-driven
+
+# Project context (optional)
+# This is shown to AI when creating artifacts.
+# Add your tech stack, conventions, style guides, domain knowledge, etc.
+# Example:
+#   context: |
+#     Tech stack: TypeScript, React, Node.js
+#     We use conventional commits
+#     Domain: e-commerce platform
+
+# Per-artifact rules (optional)
+# Add custom rules for specific artifacts.
+# Example:
+#   rules:
+#     proposal:
+#       - Keep proposals under 500 words
+#       - Always include a "Non-goals" section
+#     tasks:
+#       - Break tasks into chunks of max 2 hours