怀念经典,一步步开发一个ttplayer千千静听,播放引擎

PlayerEngine 源码完整分析

image

一、概述

PlayerEngine 是音频播放引擎,使用 JavaCV 封装的 FFmpeg 进行解码,通过 javax.sound.sampled.SourceDataLine 输出音频。它支持播放/暂停/停止、Seek、音量控制、静音、均衡器(Equalizer)以及频谱数据采集。引擎在独立线程中运行解码循环,与 UI 线程解耦。


二、包声明与导入

  • 包名:org.ttplayer.engine
  • 导入:
    • org.ttplayer.model.PlaylistManagerSong:播放列表和歌曲模型。
    • org.bytedeco.javacv.FFmpegFrameGrabberFrame:FFmpeg 解码核心类。
    • javax.sound.sampled.*:Java Sound API 用于音频输出。
    • javax.swing.*:用于 SwingUtilities.invokeLater 触发 UI 回调。
    • java.io.*java.nio.*:文件 I/O 和缓冲区操作。
    • 静态导入 org.bytedeco.ffmpeg.global.avutil.AV_SAMPLE_FMT_S16:指定采样格式为 16-bit signed。

三、字段声明

3.1 播放相关

  • private PlaylistManager playlistManager;:播放列表管理器,用于获取歌曲列表。
  • private int currentSongIndex = -1;:当前播放歌曲在列表中的索引。

3.2 均衡器

  • private final Equalizer equalizer = new Equalizer();:图形均衡器,对 PCM 数据应用双二阶滤波器。

3.3 状态标志

  • private volatile boolean playing;:是否正在播放(包括暂停状态)。
  • private volatile boolean paused;:是否处于暂停状态。
  • private volatile boolean stopped;:是否已停止(停止播放线程)。

3.4 时间位置

  • private volatile long positionUs;:当前播放位置(微秒)。
  • private volatile long durationUs;:当前歌曲总时长(微秒)。

3.5 频谱数据缓冲

  • private final short[] vizRing = new short[2048];:环形缓冲区,存储左右声道平均后的单声道样本(s16)。
  • private int vizHead = 0;:环形缓冲区写入位置。
  • private volatile int vizCount = 0;:环形缓冲区中有效样本数。
  • private byte[] vizOut;:用于 getAudioData() 返回的字节数组缓存。

3.6 线程与锁

  • private Thread playThread;:解码线程。
  • private SourceDataLine line;:音频输出线。
  • private final Object stateLock = new Object();:用于线程间同步(暂停/恢复)。

3.7 文件和歌曲

  • private volatile File currentFile;:当前正在播放的音频文件。
  • private volatile Song currentSong;:当前歌曲对象。

3.8 Seek 请求

  • private volatile boolean seekRequested;:是否有待处理的 seek 请求。
  • private volatile long seekTargetUs;:seek 目标位置(微秒)。

3.9 音量和静音

  • private volatile int volumePercent = 70;:音量百分比(0~100)。
  • private volatile boolean muted;:静音标志。

3.10 监听器

  • private PlayerEngineListener listener;:播放事件回调接口。

四、构造方法

public PlayerEngine(PlaylistManager playlistManager) {
    this.playlistManager = playlistManager;
}
  • 仅注入播放列表管理器,不启动任何线程或资源。

五、公共控制方法

5.1 play(Song song)

  • 若传入的 song 或文件路径为空,直接返回。
  • 调用 stopPlayback() 停止当前播放(如果有)。
  • 设置 currentFilecurrentSong
  • 调用 startPlayback() 启动新播放。

5.2 play(int index)

  • 检查 playlistManager 及当前播放列表非空。
  • 检查索引有效性。
  • 设置 currentSongIndex 为指定索引。
  • 调用 play(songs.get(index))

5.3 playPause()

  • 若播放线程不存在或未运行,直接返回。
  • 若当前是暂停状态,调用 resumePlayback();否则调用 pausePlayback()

5.4 stop()

  • 调用 stopPlayback() 停止播放。
  • 清空 currentSongIndexcurrentFilecurrentSong

