/**
 * 基于 Web Audio 频谱能量驱动进度指示器的呼吸效果。
 * 仅负责视觉变量更新,不参与播放逻辑。
 */
export class SeekReactiveIndicator {
    private readonly _audio: HTMLAudioElement;

    private _seekWrapper: HTMLElement | null = null;

    private _audioContext: AudioContext | null = null;

    private _analyser: AnalyserNode | null = null;

    private _mediaElementSource: MediaElementAudioSourceNode | null = null;

    private _analyserData: Uint8Array<ArrayBuffer> | null = null;

    private _rafId: number | null = null;

    private _energy: number = 0;

    constructor(audio: HTMLAudioElement) {
        this._audio = audio;
    }

    /**
     * 绑定进度条容器。
     */
    public attach(seekWrapper: HTMLElement): void {
        this._seekWrapper = seekWrapper;
        this.resetVisual();
    }

    /**
     * 开始频谱驱动动画。
     */
    public start(): void {
        if (!this._seekWrapper) {
            return;
        }

        this._seekWrapper.classList.add('music-player__seek-wrapper--playing');
        if (this._rafId !== null) {
            return;
        }

        const hasGraph = this.ensureAudioGraph();
        if (hasGraph && this._audioContext && this._audioContext.state === 'suspended') {
            void this._audioContext.resume();
        }

        const loop = () => {
            if (!this._seekWrapper || this._audio.paused) {
                this._rafId = null;
                return;
            }

            const energy = hasGraph ? this.readFrequencyEnergy() : this.readFallbackEnergy();

            // 峰值保持 + 衰减,增强节拍可见性。
            this._energy = Math.max(energy, this._energy * 0.84);

            const beatScale = 1 + Math.min(1, this._energy * 1.45) * 0.38;
            const beatGlow = 0.45 + Math.min(1.4, this._energy * 2.2);
            const lineGlow = 0.2 + Math.min(1, this._energy * 1.9) * 0.8;

            this._seekWrapper.style.setProperty('--music-seek-beat-scale', beatScale.toFixed(3));
            this._seekWrapper.style.setProperty('--music-seek-beat-glow', beatGlow.toFixed(3));
            this._seekWrapper.style.setProperty('--music-seek-line-glow', lineGlow.toFixed(3));

            this._rafId = window.requestAnimationFrame(loop);
        };

        this._rafId = window.requestAnimationFrame(loop);
    }

    /**
     * 停止动画并复位视觉变量。
     */
    public stop(): void {
        if (this._rafId !== null) {
            window.cancelAnimationFrame(this._rafId);
            this._rafId = null;
        }

        if (this._seekWrapper) {
            this._seekWrapper.classList.remove('music-player__seek-wrapper--playing');
        }

        this._energy = 0;
        this.resetVisual();
    }

    private resetVisual(): void {
        if (!this._seekWrapper) {
            return;
        }
        this._seekWrapper.style.setProperty('--music-seek-beat-scale', '1');
        this._seekWrapper.style.setProperty('--music-seek-beat-glow', '0.45');
        this._seekWrapper.style.setProperty('--music-seek-line-glow', '0.35');
    }

    private ensureAudioGraph(): boolean {
        if (
            this._audioContext &&
            this._analyser &&
            this._mediaElementSource &&
            this._analyserData
        ) {
            return true;
        }

        if (typeof window.AudioContext === 'undefined') {
            return false;
        }

        try {
            this._audioContext = new window.AudioContext();
            this._analyser = this._audioContext.createAnalyser();
            this._analyser.fftSize = 256;
            this._analyser.smoothingTimeConstant = 0.72;
            this._analyserData = new Uint8Array(new ArrayBuffer(this._analyser.frequencyBinCount));

            this._mediaElementSource = this._audioContext.createMediaElementSource(this._audio);
            this._mediaElementSource.connect(this._analyser);
            this._analyser.connect(this._audioContext.destination);
            return true;
        } catch (e) {
            console.warn('Seek reactive indicator init failed:', e);
            this._audioContext = null;
            this._analyser = null;
            this._mediaElementSource = null;
            this._analyserData = null;
            return false;
        }
    }

    private readFrequencyEnergy(): number {
        if (!this._analyser || !this._analyserData) {
            return 0;
        }

        this._analyser.getByteFrequencyData(this._analyserData);

        // 低频到中低频更接近“鼓点感”,并加权前几个 bin。
        const bins = Math.min(24, this._analyserData.length);
        let sum = 0;
        let weightSum = 0;
        for (let i = 0; i < bins; i++) {
            const weight = 1.35 - i / (bins * 1.8);
            sum += this._analyserData[i] * weight;
            weightSum += weight;
        }

        return weightSum > 0 ? sum / weightSum / 255 : 0;
    }

