Procházet zdrojové kódy

优化特效频谱和水波纹频谱

onecold před 4 měsíci
rodič
revize
4dd78bdaa2

+ 5 - 6
entry/src/main/ets/view/LocalMusic.ets

@@ -796,8 +796,6 @@ export struct LocalMusic {
   private scrollerForlist: ListScroller = new ListScroller();
   private knockController: KnockController | undefined = undefined;
 
-  // 修改为:
-  imagesF: ImageFrameInfo[] = CommonConstants.IMAGE_FRAME_INFO;
 
   @StorageProp('animationState') animationState: AnimationStatus = AnimationStatus.Initial
   // 缓存机制
@@ -15658,7 +15656,8 @@ export struct LocalMusic {
                   { value: '轨道线' },
                   { value: '点阵' },
                   { value: '星爆' },
-                  { value: '心电图' }
+                  { value: '心电图' },
+                  { value: '特效' }
                 ])
                   .font({ size: 14, weight: FontWeight.Medium })
                   .fontColor(Color.White)
@@ -15666,13 +15665,13 @@ export struct LocalMusic {
                   .margin({top:more.id >= 22?3:0})
                   .visibility(more.id === 26 ? Visibility.Visible : Visibility.None)
                   .selected(this.spectrumModeIndex)
-                  .value(['柱状', '波形', '水波纹', '圆形', '粒子', '脉冲', '镜像柱', '影子柱', '轨道线', '点阵', '星爆', '心电图'][this.spectrumModeIndex])
+                  .value(['柱状', '波形', '水波纹', '圆形', '粒子', '脉冲', '镜像柱', '影子柱', '轨道线', '点阵', '星爆', '心电图', '特效'][this.spectrumModeIndex])
                   .onSelect((index: number, _text?: string) => {
                     this.spectrumModeIndex = index;
                     // 使用同步方法保存,确保立即生效
                     PreferencesUtil.putSync('spectrumModeIndex', this.spectrumModeIndex);
                     AppStorage.setOrCreate<number>('spectrumMode', this.spectrumModeIndex);
-                    const modeNames = ['柱状', '波形', '水波纹', '圆形', '粒子', '脉冲', '镜像柱', '影子柱', '轨道线', '点阵', '星爆', '心电图'];
+                    const modeNames = ['柱状', '波形', '水波纹', '圆形', '粒子', '脉冲', '镜像柱', '影子柱', '轨道线', '点阵', '星爆', '心电图', '特效'];
                     Logger.info('heanup-LocalMusic', '频谱特效已切换为: ' + modeNames[index]);
                   });
 
@@ -18462,7 +18461,7 @@ export struct LocalMusic {
   }
 
   private cycleSpectrumMode(): void {
-    const modeNames: string[] = ['柱状', '波形', '水波纹', '圆形', '粒子', '脉冲', '镜像柱', '影子柱', '轨道线', '点阵', '星爆', '心电图'];
+    const modeNames: string[] = ['柱状', '波形', '水波纹', '圆形', '粒子', '脉冲', '镜像柱', '影子柱', '轨道线', '点阵', '星爆', '心电图', '特效'];
     if (modeNames.length <= 0) {
       return;
     }

+ 1 - 1
entry/src/main/ets/view/PlayingIndicator.ets

@@ -76,7 +76,7 @@ export struct PlayingIndicator {
     const brandColor: string = normalizeHexColor(this.themeColor, CommonConstants.DEFAULT_THEME_COLOR)
     const barCount: number = 5
     const barWidth: number = Math.max(1.0, Math.min(1.8, this.canvasWidth * 0.075))
-    const barGap: number = Math.max(0.9, Math.min(1.7, this.canvasWidth * 0.082))
+    const barGap: number = Math.max(1.0, Math.min(1.9, this.canvasWidth * 0.092))
     const totalWidth: number = barWidth * barCount + barGap * (barCount - 1)
     const startX: number = (this.canvasWidth - totalWidth) / 2
     const baseY: number = this.canvasHeight

+ 90 - 76
entry/src/main/ets/view/spectrum/EcgSpectrumRenderer.ets

@@ -2,10 +2,10 @@ import { SpectrumRenderer, SpectrumRenderOptions, buildSpectrumPalette, hslToHex
 
 /**
  * 心电图频谱渲染器
- * ECG 风格单线波形,带渐变描边和轻微辉
+ * 直接使用 FFT 频段数据构建平滑曲线,保留心电图观感但不再使用固定扫描
  */
 export class EcgSpectrumRenderer implements SpectrumRenderer {
-  private phase: number = 0
+  private previousLevels: number[] = []
 
   render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
     width: number, height: number, brandColor: string, options?: SpectrumRenderOptions): void {
@@ -18,77 +18,102 @@ export class EcgSpectrumRenderer implements SpectrumRenderer {
 
     const palette = buildSpectrumPalette(brandColor)
     const complexityScale: number = Math.max(0.45, Math.min(1, options?.complexityScale ?? 1))
-    const beatCount: number = Math.max(2, Math.min(4, Math.floor(2 + complexityScale * 2)))
-    const centerY: number = height * 0.56
-    const maxAmp: number = height * (0.16 + energy * 0.16)
-    const microAmp: number = height * (0.010 + energy * 0.018)
+    const centerY: number = height * 0.58
+    const maxAmp: number = height * (0.17 + energy * 0.15)
     const lineColor: string = hslToHex(palette.coolHue, 0.92, 0.62)
     const accentColor: string = hslToHex(palette.accentHue, 0.90, 0.58)
     const warmColor: string = hslToHex(palette.warmHue, 0.82, 0.54)
 
-    this.phase += 0.010 + energy * 0.028
-    if (this.phase >= 1) {
-      this.phase -= 1
-    }
-
-    const baselineGradient: CanvasGradient = ctx.createLinearGradient(0, centerY - height * 0.14, 0, centerY + height * 0.12)
-    baselineGradient.addColorStop(0, rgbaFromHex(lineColor, 0))
-    baselineGradient.addColorStop(0.48, rgbaFromHex(lineColor, 0.08 + energy * 0.05))
-    baselineGradient.addColorStop(0.52, rgbaFromHex(accentColor, 0.18 + energy * 0.08))
-    baselineGradient.addColorStop(1, rgbaFromHex(lineColor, 0))
-    ctx.fillStyle = baselineGradient
-    ctx.fillRect(0, centerY - height * 0.16, width, height * 0.30)
-
-    const lineGradient: CanvasGradient = ctx.createLinearGradient(0, 0, width, 0)
-    lineGradient.addColorStop(0, rgbaFromHex(warmColor, 0.84))
-    lineGradient.addColorStop(0.35, rgbaFromHex(accentColor, 0.96))
-    lineGradient.addColorStop(0.7, rgbaFromHex(lineColor, 0.96))
-    lineGradient.addColorStop(1, rgbaFromHex(warmColor, 0.78))
-
-    const glowGradient: CanvasGradient = ctx.createLinearGradient(0, 0, width, 0)
-    glowGradient.addColorStop(0, rgbaFromHex(warmColor, 0.16))
-    glowGradient.addColorStop(0.5, rgbaFromHex(accentColor, 0.26 + energy * 0.10))
-    glowGradient.addColorStop(1, rgbaFromHex(lineColor, 0.14))
-
+    const levels: number[] = this.buildDisplayLevels(bands, energy)
     const pointsX: number[] = []
     const pointsY: number[] = []
     const stepX: number = bandCount > 1 ? width / (bandCount - 1) : width
 
     for (let i = 0; i < bandCount; i++) {
-      const progress: number = bandCount > 1 ? i / (bandCount - 1) : 0
       const x: number = i * stepX
-      const cycleProgress: number = (progress * beatCount + this.phase) % 1
-      const bandValue: number = Math.pow(Math.max(0, Math.min(1, bands[i])), 0.82)
-      const envelope: number = 0.35 + bandValue * 0.95 + energy * 0.20
-      const ecgShape: number = this.getEcgShape(cycleProgress)
-      const flutter: number = Math.sin(progress * Math.PI * (18 + beatCount * 2) + this.phase * Math.PI * 2) * microAmp * (0.3 + bandValue * 0.7)
-      const y: number = centerY - ecgShape * maxAmp * envelope + flutter
+      const current: number = levels[i]
+      const prev: number = i > 0 ? levels[i - 1] : current
+      const next: number = i < bandCount - 1 ? levels[i + 1] : current
+      const localPeak: number = Math.max(0, current * 2 - prev - next)
+      const localValley: number = Math.max(0, prev + next - current * 2)
+      const contrast: number = Math.abs(next - prev)
+      const emphasized: number = Math.min(1, current * (0.70 + contrast * 0.85) + localPeak * 0.95)
+      const dipOffset: number = localValley * maxAmp * (0.05 + complexityScale * 0.03)
+      const y: number = centerY - emphasized * maxAmp + dipOffset
       pointsX.push(x)
       pointsY.push(y)
     }
 
-    ctx.strokeStyle = rgbaFromHex(lineColor, 0.20)
+    const baselineGradient: CanvasGradient = ctx.createLinearGradient(0, centerY - height * 0.12, 0, centerY + height * 0.10)
+    baselineGradient.addColorStop(0, rgbaFromHex(lineColor, 0))
+    baselineGradient.addColorStop(0.46, rgbaFromHex(lineColor, 0.06 + energy * 0.04))
+    baselineGradient.addColorStop(0.52, rgbaFromHex(accentColor, 0.14 + energy * 0.08))
+    baselineGradient.addColorStop(1, rgbaFromHex(lineColor, 0))
+    ctx.fillStyle = baselineGradient
+    ctx.fillRect(0, centerY - height * 0.14, width, height * 0.26)
+
+    ctx.strokeStyle = rgbaFromHex(lineColor, 0.18)
     ctx.lineWidth = 1
     ctx.beginPath()
     ctx.moveTo(0, centerY)
     ctx.lineTo(width, centerY)
     ctx.stroke()
 
-    this.strokePath(ctx, pointsX, pointsY, glowGradient, 10 + energy * 8)
-    this.strokePath(ctx, pointsX, pointsY, glowGradient, 6 + energy * 4)
-    this.strokePath(ctx, pointsX, pointsY, lineGradient, 2.2 + energy * 2.0)
-
-    const scanX: number = ((this.phase * 1.18) % 1) * width
-    const scanGradient: CanvasGradient = ctx.createLinearGradient(scanX - 18, 0, scanX + 18, 0)
-    scanGradient.addColorStop(0, 'rgba(255,255,255,0)')
-    scanGradient.addColorStop(0.5, rgbaFromHex(accentColor, 0.22 + energy * 0.10))
-    scanGradient.addColorStop(1, 'rgba(255,255,255,0)')
-    ctx.fillStyle = scanGradient
-    ctx.fillRect(Math.max(0, scanX - 18), 0, 36, height)
+    const fillGradient: CanvasGradient = ctx.createLinearGradient(0, centerY - maxAmp * 1.2, 0, centerY + height * 0.10)
+    fillGradient.addColorStop(0, rgbaFromHex(warmColor, 0.12))
+    fillGradient.addColorStop(0.38, rgbaFromHex(accentColor, 0.16 + energy * 0.06))
+    fillGradient.addColorStop(1, rgbaFromHex(lineColor, 0))
+
+    ctx.beginPath()
+    this.buildCurvePath(ctx, pointsX, pointsY)
+    ctx.lineTo(width, centerY)
+    ctx.lineTo(0, centerY)
+    ctx.closePath()
+    ctx.fillStyle = fillGradient
+    ctx.fill()
+
+    const glowGradient: CanvasGradient = ctx.createLinearGradient(0, 0, width, 0)
+    glowGradient.addColorStop(0, rgbaFromHex(warmColor, 0.12))
+    glowGradient.addColorStop(0.50, rgbaFromHex(accentColor, 0.22 + energy * 0.08))
+    glowGradient.addColorStop(1, rgbaFromHex(lineColor, 0.12))
+
+    const lineGradient: CanvasGradient = ctx.createLinearGradient(0, 0, width, 0)
+    lineGradient.addColorStop(0, rgbaFromHex(warmColor, 0.82))
+    lineGradient.addColorStop(0.35, rgbaFromHex(accentColor, 0.96))
+    lineGradient.addColorStop(0.72, rgbaFromHex(lineColor, 0.96))
+    lineGradient.addColorStop(1, rgbaFromHex(warmColor, 0.78))
+
+    this.strokePath(ctx, pointsX, pointsY, glowGradient, 9 + energy * 7)
+    this.strokePath(ctx, pointsX, pointsY, glowGradient, 5 + energy * 3)
+    this.strokePath(ctx, pointsX, pointsY, lineGradient, 2.0 + energy * 1.8)
   }
 
   reset(): void {
-    this.phase = 0
+    this.previousLevels = []
+  }
+
+  private buildDisplayLevels(bands: number[], energy: number): number[] {
+    if (this.previousLevels.length !== bands.length) {
+      this.previousLevels = new Array<number>(bands.length)
+      for (let i = 0; i < bands.length; i++) {
+        this.previousLevels[i] = 0
+      }
+    }
+
+    const levels: number[] = new Array<number>(bands.length)
+    for (let i = 0; i < bands.length; i++) {
+      const current: number = Math.pow(this.clamp01(bands[i]), 0.78)
+      const prev: number = i > 0 ? Math.pow(this.clamp01(bands[i - 1]), 0.86) : current
+      const next: number = i < bands.length - 1 ? Math.pow(this.clamp01(bands[i + 1]), 0.86) : current
+      const neighborAverage: number = (prev + current + next) / 3
+      const localContrast: number = Math.abs(next - prev)
+      const rawLevel: number = Math.min(1, current * 0.62 + neighborAverage * 0.28 + localContrast * 0.42 + energy * 0.05)
+      const prevLevel: number = this.previousLevels[i]
+      levels[i] = rawLevel >= prevLevel ? rawLevel * 0.66 + prevLevel * 0.34 : rawLevel * 0.32 + prevLevel * 0.68
+    }
+
+    this.previousLevels = levels.slice()
+    return levels
   }
 
   private strokePath(ctx: CanvasRenderingContext2D, pointsX: number[], pointsY: number[],
@@ -97,6 +122,16 @@ export class EcgSpectrumRenderer implements SpectrumRenderer {
       return
     }
     ctx.beginPath()
+    this.buildCurvePath(ctx, pointsX, pointsY)
+    ctx.strokeStyle = strokeStyle
+    ctx.lineWidth = lineWidth
+    ctx.stroke()
+  }
+
+  private buildCurvePath(ctx: CanvasRenderingContext2D, pointsX: number[], pointsY: number[]): void {
+    if (pointsX.length === 0) {
+      return
+    }
     ctx.moveTo(pointsX[0], pointsY[0])
     for (let i = 1; i < pointsX.length; i++) {
       const prevX: number = pointsX[i - 1]
@@ -106,36 +141,15 @@ export class EcgSpectrumRenderer implements SpectrumRenderer {
       const cpX: number = (prevX + currX) * 0.5
       ctx.bezierCurveTo(cpX, prevY, cpX, currY, currX, currY)
     }
-    ctx.strokeStyle = strokeStyle
-    ctx.lineWidth = lineWidth
-    ctx.stroke()
   }
 
-  private getEcgShape(t: number): number {
-    if (t < 0.12) {
-      return 0
-    }
-    if (t < 0.20) {
-      return Math.sin((t - 0.12) / 0.08 * Math.PI) * 0.18
-    }
-    if (t < 0.28) {
+  private clamp01(value: number): number {
+    if (value < 0) {
       return 0
     }
-    if (t < 0.34) {
-      return -((t - 0.28) / 0.06) * 0.24
-    }
-    if (t < 0.39) {
-      return -0.24 + ((t - 0.34) / 0.05) * 1.50
-    }
-    if (t < 0.45) {
-      return 1.26 - ((t - 0.39) / 0.06) * 1.72
-    }
-    if (t < 0.58) {
-      return -0.46 + Math.sin((t - 0.45) / 0.13 * Math.PI) * 0.24
-    }
-    if (t < 0.74) {
-      return Math.sin((t - 0.58) / 0.16 * Math.PI) * 0.36
+    if (value > 1) {
+      return 1
     }
-    return 0
+    return value
   }
 }

+ 165 - 103
entry/src/main/ets/view/spectrum/RippleWaveSpectrumRenderer.ets

@@ -1,131 +1,193 @@
-import { SpectrumRenderer, SpectrumRenderOptions, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
+import { SpectrumRenderer, SpectrumRenderOptions, rgbaFromHex } from './SpectrumRenderer'
 
 /**
  * 水波纹频谱渲染器
- * 海浪式抛物线水波,中心浪峰更高,两侧自然回落
+ * 参考经典水波纹实现,使用海洋色系的三层填充波浪并结合 FFT 频段做实时起伏
+ * 高频越强,海浪越急、越碎、越有冲击感
  */
 export class RippleWaveSpectrumRenderer implements SpectrumRenderer {
   private phase: number = 0
 
   render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
-    width: number, height: number, brandColor: string, options?: SpectrumRenderOptions): void {
+    width: number, height: number, _brandColor: string, options?: SpectrumRenderOptions): void {
     if (bands.length === 0) {
       return
     }
 
     ctx.clearRect(0, 0, width, height)
 
-    const palette = buildSpectrumPalette(brandColor)
     const complexityScale: number = Math.max(0.45, Math.min(1, options?.complexityScale ?? 1))
-    const layerCount: number = Math.max(4, Math.floor(7 * complexityScale))
-    const segmentCount: number = Math.max(44, Math.floor(92 * complexityScale))
-    const horizonY: number = height * 0.58
-    const waveHeight: number = height * (0.075 + energy * 0.13)
-    const layerGap: number = height * 0.05
-    const coolColor: string = hslToHex(palette.coolHue, 0.84, 0.66)
-    const primaryColor: string = hslToHex(palette.primaryHue, 0.78, 0.50)
-    const deepColor: string = hslToHex(palette.complementaryHue, 0.52, 0.26)
-
-    this.phase += 0.055 + energy * 0.05
+    const segmentCount: number = Math.max(60, Math.floor(124 * complexityScale))
+    const centerY: number = height * 0.60
+    const trebleEnergy: number = this.getBandRangeAverage(bands, 0.62, 1.0)
+    const midEnergy: number = this.getBandRangeAverage(bands, 0.30, 0.62)
+    const waveAggression: number = this.clamp(energy * 0.34 + midEnergy * 0.28 + trebleEnergy * 1.08, 0, 1)
+    const trebleSurge: number = this.clamp(trebleEnergy * 1.18 + energy * 0.20, 0, 1)
+
+    const aquaGreen: string = '#27D7A1'
+    const lightBlue: string = '#7FD8FF'
+    const cyanBlue: string = '#3FB7FF'
+    const seaBlue: string = '#1C75DB'
+    const deepOcean: string = '#0F3F8E'
+
+    this.phase += 0.042 + waveAggression * 0.082 + trebleSurge * 0.052
     if (this.phase > Math.PI * 2) {
       this.phase -= Math.PI * 2
     }
 
-    const skyGradient: CanvasGradient = ctx.createLinearGradient(0, 0, 0, height)
-    skyGradient.addColorStop(0, 'rgba(0, 0, 0, 0)')
-    skyGradient.addColorStop(0.62, rgbaFromHex(coolColor, 0.03 + energy * 0.02))
-    skyGradient.addColorStop(1, rgbaFromHex(primaryColor, 0.08 + energy * 0.05))
-    ctx.fillStyle = skyGradient
-    ctx.beginPath()
-    ctx.rect(0, 0, width, height)
-    ctx.fill()
+    const backgroundGradient: CanvasGradient = ctx.createLinearGradient(0, 0, 0, height)
+    backgroundGradient.addColorStop(0, 'rgba(0, 0, 0, 0)')
+    backgroundGradient.addColorStop(0.60, rgbaFromHex(lightBlue, 0.03 + waveAggression * 0.02))
+    backgroundGradient.addColorStop(0.82, rgbaFromHex(cyanBlue, 0.05 + waveAggression * 0.04))
+    backgroundGradient.addColorStop(1, rgbaFromHex(deepOcean, 0.09 + waveAggression * 0.05))
+    ctx.fillStyle = backgroundGradient
+    ctx.fillRect(0, 0, width, height)
+
+    this.fillWaveLayer(ctx, bands, width, height, segmentCount, {
+      baseY: centerY + height * 0.10,
+      amplitude: height * (0.070 + waveAggression * 0.060 + trebleSurge * 0.022),
+      bandAmplitude: height * (0.060 + waveAggression * 0.070 + trebleSurge * 0.022),
+      mainFrequency: 0.010,
+      detailFrequency: 0.021,
+      chopFrequency: 0.040,
+      detailScale: 0.24,
+      chopScale: 0.16,
+      phaseShift: this.phase,
+      trebleEnergy,
+      surgeEnergy: trebleSurge,
+      bandOffset: 0.00,
+      fillColor: rgbaFromHex(seaBlue, 0.46)
+    })
+
+    this.fillWaveLayer(ctx, bands, width, height, segmentCount, {
+      baseY: centerY + height * 0.01,
+      amplitude: height * (0.078 + waveAggression * 0.072 + trebleSurge * 0.028),
+      bandAmplitude: height * (0.066 + waveAggression * 0.076 + trebleSurge * 0.026),
+      mainFrequency: 0.018,
+      detailFrequency: 0.033,
+      chopFrequency: 0.062,
+      detailScale: 0.22,
+      chopScale: 0.20,
+      phaseShift: this.phase * 1.22 + 0.9,
+      trebleEnergy,
+      surgeEnergy: trebleSurge,
+      bandOffset: 0.08,
+      fillColor: rgbaFromHex(lightBlue, 0.36)
+    })
+
+    this.fillWaveLayer(ctx, bands, width, height, segmentCount, {
+      baseY: centerY - height * 0.10,
+      amplitude: height * (0.145 + waveAggression * 0.132 + trebleSurge * 0.056),
+      bandAmplitude: height * (0.124 + waveAggression * 0.128 + trebleSurge * 0.054),
+      mainFrequency: 0.014,
+      detailFrequency: 0.028,
+      chopFrequency: 0.086,
+      detailScale: 0.30,
+      chopScale: 0.34,
+      phaseShift: this.phase * 0.92 + 1.7,
+      trebleEnergy,
+      surgeEnergy: trebleSurge,
+      bandOffset: 0.15,
+      fillColor: rgbaFromHex(aquaGreen, 0.54)
+    })
+  }
 
-    for (let layer = 0; layer < layerCount; layer++) {
-      const layerProgress: number = layer / Math.max(1, layerCount - 1)
-      const baseY: number = horizonY + layer * layerGap
-      const localHeight: number = waveHeight * (1 - layerProgress * 0.5)
-      const strokeAlpha: number = 0.22 + (1 - layerProgress) * 0.24 + energy * 0.10
-      const fillAlpha: number = 0.05 + (1 - layerProgress) * 0.07 + energy * 0.04
-      const hue: number = (palette.coolHue + layer * 8) % 360
-      const lineWidth: number = 1.2 + (1 - layerProgress) * 1.6
-      const phaseShift: number = this.phase * (1.0 + layer * 0.08) + layer * 0.65
-
-      ctx.beginPath()
-      for (let i = 0; i <= segmentCount; i++) {
-        const progressX: number = i / segmentCount
-        const x: number = progressX * width
-        const normalizedX: number = progressX * 2 - 1
-        const parabolaEnvelope: number = Math.max(0, 1 - normalizedX * normalizedX)
-
-        const bandIndexFloat: number = progressX * (bands.length - 1)
-        const bandIndex: number = Math.min(bands.length - 1, Math.max(0, Math.floor(bandIndexFloat + layer)))
-        const bandValue: number = Math.pow(bands[bandIndex], 0.9)
-
-        const mainSwell: number = Math.sin(progressX * Math.PI * (2.0 + layer * 0.10) + phaseShift)
-        const detailSwell: number = Math.sin(progressX * Math.PI * (4.8 + layer * 0.22) - phaseShift * 0.82)
-        const crestShape: number = parabolaEnvelope * (0.52 + bandValue * 0.88)
-        const swellOffset: number = mainSwell * localHeight * 0.18 + detailSwell * localHeight * 0.06
-        const lift: number = crestShape * localHeight
-        const y: number = baseY - lift + swellOffset
-
-        if (i === 0) {
-          ctx.moveTo(x, y)
-        } else {
-          ctx.lineTo(x, y)
-        }
-      }
+  reset(): void {
+    this.phase = 0
+  }
 
-      const fillGradient: CanvasGradient = ctx.createLinearGradient(0, baseY - localHeight * 1.1, 0, height)
-      fillGradient.addColorStop(0, rgbaFromHex(hslToHex(hue, 0.84, 0.64), fillAlpha))
-      fillGradient.addColorStop(0.55, rgbaFromHex(primaryColor, fillAlpha * 0.65))
-      fillGradient.addColorStop(1, rgbaFromHex(deepColor, 0.02))
-      ctx.lineTo(width, height)
-      ctx.lineTo(0, height)
-      ctx.closePath()
-      ctx.fillStyle = fillGradient
-      ctx.fill()
-
-      ctx.beginPath()
-      for (let i = 0; i <= segmentCount; i++) {
-        const progressX: number = i / segmentCount
-        const x: number = progressX * width
-        const normalizedX: number = progressX * 2 - 1
-        const parabolaEnvelope: number = Math.max(0, 1 - normalizedX * normalizedX)
-
-        const bandIndexFloat: number = progressX * (bands.length - 1)
-        const bandIndex: number = Math.min(bands.length - 1, Math.max(0, Math.floor(bandIndexFloat + layer)))
-        const bandValue: number = Math.pow(bands[bandIndex], 0.9)
-
-        const mainSwell: number = Math.sin(progressX * Math.PI * (2.0 + layer * 0.10) + phaseShift)
-        const detailSwell: number = Math.sin(progressX * Math.PI * (4.8 + layer * 0.22) - phaseShift * 0.82)
-        const crestShape: number = parabolaEnvelope * (0.52 + bandValue * 0.88)
-        const swellOffset: number = mainSwell * localHeight * 0.18 + detailSwell * localHeight * 0.06
-        const lift: number = crestShape * localHeight
-        const y: number = baseY - lift + swellOffset
-
-        if (i === 0) {
-          ctx.moveTo(x, y)
-        } else {
-          ctx.lineTo(x, y)
-        }
+  private fillWaveLayer(ctx: CanvasRenderingContext2D, bands: number[], width: number, height: number,
+    segmentCount: number, config: RippleWaveLayerConfig): void {
+    ctx.beginPath()
+    for (let i = 0; i <= segmentCount; i++) {
+      const progress: number = i / segmentCount
+      const x: number = progress * width
+      const bandValue: number = this.sampleBands(bands, progress + config.bandOffset)
+      const prevBand: number = this.sampleBands(bands, progress + config.bandOffset - 0.025)
+      const nextBand: number = this.sampleBands(bands, progress + config.bandOffset + 0.025)
+      const averageBand: number = (prevBand + bandValue + nextBand) / 3
+      const fftPeak: number = Math.max(0, bandValue * 2 - prevBand - nextBand)
+
+      const mainWave: number = Math.sin(x * config.mainFrequency + config.phaseShift)
+      const detailWave: number = Math.sin(x * config.detailFrequency + config.phaseShift * 1.18 + progress * Math.PI * 2)
+      const chopWave: number = Math.sin(x * config.chopFrequency + config.phaseShift * 1.46 + bandValue * 5.2)
+      const waveOffset: number = mainWave * config.amplitude +
+        detailWave * config.amplitude * config.detailScale * (0.48 + bandValue * 0.62 + config.surgeEnergy * 0.22) +
+        chopWave * config.amplitude * config.chopScale *
+        (0.24 + config.trebleEnergy * 1.22 + config.surgeEnergy * 0.72 + fftPeak * 0.70)
+      const fftOffset: number = (averageBand - 0.28) * config.bandAmplitude +
+        fftPeak * config.bandAmplitude * (0.72 + config.trebleEnergy * 1.18 + config.surgeEnergy * 0.60)
+      const y: number = this.clamp(config.baseY + waveOffset - fftOffset, 0, height)
+
+      if (i === 0) {
+        ctx.moveTo(x, y)
+      } else {
+        ctx.lineTo(x, y)
       }
-      ctx.strokeStyle = rgbaFromHex(hslToHex(hue, 0.88, 0.70), strokeAlpha)
-      ctx.lineWidth = lineWidth
-      ctx.stroke()
     }
-
-    const shimmerY: number = horizonY - waveHeight * (0.25 + energy * 0.15)
-    const shimmerGradient: CanvasGradient = ctx.createLinearGradient(0, shimmerY - 8, 0, shimmerY + 18)
-    shimmerGradient.addColorStop(0, 'rgba(255,255,255,0)')
-    shimmerGradient.addColorStop(0.5, rgbaFromHex(coolColor, 0.08 + energy * 0.05))
-    shimmerGradient.addColorStop(1, 'rgba(255,255,255,0)')
-    ctx.fillStyle = shimmerGradient
-    ctx.beginPath()
-    ctx.rect(0, shimmerY - 10, width, 28)
+    ctx.lineTo(width, height)
+    ctx.lineTo(0, height)
+    ctx.closePath()
+    ctx.fillStyle = config.fillColor
     ctx.fill()
   }
 
-  reset(): void {
-    this.phase = 0
+  private sampleBands(bands: number[], progress: number): number {
+    if (bands.length === 1) {
+      return this.normalizeBand(bands[0])
+    }
+    const safeProgress: number = this.clamp(progress, 0, 1)
+    const position: number = safeProgress * (bands.length - 1)
+    const leftIndex: number = Math.floor(position)
+    const rightIndex: number = Math.min(bands.length - 1, leftIndex + 1)
+    const mix: number = position - leftIndex
+    const leftValue: number = this.normalizeBand(bands[leftIndex])
+    const rightValue: number = this.normalizeBand(bands[rightIndex])
+    return leftValue * (1 - mix) + rightValue * mix
   }
+
+  private normalizeBand(value: number): number {
+    return Math.pow(this.clamp(value, 0, 1), 0.82)
+  }
+
+  private getBandRangeAverage(bands: number[], startProgress: number, endProgress: number): number {
+    if (bands.length === 0) {
+      return 0
+    }
+    const startIndex: number = Math.floor(this.clamp(startProgress, 0, 1) * (bands.length - 1))
+    const endIndex: number = Math.max(startIndex, Math.floor(this.clamp(endProgress, 0, 1) * (bands.length - 1)))
+    let sum: number = 0
+    let count: number = 0
+    for (let i = startIndex; i <= endIndex; i++) {
+      sum += this.normalizeBand(bands[i])
+      count++
+    }
+    return count > 0 ? sum / count : 0
+  }
+
+  private clamp(value: number, min: number, max: number): number {
+    if (value < min) {
+      return min
+    }
+    if (value > max) {
+      return max
+    }
+    return value
+  }
+}
+
+interface RippleWaveLayerConfig {
+  baseY: number
+  amplitude: number
+  bandAmplitude: number
+  mainFrequency: number
+  detailFrequency: number
+  chopFrequency: number
+  detailScale: number
+  chopScale: number
+  phaseShift: number
+  trebleEnergy: number
+  surgeEnergy: number
+  bandOffset: number
+  fillColor: string
 }

+ 197 - 0
entry/src/main/ets/view/spectrum/SpecialEffectSpectrumRenderer.ets

@@ -0,0 +1,197 @@
+import { SpectrumRenderer, SpectrumRenderOptions } from './SpectrumRenderer'
+
+/**
+ * 特效频谱渲染器
+ * 基于 FFT 频段构建上下对称的连续线条频谱,使用固定的多段渐变色
+ */
+export class SpecialEffectSpectrumRenderer implements SpectrumRenderer {
+  private previousUpper: number[] = []
+
+  render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
+    width: number, height: number, _brandColor: string, options?: SpectrumRenderOptions): void {
+    const bandCount: number = bands.length
+    if (bandCount === 0) {
+      return
+    }
+
+    ctx.clearRect(0, 0, width, height)
+
+    const complexityScale: number = Math.max(0.45, Math.min(1, options?.complexityScale ?? 1))
+    const pointCount: number = Math.max(112, Math.floor(176 * complexityScale))
+    const centerY: number = height * 0.50
+    const maxAmp: number = height * (0.26 + energy * 0.18)
+    const upperPoints: number[] = new Array<number>(pointCount)
+    const lowerPoints: number[] = new Array<number>(pointCount)
+    const stepX: number = pointCount > 1 ? width / (pointCount - 1) : width
+
+    if (this.previousUpper.length !== pointCount) {
+      this.previousUpper = new Array<number>(pointCount)
+      for (let i = 0; i < pointCount; i++) {
+        this.previousUpper[i] = centerY
+      }
+    }
+
+    for (let i = 0; i < pointCount; i++) {
+      const progress: number = pointCount > 1 ? i / (pointCount - 1) : 0
+      const bandValue: number = this.sampleBands(bands, progress)
+      const prevBand: number = this.sampleBands(bands, Math.max(0, progress - 0.02))
+      const nextBand: number = this.sampleBands(bands, Math.min(1, progress + 0.02))
+      const localPeak: number = Math.max(0, bandValue * 2 - prevBand - nextBand)
+      const localContrast: number = Math.abs(nextBand - prevBand)
+      const regionEnvelope: number = this.getRegionEnvelope(progress)
+      const texture: number = this.getRegionTexture(progress, bandValue)
+      const sharpBoost: number = this.getMidSharpBoost(progress, localPeak, localContrast)
+
+      let amplitude: number = (bandValue * 0.58 + localContrast * 0.26 + localPeak * 0.92 + texture + sharpBoost) *
+        regionEnvelope
+      amplitude = this.clamp01(amplitude)
+
+      const targetUpperY: number = centerY - amplitude * maxAmp
+      const previousUpperY: number = this.previousUpper[i]
+      const upperY: number = targetUpperY <= previousUpperY ?
+        targetUpperY * 0.70 + previousUpperY * 0.30 :
+        targetUpperY * 0.34 + previousUpperY * 0.66
+
+      upperPoints[i] = upperY
+      lowerPoints[i] = centerY + (centerY - upperY)
+      this.previousUpper[i] = upperY
+    }
+
+    const gradient: CanvasGradient = ctx.createLinearGradient(0, 0, width, 0)
+    gradient.addColorStop(0.00, '#6DFF57')
+    gradient.addColorStop(0.18, '#2AE3D2')
+    gradient.addColorStop(0.42, '#7EBBFF')
+    gradient.addColorStop(0.68, '#5D66FF')
+    gradient.addColorStop(1.00, '#2A0B70')
+
+    this.drawNeedleFill(ctx, upperPoints, lowerPoints, width, centerY)
+
+    const lineWidth: number = 1.8 + energy * 1.2
+    this.strokeCurve(ctx, upperPoints, width, stepX, gradient, lineWidth)
+    this.strokeCurve(ctx, lowerPoints, width, stepX, gradient, lineWidth)
+  }
+
+  reset(): void {
+    this.previousUpper = []
+  }
+
+  private drawNeedleFill(ctx: CanvasRenderingContext2D, upperPoints: number[], lowerPoints: number[],
+    width: number, centerY: number): void {
+    const needleCount: number = upperPoints.length
+    if (needleCount === 0) {
+      return
+    }
+
+    const needleGradient: CanvasGradient = ctx.createLinearGradient(0, 0, width, 0)
+    needleGradient.addColorStop(0.00, '#6DFF57')
+    needleGradient.addColorStop(0.18, '#2AE3D2')
+    needleGradient.addColorStop(0.42, '#7EBBFF')
+    needleGradient.addColorStop(0.68, '#5D66FF')
+    needleGradient.addColorStop(1.00, '#2A0B70')
+
+    const stepX: number = needleCount > 1 ? width / (needleCount - 1) : width
+    ctx.beginPath()
+    for (let i = 0; i < needleCount; i++) {
+      const x: number = i * stepX
+      ctx.moveTo(x, upperPoints[i])
+      ctx.lineTo(x, lowerPoints[i])
+    }
+    ctx.strokeStyle = needleGradient
+    ctx.lineWidth = 0.72
+    ctx.stroke()
+
+    ctx.beginPath()
+    for (let i = 1; i < needleCount; i += 2) {
+      const x: number = i * stepX
+      const compressedUpper: number = centerY - (centerY - upperPoints[i]) * 0.58
+      const compressedLower: number = centerY + (lowerPoints[i] - centerY) * 0.58
+      ctx.moveTo(x, compressedUpper)
+      ctx.lineTo(x, compressedLower)
+    }
+    ctx.strokeStyle = needleGradient
+    ctx.lineWidth = 0.46
+    ctx.stroke()
+  }
+
+  private strokeCurve(ctx: CanvasRenderingContext2D, pointsY: number[], width: number, stepX: number,
+    strokeStyle: CanvasGradient, lineWidth: number): void {
+    if (pointsY.length === 0) {
+      return
+    }
+    ctx.beginPath()
+    ctx.moveTo(0, pointsY[0])
+    for (let i = 1; i < pointsY.length; i++) {
+      const prevX: number = (i - 1) * stepX
+      const currX: number = i * stepX
+      const prevY: number = pointsY[i - 1]
+      const currY: number = pointsY[i]
+      const cpX: number = (prevX + currX) * 0.5
+      ctx.bezierCurveTo(cpX, prevY, cpX, currY, currX, currY)
+    }
+    if (pointsY.length === 1) {
+      ctx.lineTo(width, pointsY[0])
+    }
+    ctx.strokeStyle = strokeStyle
+    ctx.lineWidth = lineWidth
+    ctx.stroke()
+  }
+
+  private sampleBands(bands: number[], progress: number): number {
+    if (bands.length === 1) {
+      return this.normalizeBand(bands[0])
+    }
+    const position: number = progress * (bands.length - 1)
+    const leftIndex: number = Math.floor(position)
+    const rightIndex: number = Math.min(bands.length - 1, leftIndex + 1)
+    const mix: number = position - leftIndex
+    const leftValue: number = this.normalizeBand(bands[leftIndex])
+    const rightValue: number = this.normalizeBand(bands[rightIndex])
+    return leftValue * (1 - mix) + rightValue * mix
+  }
+
+  private normalizeBand(value: number): number {
+    return Math.pow(this.clamp01(value), 0.84)
+  }
+
+  private getRegionEnvelope(progress: number): number {
+    if (progress < 0.28) {
+      return 0.58 + progress / 0.28 * 0.12
+    }
+    if (progress < 0.66) {
+      const t: number = (progress - 0.28) / 0.38
+      const bell: number = Math.sin(t * Math.PI)
+      return 0.70 + bell * 0.48
+    }
+    const rightT: number = (progress - 0.66) / 0.34
+    return 0.78 - rightT * 0.48
+  }
+
+  private getMidSharpBoost(progress: number, localPeak: number, localContrast: number): number {
+    const distance: number = Math.abs(progress - 0.50)
+    const focus: number = Math.max(0, 1 - distance / 0.18)
+    return (localPeak * 0.90 + localContrast * 0.34) * focus
+  }
+
+  private getRegionTexture(progress: number, bandValue: number): number {
+    if (progress < 0.30) {
+      return Math.abs(Math.sin(progress * Math.PI * 24)) * (0.05 + bandValue * 0.08)
+    }
+    if (progress < 0.66) {
+      return Math.abs(Math.sin(progress * Math.PI * 12)) * (0.02 + bandValue * 0.04)
+    }
+    const rightT: number = (progress - 0.66) / 0.34
+    const frequency: number = 24 - rightT * 17
+    const taper: number = 1 - rightT * 0.72
+    return Math.abs(Math.sin(progress * Math.PI * frequency)) * (0.05 + bandValue * 0.06) * taper
+  }
+
+  private clamp01(value: number): number {
+    if (value < 0) {
+      return 0
+    }
+    if (value > 1) {
+      return 1
+    }
+    return value
+  }
+}

+ 8 - 0
entry/src/main/ets/view/spectrum/SpectrumEffectRegistry.ets

@@ -11,6 +11,7 @@ import { DotMatrixSpectrumRenderer } from './DotMatrixSpectrumRenderer'
 import { StarburstSpectrumRenderer } from './StarburstSpectrumRenderer'
 import { RippleWaveSpectrumRenderer } from './RippleWaveSpectrumRenderer'
 import { EcgSpectrumRenderer } from './EcgSpectrumRenderer'
+import { SpecialEffectSpectrumRenderer } from './SpecialEffectSpectrumRenderer'
 
 export type SpectrumPerformanceLevel = 'low' | 'medium' | 'high'
 
@@ -113,6 +114,13 @@ const EFFECTS: SpectrumEffectDefinition[] = [
     performanceLevel: 'medium',
     performance: { targetFps: 30, minFps: 18, maxBandCount: 32, complexityFloor: 0.60 },
     createRenderer: (): SpectrumRenderer => new EcgSpectrumRenderer()
+  },
+  {
+    effectId: 'special-effect',
+    displayName: '特效',
+    performanceLevel: 'medium',
+    performance: { targetFps: 30, minFps: 18, maxBandCount: 36, complexityFloor: 0.58 },
+    createRenderer: (): SpectrumRenderer => new SpecialEffectSpectrumRenderer()
   }
 ]