5.5 previous()

  • 检查播放列表和当前索引。
  • 计算上一首索引(若为 -1 则跳到最后一首)。
  • 调用 play(songs.get(idx))

5.6 next()

  • 类似 previous(),计算下一首索引(若超出则跳到第一首)。

5.7 seekTo(int seconds)

  • 检查秒数非负且文件存在。
  • 调用 seekToMs(seconds * 1000L)

5.8 seekToMs(long ms)

  • 检查 ms 非负且文件存在。
  • 设置 seekTargetUs = ms * 1000L
  • 设置 positionUs = seekTargetUs(先更新当前位置,但真正的 seek 发生在解码循环)。
  • 设置 seekRequested = true
  • 唤醒 stateLock 上的等待线程(解码循环可能在暂停等待)。

5.9 setGainFromPercent(int percent)

  • 限制百分值在 0~100 之间,赋值给 volumePercent
  • 调用 applyVolume() 立即生效。

5.10 mute()

  • 切换静音状态(muted = !muted)。
  • 调用 applyVolume()

5.11 setMuted(boolean m)

  • 设置静音状态,调用 applyVolume()

六、内部方法

6.1 startPlayback()

  • 若文件不存在,通过 SwingUtilities.invokeLater 通知监听器错误。
  • 设置 playing = true; paused = false; stopped = false;
  • 创建并启动新线程 playThread,执行 decodeLoop
  • 线程设为守护线程。

6.2 decodeLoop() —— 核心解码循环

这是整个引擎最复杂的方法,在一个独立线程中运行。

流程:

  1. 初始化抓取器和音频线

    • 创建 FFmpegFrameGrabber,设置采样格式为 AV_SAMPLE_FMT_S16,采样率 44100 Hz,双声道。
    • 调用 grabber.start() 打开解码器。
    • 获取总时长 lenUs = grabber.getLengthInTime(),若有效则赋值 durationUs
    • 创建 AudioFormat(44.1kHz, 16-bit, 立体声, signed, little-endian)。
    • 获取 SourceDataLine 并打开、启动,存入 this.line
    • 调用 applyVolume() 应用初始音量。
  2. 通知 UI 开始播放

    • 通过 SwingUtilities.invokeLater 调用监听器的 onPlaybackStarted
  3. 主循环while (playing && !stopped)):

    • 处理 Seek 请求
      • 如果 seekRequested 为 true,调用 grabber.setTimestamp(seekTargetUs) 进行跳转,并设置 positionUs = seekTargetUs,然后清除请求标志。
    • 暂停处理
      • 进入同步块 synchronized (stateLock),如果 paused 且未停止,则:
        • 如果 dl 正在运行,调用 dl.flush() 清空缓冲区。
        • 调用 stateLock.wait() 等待恢复。
        • 被唤醒后,调用 dl.start() 恢复输出,并调用 applyVolume() 恢复音量。
    • 解码帧
      • 调用 grabber.grabSamples() 获取一帧音频数据(返回 Frame)。
      • 若帧为空或样本数为 0,跳出循环(文件结束)。
      • frame.samples[0] 获取 ShortBuffer,读取样本数 count
      • ShortBuffer 中的样本转换为 byte[] pcm(小端字节序),长度 count * 2
    • 均衡器处理
      • 调用 equalizer.process(pcm) 对 PCM 数据进行滤波(原位修改)。
    • 输出音频
      • dl.write(pcm, 0, pcm.length) 写入音频线。
    • 频谱数据采集
      • 将双声道样本平均为单声道,写入环形缓冲区 vizRing
      • 计算左右声道值(从 pcm 字节数组中提取),求平均后转为 short
      • 存入 vizRing[vizHead],更新 vizHeadvizCount(最多 2048 个样本)。
    • 更新播放位置
      • 若帧带有有效时间戳 frame.timestamp,则 positionUs = ts;否则按采样率累加估算(count * 1000000L / 44100)。
  4. 循环结束(正常结束或文件末尾)

    • 若未停止且仍在播放状态,设置 positionUs = durationUs,并通过 UI 线程触发 onPlaybackComplete
  5. 异常处理

    • 捕获异常,通过 UI 线程触发 onPlaybackError
  6. 最终清理finally 块):

    • 设置 playing = false
    • 停止并释放 grabber
    • 排空、停止并关闭 SourceDataLinethis.line = null