    private readFallbackEnergy(): number {
        const t = performance.now() / 1000;
        const wave = (Math.sin(t * 2.6) + 1) * 0.18;
        const jitter = Math.random() * 0.05;
        return Math.min(0.35, wave + jitter);
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

这是一个基于 Web Audio API 的音乐播放器视觉效果类,用于创建随音频频谱变化的进度条"呼吸"动画效果。让我详细解释一下各个部分:

类的整体设计

这是一个纯视觉效果类,只负责更新 CSS 变量来驱动动画,不参与音频播放逻辑。

主要属性

// 核心音频元素
private readonly _audio: HTMLAudioElement;
// 进度条容器元素
private _seekWrapper: HTMLElement | null = null;

// Web Audio API 相关
private _audioContext: AudioContext | null = null;
private _analyser: AnalyserNode | null = null;
private _mediaElementSource: MediaElementAudioSourceNode | null = null;
private _analyserData: Uint8Array<ArrayBuffer> | null = null;

// 动画控制
private _rafId: number | null = null;
private _energy: number = 0; // 当前能量值

核心方法解析

1. attach() - 绑定 DOM 元素

public attach(seekWrapper: HTMLElement): void {
    this._seekWrapper = seekWrapper;
    this.resetVisual(); // 初始化视觉状态
}

2. start() - 开始动画循环

这是核心方法,包含以下步骤:

  • 添加播放状态的 CSS 类
  • 确保 Web Audio 图形已创建
  • 启动 requestAnimationFrame 循环

动画循环逻辑:

const loop = () => {
    // 读取音频能量(频谱分析或备用方案)
    const energy = hasGraph ? this.readFrequencyEnergy() : this.readFallbackEnergy();
    
    // 峰值保持 + 衰减算法,让节拍更明显
    this._energy = Math.max(energy, this._energy * 0.84);
    
    // 计算视觉效果参数
    const beatScale = 1 + Math.min(1, this._energy * 1.45) * 0.38;
    const beatGlow = 0.45 + Math.min(1.4, this._energy * 2.2);
    const lineGlow = 0.2 + Math.min(1, this._energy * 1.9) * 0.8;
    
    // 更新 CSS 自定义属性
    this._seekWrapper.style.setProperty('--music-seek-beat-scale', beatScale.toFixed(3));
    this._seekWrapper.style.setProperty('--music-seek-beat-glow', beatGlow.toFixed(3));
    this._seekWrapper.style.setProperty('--music-seek-line-glow', lineGlow.toFixed(3));
};

3. ensureAudioGraph() - 创建音频分析图

private ensureAudioGraph(): boolean {
    try {
        this._audioContext = new window.AudioContext();
        this._analyser = this._audioContext.createAnalyser();
        this._analyser.fftSize = 256; // 频谱分辨率
        this._analyser.smoothingTimeConstant = 0.72; // 平滑度
        
        // 创建音频处理链: 音频元素 -> 分析器 -> 扬声器
        this._mediaElementSource = this._audioContext.createMediaElementSource(this._audio);
        this._mediaElementSource.connect(this._analyser);
        this._analyser.connect(this._audioContext.destination);
        
        return true;
    } catch (e) {
        // 降级处理
        return false;
    }
}

4. 能量读取方法

频谱能量读取:

private readFrequencyEnergy(): number {
    this._analyser.getByteFrequencyData(this._analyserData);
    
    // 重点关注低频到中低频(鼓点感强)
    const bins = Math.min(24, this._analyserData.length);
    let sum = 0;
    let weightSum = 0;
    
    for (let i = 0; i < bins; i++) {
        // 给前面的频率 bin 更高权重
        const weight = 1.35 - i / (bins * 1.8);
        sum += this._analyserData[i] * weight;
        weightSum += weight;
    }
    
    return weightSum > 0 ? sum / weightSum / 255 : 0;
}

备用能量方案(无 Web Audio 支持时):

private readFallbackEnergy(): number {
    const t = performance.now() / 1000;
    const wave = (Math.sin(t * 2.6) + 1) * 0.18; // 模拟波动
    const jitter = Math.random() * 0.05; // 添加随机性
    return Math.min(0.35, wave + jitter);
}

设计亮点

  1. 峰值保持算法this._energy = Math.max(energy, this._energy * 0.84) 让节拍更明显
  2. 频率加权:专注低频段,突出鼓点和低音
  3. 优雅降级:Web Audio API 不支持时使用数学函数模拟
  4. 性能优化:使用 requestAnimationFrame 和适当的频谱分辨率
  5. 分离关注点:只负责视觉效果,不干预音频播放

这个类创建了一个随音乐节拍"跳动"的视觉效果,增强了用户的听觉体验。

评论加载中...