怀念经典,一步步开发一个ttplayer千千静听,播放引擎
PlayerEngine 源码完整分析

一、概述
PlayerEngine 是音频播放引擎,使用 JavaCV 封装的 FFmpeg 进行解码,通过 javax.sound.sampled.SourceDataLine 输出音频。它支持播放/暂停/停止、Seek、音量控制、静音、均衡器(Equalizer)以及频谱数据采集。引擎在独立线程中运行解码循环,与 UI 线程解耦。
二、包声明与导入
- 包名:
org.ttplayer.engine - 导入:
org.ttplayer.model.PlaylistManager、Song:播放列表和歌曲模型。org.bytedeco.javacv.FFmpegFrameGrabber、Frame: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()停止当前播放(如果有)。 - 设置
currentFile和currentSong。 - 调用
startPlayback()启动新播放。
5.2 play(int index)
- 检查
playlistManager及当前播放列表非空。 - 检查索引有效性。
- 设置
currentSongIndex为指定索引。 - 调用
play(songs.get(index))。
5.3 playPause()
- 若播放线程不存在或未运行,直接返回。
- 若当前是暂停状态,调用
resumePlayback();否则调用pausePlayback()。
5.4 stop()
- 调用
stopPlayback()停止播放。 - 清空
currentSongIndex、currentFile、currentSong。
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() —— 核心解码循环
这是整个引擎最复杂的方法,在一个独立线程中运行。
流程:
-
初始化抓取器和音频线:
- 创建
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()应用初始音量。
- 创建
-
通知 UI 开始播放:
- 通过
SwingUtilities.invokeLater调用监听器的onPlaybackStarted。
- 通过
-
主循环(
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],更新vizHead和vizCount(最多 2048 个样本)。
- 将双声道样本平均为单声道,写入环形缓冲区
- 更新播放位置:
- 若帧带有有效时间戳
frame.timestamp,则positionUs = ts;否则按采样率累加估算(count * 1000000L / 44100)。
- 若帧带有有效时间戳
- 处理 Seek 请求:
-
循环结束(正常结束或文件末尾):
- 若未停止且仍在播放状态,设置
positionUs = durationUs,并通过 UI 线程触发onPlaybackComplete。
- 若未停止且仍在播放状态,设置
-
异常处理:
- 捕获异常,通过 UI 线程触发
onPlaybackError。
- 捕获异常,通过 UI 线程触发
-
最终清理(
finally块):- 设置
playing = false。 - 停止并释放
grabber。 - 排空、停止并关闭
SourceDataLine,this.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。 - 重置
line、positionUs、环形缓冲区、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 通过查询方法获取。
十一、数据流
- 音频文件 → FFmpeg 解码 → 16-bit 立体声 PCM(
ShortBuffer)。 - 转换为
byte[]小端格式。 - 经过均衡器处理(原位修改)。
- 写入
SourceDataLine播放。 - 同时将立体声样本平均为单声道,存入环形缓冲区供频谱分析。
十二、错误处理
- 文件不存在或解码失败时,通过监听器回调错误信息。
- 解码循环捕获异常后,清理资源并退出。
- 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


浙公网安备 33010602011771号