七、辅助控制方法

7.1 pausePlayback()

  • 设置 paused = true
  • 如果 line 正在运行,调用 line.flush() 清除缓冲。
  • 通过 UI 线程触发 onPlaybackPaused

7.2 resumePlayback()

  • 设置 paused = false
  • 唤醒 stateLock 上等待的线程(通知解码循环继续)。
  • 调用 applyVolume() 恢复音量。
  • 触发 onPlaybackResumed

7.3 stopPlayback()

  • 设置 stopped = true; playing = false; paused = false;
  • 唤醒 stateLock 上等待的线程。
  • 中断并等待播放线程结束(playThread.join(1000))。
  • 关闭 SourceDataLine
  • 重置 linepositionUs、环形缓冲区、seek 标志、durationUs
  • 触发 onPlaybackStopped

7.4 applyVolume()

  • line 已打开,获取 MASTER_GAIN 控件。
  • 若静音或音量为 0,将增益设置为最小值(min)。
  • 否则,将音量百分比映射为分贝值:db = 20 * log10(percent/100)(注意:百分比 100% → 0dB,50% → -6dB,以此类推),然后设置到控件,限制在 [min, max] 范围内。

八、查询方法

  • isPaused(), isPlaying():返回状态标志。
  • getCurrentSongIndex(), getCurrentSong(), getVolumePercent(), getDuration(), getPositionMs(), getPosition():返回相应字段值(单位转换)。
  • getEqualizer():返回均衡器对象,供 UI 调整参数。
  • getAudioData():返回最多 1024 个单声道样本(s16le 字节数组),用于频谱可视化。具体实现:
    • vizCount 和 1024 中较小值作为 n
    • n<=0 返回 null。
    • 若缓存数组不足则重新分配。
    • 从环形缓冲区中读取最近 n 个样本(vizHead - n 为起点),按小端顺序写入 vizOut
    • 返回 vizOut

九、监听器接口 PlayerEngineListener

定义了播放状态变更的回调方法:

  • onPlaybackStarted(Song song):播放开始。
  • onPlaybackStopped():播放停止。
  • onPlaybackPaused():暂停。
  • onPlaybackResumed():恢复。
  • onPlaybackComplete():播放结束(自然播完)。
  • onPlaybackError(String error):发生错误。

所有回调都通过 SwingUtilities.invokeLater 在 UI 线程中执行,避免并发问题。


十、线程模型总结

  • 所有播放控制方法(play, pause, stop, seek 等)由 UI 线程调用。
  • 解码循环在独立线程中运行,与 UI 分离。
  • 使用 volatile 保证状态标志的可见性。
  • 使用 stateLock 实现暂停/恢复的等待通知机制。
  • Seek 请求通过 seekRequested 标志和 seekTargetUs 传递,由解码循环在下一个循环迭代中应用。
  • 播放位置 positionUs 由解码循环更新,UI 通过查询方法获取。

十一、数据流

  1. 音频文件 → FFmpeg 解码 → 16-bit 立体声 PCM(ShortBuffer)。
  2. 转换为 byte[] 小端格式。
  3. 经过均衡器处理(原位修改)。
  4. 写入 SourceDataLine 播放。
  5. 同时将立体声样本平均为单声道,存入环形缓冲区供频谱分析。

十二、错误处理

  • 文件不存在或解码失败时,通过监听器回调错误信息。
  • 解码循环捕获异常后,清理资源并退出。
  • Seek 失败时忽略异常,继续播放(不中断循环)。

以上是对 PlayerEngine 源码的完整分析,涵盖了所有字段、方法、逻辑流程和线程交互。


package org.ttplayer.engine;

import org.ttplayer.model.PlaylistManager;
import org.ttplayer.model.Song;

import org.bytedeco.javacv.FFmpegFrameGrabber;
import org.bytedeco.javacv.Frame;

import javax.sound.sampled.*;
import javax.swing.*;
import java.io.*;
import java.nio.Buffer;
import java.nio.ShortBuffer;
import java.util.List;

