Decode on a single background thread; add intro skip key

PlaybackEngine now runs one daemon decode thread that routes packets into
bounded queues, while the video/audio presenter threads only consume and
pace. This removes the demuxer contention that caused stutter, bursts, and
the speed-up/catch-up behavior.

Press S (or media next) during playback to skip the current playlist and
advance the menu's intro chain quickly.
This commit is contained in:
2026-09-16 00:04:25 -04:00
parent 3ded804b94
commit 9a1e048264
2 changed files with 101 additions and 54 deletions
@@ -9,7 +9,9 @@ import dev.quoije.umd.mps.AudioFramer;
import dev.quoije.umd.mps.MpsDemuxer;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
/**
* Decodes an MPS stream into timestamped video frames and PCM audio.
@@ -47,9 +49,12 @@ public final class PlaybackEngine implements AutoCloseable {
private final AnnexBSplitter videoSplitter = new AnnexBSplitter();
private final AudioFramer audioFramer = new AudioFramer();
private static final int VIDEO_QUEUE_CAPACITY = 16;
private static final int AUDIO_QUEUE_CAPACITY = 64;
private Atrac3pDecoder audioDecoder;
private final ArrayDeque<VideoFrame> videoQueue = new ArrayDeque<>();
private final ArrayDeque<AudioChunk> audioQueue = new ArrayDeque<>();
private final BlockingQueue<VideoFrame> videoQueue = new LinkedBlockingQueue<>(VIDEO_QUEUE_CAPACITY);
private final BlockingQueue<AudioChunk> audioQueue = new LinkedBlockingQueue<>(AUDIO_QUEUE_CAPACITY);
private long pendingVideoPts = -1;
private long nextVideoPts = 0;
@@ -59,71 +64,98 @@ public final class PlaybackEngine implements AutoCloseable {
private boolean audioPtsSet = false;
private long audioFramesEmitted = 0;
private boolean eof = false;
private volatile boolean eof = false;
private volatile IOException failure = null;
private final Thread decodeThread;
private PlaybackEngine(UmdIsoReader iso, String streamName, int audioSubstream) throws IOException {
this.demuxer = MpsDemuxer.open(iso, streamName);
this.audioSubstream = audioSubstream;
this.decodeThread = new Thread(this::decodeLoop, "umd-decode-" + streamName);
this.decodeThread.setDaemon(true);
}
/** Opens the given playlist stream for playback, using the given audio substream (0 or 1). */
/** Opens the given playlist stream and starts decoding in the background. */
public static PlaybackEngine open(UmdIsoReader iso, String streamName, int audioSubstream) throws IOException {
return new PlaybackEngine(iso, streamName, audioSubstream);
PlaybackEngine engine = new PlaybackEngine(iso, streamName, audioSubstream);
engine.decodeThread.start();
return engine;
}
/**
* Decodes until the next video frame is available, or end of stream.
* Returns {@code null} at end of stream.
*/
public synchronized VideoFrame nextVideoFrame() throws IOException {
int budget = 1000;
while (videoQueue.isEmpty() && budget-- > 0) {
if (!routeOne()) {
break;
/** The single decode thread: routes every packet and fills both queues. */
private void decodeLoop() {
try {
while (true) {
MpsDemuxer.Packet packet = demuxer.nextPacket();
if (packet == null) {
finish();
break;
}
if (packet.isVideo()) {
handleVideo(packet);
} else if (packet.isAudio() && packet.channel() == audioSubstream) {
handleAudio(packet);
}
}
} catch (InterruptedException e) {
// Closed; stop decoding.
} catch (IOException e) {
failure = e;
} finally {
eof = true;
}
}
/** Waits for the next video frame, or returns {@code null} at end of stream. */
public VideoFrame nextVideoFrame() throws IOException {
while (true) {
VideoFrame frame;
try {
frame = videoQueue.poll(50, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
if (frame != null) {
return frame;
}
if (eof && videoQueue.isEmpty()) {
if (failure != null) {
throw failure;
}
return null;
}
}
return videoQueue.poll();
}
/**
* Decodes until the next audio chunk is available, or end of stream.
* Returns {@code null} at end of stream.
*/
public synchronized AudioChunk nextAudioChunk() throws IOException {
int budget = 1000;
while (audioQueue.isEmpty() && budget-- > 0) {
if (!routeOne()) {
break;
/** Waits for the next audio chunk, or returns {@code null} at end of stream. */
public AudioChunk nextAudioChunk() throws IOException {
while (true) {
AudioChunk chunk;
try {
chunk = audioQueue.poll(50, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
if (chunk != null) {
return chunk;
}
if (eof && audioQueue.isEmpty()) {
if (failure != null) {
throw failure;
}
return null;
}
}
return audioQueue.poll();
}
/** True once the stream has been fully decoded. */
public synchronized boolean isEof() {
/** True once the stream has been fully decoded and drained. */
public boolean isEof() {
return eof && videoQueue.isEmpty() && audioQueue.isEmpty();
}
/** Reads and routes one packet. Returns false at end of stream. */
private boolean routeOne() throws IOException {
if (eof) {
return false;
}
MpsDemuxer.Packet packet = demuxer.nextPacket();
if (packet == null) {
finish();
eof = true;
return false;
}
if (packet.isVideo()) {
handleVideo(packet);
} else if (packet.isAudio() && packet.channel() == audioSubstream) {
handleAudio(packet);
}
return true;
}
private void handleVideo(MpsDemuxer.Packet packet) {
private void handleVideo(MpsDemuxer.Packet packet) throws InterruptedException {
if (packet.pts() >= 0) {
if (pendingVideoPts < 0) {
nextVideoPts = packet.pts();
@@ -137,7 +169,7 @@ public final class PlaybackEngine implements AutoCloseable {
videoDecoder.feedAccessUnit(accessUnit);
VideoDecoder.Frame frame;
while ((frame = videoDecoder.receive()) != null) {
videoQueue.add(new VideoFrame(
videoQueue.put(new VideoFrame(
assignVideoPts(), frame.width(), frame.height(),
frame.pixelFormat(), frame.yPlane(), frame.yStride(),
frame.uPlane(), frame.uStride(), frame.vPlane(), frame.vStride()));
@@ -167,7 +199,7 @@ public final class PlaybackEngine implements AutoCloseable {
return videoFrameDurationTicks;
}
private void handleAudio(MpsDemuxer.Packet packet) {
private void handleAudio(MpsDemuxer.Packet packet) throws InterruptedException {
if (packet.pts() >= 0) {
if (!audioPtsSet) {
audioFirstPts = packet.pts();
@@ -191,12 +223,12 @@ public final class PlaybackEngine implements AutoCloseable {
audioDecoder.feedFrame(frame);
byte[] pcm;
while ((pcm = audioDecoder.receive()) != null) {
audioQueue.add(new AudioChunk(pts, pcm));
audioQueue.put(new AudioChunk(pts, pcm));
}
}
}
private void finish() {
private void finish() throws InterruptedException {
byte[] tail = videoSplitter.drain();
if (tail != null && tail.length > 0) {
videoDecoder.feedAccessUnit(tail);
@@ -204,7 +236,7 @@ public final class PlaybackEngine implements AutoCloseable {
videoDecoder.flush();
VideoDecoder.Frame frame;
while ((frame = videoDecoder.receive()) != null) {
videoQueue.add(new VideoFrame(
videoQueue.put(new VideoFrame(
assignVideoPts(), frame.width(), frame.height(),
frame.pixelFormat(), frame.yPlane(), frame.yStride(),
frame.uPlane(), frame.uStride(), frame.vPlane(), frame.vStride()));
@@ -214,7 +246,7 @@ public final class PlaybackEngine implements AutoCloseable {
audioDecoder.flush();
byte[] pcm;
while ((pcm = audioDecoder.receive()) != null) {
audioQueue.add(new AudioChunk(
audioQueue.put(new AudioChunk(
audioFirstPts + audioFramesEmitted * AUDIO_FRAME_TICKS, pcm));
}
}
@@ -222,6 +254,12 @@ public final class PlaybackEngine implements AutoCloseable {
@Override
public void close() {
decodeThread.interrupt();
try {
decodeThread.join(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
videoDecoder.close();
if (audioDecoder != null) {
audioDecoder.close();