import static org.bytedeco.ffmpeg.global.avutil.AV_SAMPLE_FMT_S16;
import org.ttplayer.util.Messages;

/**
 * 音频播放引擎 — JavaCV(FFmpeg) 解码 + javax.sound 输出
 * 统一处理 MP3 / FLAC / WAV / OGG 等格式,支持拖动 seek。
 */
public class PlayerEngine {

    private PlaylistManager playlistManager;
    private int currentSongIndex = -1;

    /** 图形均衡器(biquad),启用后对输出 PCM 滤波 */
    private final Equalizer equalizer = new Equalizer();

    private volatile boolean playing;
    private volatile boolean paused;
    private volatile boolean stopped;

    private volatile long positionUs;
    private volatile long durationUs;

    // 频谱分析用单声道环形缓冲(立体声左右平均,对应 C 的 pcm_push)
    private final short[] vizRing = new short[2048];
    private int vizHead = 0;
    private volatile int vizCount = 0;
    private byte[] vizOut;

    private Thread playThread;
    private SourceDataLine line;
    private final Object stateLock = new Object();

    private volatile File currentFile;
    private volatile Song currentSong;

    private volatile boolean seekRequested;
    private volatile long seekTargetUs;

    /** 当前音量百分比(0-100),与音量条保持一致 */
    private volatile int volumePercent = 70;

    /** 静音状态:静音时增益压到最低 */
    private volatile boolean muted;

    private PlayerEngineListener listener;

    public interface PlayerEngineListener {
        void onPlaybackStarted(Song song);
        void onPlaybackStopped();
        void onPlaybackPaused();
        void onPlaybackResumed();
        void onPlaybackComplete();
        void onPlaybackError(String error);
    }

    public PlayerEngine(PlaylistManager playlistManager) {
        this.playlistManager = playlistManager;
    }

    public void setListener(PlayerEngineListener listener) { this.listener = listener; }

    // ======================== 公共控制 ========================

    public void play(Song song) {
        if (song == null || song.filePath == null) return;
        stopPlayback();
        currentFile = new File(song.filePath);
        currentSong = song;
        startPlayback();
    }

    public void play(int index) {
        if (playlistManager == null || playlistManager.getCurrentPlaylist() == null) return;
        List<Song> songs = playlistManager.getCurrentPlaylist().songs;
        if (index < 0 || index >= songs.size()) return;
        currentSongIndex = index;
        play(songs.get(index));
    }

    public void playPause() {
        if (playThread == null || !playThread.isAlive()) return;
        if (paused) {
            resumePlayback();
        } else {
            pausePlayback();
        }
    }

    public void stop() {
        stopPlayback();
        currentSongIndex = -1;
        currentFile = null;
        currentSong = null;
    }

    public void previous() {
        if (playlistManager == null || playlistManager.getCurrentPlaylist() == null) return;
        List<Song> songs = playlistManager.getCurrentPlaylist().songs;
        if (songs.isEmpty()) return;
        int idx = currentSongIndex - 1;
        if (idx < 0) idx = songs.size() - 1;
        currentSongIndex = idx;
        play(songs.get(idx));
    }

    public void next() {
        if (playlistManager == null || playlistManager.getCurrentPlaylist() == null) return;
        List<Song> songs = playlistManager.getCurrentPlaylist().songs;
        if (songs.isEmpty()) return;
        int idx = currentSongIndex + 1;
        if (idx >= songs.size()) idx = 0;
        currentSongIndex = idx;
        play(songs.get(idx));
    }

    public void seekTo(int seconds) {
        if (seconds < 0 || currentFile == null) return;
        seekToMs(seconds * 1000L);
    }

    /** 毫秒级 seek:目标时间写入后由解码循环在下一个循环位置应用 */
    public void seekToMs(long ms) {
        if (ms < 0 || currentFile == null) return;
        seekTargetUs = ms * 1000L;
        positionUs = seekTargetUs;
        seekRequested = true;
        synchronized (stateLock) { stateLock.notifyAll(); }
    }

    public void setGainFromPercent(int percent) {
        volumePercent = Math.max(0, Math.min(100, percent));
        applyVolume();
    }

    public void mute() {
        muted = !muted;
        applyVolume();
    }

    public void setMuted(boolean m) {
        muted = m;
        applyVolume();
    }

    public boolean isMuted() { return muted; }

    // ======================== 内部 ========================

    private void startPlayback() {
        if (currentFile == null || !currentFile.exists()) {
            SwingUtilities.invokeLater(() -> {
                if (listener != null) listener.onPlaybackError(org.ttplayer.util.Messages.get("player.fileNotFound"));
            });
            return;
        }

        playing = true;
        paused = false;
        stopped = false;

        playThread = new Thread(this::decodeLoop, "playback");
        playThread.setDaemon(true);
        playThread.start();
    }

    // ==================== FFmpeg 解码循环 ====================

    private void decodeLoop() {
        FFmpegFrameGrabber grabber = null;
        SourceDataLine dl = null;

        try {
            grabber = new FFmpegFrameGrabber(currentFile);
            grabber.setSampleFormat(AV_SAMPLE_FMT_S16);
            grabber.setSampleRate(44100);
            grabber.setAudioChannels(2);
            grabber.start();

            long lenUs = grabber.getLengthInTime();
            durationUs = lenUs > 0 ? lenUs : 0;

            AudioFormat fmt = new AudioFormat(44100, 16, 2, true, false);
            dl = (SourceDataLine) AudioSystem.getLine(new DataLine.Info(SourceDataLine.class, fmt));
            dl.open(fmt);
            dl.start();
            this.line = dl;
            applyVolume();

            final Song startSong = currentSong;
            SwingUtilities.invokeLater(() -> {
                if (listener != null && startSong != null) {
                    listener.onPlaybackStarted(startSong);
                }
            });

            while (playing && !stopped) {
                if (seekRequested) {
                    try {
                        grabber.setTimestamp(seekTargetUs);
                        positionUs = seekTargetUs;
                    } catch (Exception ignored) {
                        // seek 失败时保持当前位置继续播放
                    }
                    seekRequested = false;
                    continue;
                }

                synchronized (stateLock) {
                    while (paused && !stopped) {
                        if (dl.isRunning()) dl.flush();
                        try { stateLock.wait(); } catch (InterruptedException e) { break; }
                        dl.start();
                        applyVolume();
                    }
                }
                if (stopped) break;

                Frame frame = grabber.grabSamples();
                if (frame == null) break;
                if (frame.samples == null || frame.samples.length == 0) continue;

                Buffer sampleBuf = frame.samples[0];
                if (!(sampleBuf instanceof ShortBuffer)) continue;
                ShortBuffer sb = (ShortBuffer) sampleBuf;
                int count = sb.remaining();
                if (count <= 0) continue;

                byte[] pcm = new byte[count * 2];
                sb.rewind();
                for (int i = 0; i < count; i++) {
                    short s = sb.get();
                    pcm[2 * i] = (byte) (s & 0xFF);
                    pcm[2 * i + 1] = (byte) ((s >> 8) & 0xFF);
                }

                // 均衡器(启用时原地滤波)
                equalizer.process(pcm);

                dl.write(pcm, 0, pcm.length);

                // 立体声左右平均 → 单声道环缓冲(对应 C pcm_push,取滤波后数据)
                int mono = count / 2;
                for (int m = 0; m < mono; m++) {
                    int base = m * 4;
                    int l = ((pcm[base + 1] << 8) | (pcm[base] & 0xFF));
                    int r = ((pcm[base + 3] << 8) | (pcm[base + 2] & 0xFF));
                    short s = (short) ((l + r) / 2);
                    vizRing[vizHead] = s;
                    vizHead = (vizHead + 1) % vizRing.length;
                    if (vizCount < vizRing.length) vizCount++;
                }

                long ts = frame.timestamp;
                if (ts > 0) {
                    positionUs = ts;
                } else {
                    positionUs += count * 1000000L / 44100;
                }
            }

            if (!stopped && playing) {
                positionUs = durationUs;
                SwingUtilities.invokeLater(() -> {
                    if (listener != null) listener.onPlaybackComplete();
                });
            }

        } catch (Exception e) {
            if (!stopped) {
                SwingUtilities.invokeLater(() -> {
                    if (listener != null) listener.onPlaybackError(org.ttplayer.util.Messages.get("player.errorPrefix") + e.getMessage());
                });
            }
        } finally {
            playing = false;
            if (grabber != null) {
                try { grabber.stop(); } catch (Exception ignored) {}
            }
            if (dl != null) {
                try { dl.drain(); } catch (Exception ignored) {}
                try { dl.stop(); dl.close(); } catch (Exception ignored) {}
            }
            this.line = null;
        }
    }

    // ==================== 通用方法 ====================

    private void pausePlayback() {
        paused = true;
        if (line != null && line.isRunning()) line.flush();
        SwingUtilities.invokeLater(() -> {
            if (listener != null) listener.onPlaybackPaused();
        });
    }

    private void resumePlayback() {
        paused = false;
        synchronized (stateLock) { stateLock.notifyAll(); }
        applyVolume();
        SwingUtilities.invokeLater(() -> {
            if (listener != null) listener.onPlaybackResumed();
        });
    }

    private void stopPlayback() {
        stopped = true;
        playing = false;
        paused = false;
        synchronized (stateLock) { stateLock.notifyAll(); }
        if (playThread != null) {
            playThread.interrupt();
            try { playThread.join(1000); } catch (InterruptedException ignored) {}
            playThread = null;
        }
        if (line != null) {
            try { line.drain(); } catch (Exception ignored) {}
            try { line.stop(); line.close(); } catch (Exception ignored) {}
        }
        line = null;
        positionUs = 0;
        vizHead = 0;
        vizCount = 0;
        seekRequested = false;
        durationUs = 0;
        SwingUtilities.invokeLater(() -> {
            if (listener != null) listener.onPlaybackStopped();
        });
    }

    private void applyVolume() {
        if (line != null && line.isOpen()) {
            try {
                FloatControl vol = (FloatControl) line.getControl(FloatControl.Type.MASTER_GAIN);
                float min = vol.getMinimum();
                float max = vol.getMaximum();
                if (muted || volumePercent <= 0) {
                    vol.setValue(min);
                } else {
                    // 百分比 → 分贝(对数映射):100%≈0dB,50%≈-6dB,近似线性响度
                    float db = 20f * (float) Math.log10(volumePercent / 100.0);
                    vol.setValue(Math.max(min, Math.min(max, db)));
                }
            } catch (Exception ignored) {}
        }
    }

    // ======================== 查询 ========================

    public boolean isPaused() { return paused; }
    public boolean isPlaying() { return playing; }
    public int getCurrentSongIndex() { return currentSongIndex; }
    public Song getCurrentSong() { return currentSong; }
    public int getVolumePercent() { return volumePercent; }
    public int getDuration() { return (int) (durationUs / 1000000); }
    public long getPositionMs() { return positionUs / 1000; }
    public int getPosition() { return (int) (positionUs / 1000000); }

    public Equalizer getEqualizer() { return equalizer; }

    /**
     * 返回最近最多 1024 个单声道(左右平均)s16le 样本,供频谱分析使用。
     * 对应 C 的 tt_audio_get_pcm_samples(audio, pcm, VIZ_FFT_N)。
     */
    public byte[] getAudioData() {
        int n = Math.min(1024, vizCount);
        if (n <= 0) return null;
        if (vizOut == null || vizOut.length != n * 2) vizOut = new byte[n * 2];
        int idx = vizHead - n;
        if (idx < 0) idx += vizRing.length;
        int j = 0;
        for (int i = 0; i < n; i++) {
            short s = vizRing[(idx + i) % vizRing.length];
            vizOut[j++] = (byte) (s & 0xFF);
            vizOut[j++] = (byte) ((s >> 8) & 0xFF);
        }
        return vizOut;
    }
}

License

本项目仅供学习和个人使用。皮肤资源版权归原作者所有。

「获取方式」:后台回复 ttplayer

image

posted @ 2026-08-28 12:03  tomj2ee  阅读(3)  评论(0)    收藏  举报