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 dev.quoije.umd.mps.MpsDemuxer;
import java.io.IOException; 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. * 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 AnnexBSplitter videoSplitter = new AnnexBSplitter();
private final AudioFramer audioFramer = new AudioFramer(); 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 Atrac3pDecoder audioDecoder;
private final ArrayDeque<VideoFrame> videoQueue = new ArrayDeque<>(); private final BlockingQueue<VideoFrame> videoQueue = new LinkedBlockingQueue<>(VIDEO_QUEUE_CAPACITY);
private final ArrayDeque<AudioChunk> audioQueue = new ArrayDeque<>(); private final BlockingQueue<AudioChunk> audioQueue = new LinkedBlockingQueue<>(AUDIO_QUEUE_CAPACITY);
private long pendingVideoPts = -1; private long pendingVideoPts = -1;
private long nextVideoPts = 0; private long nextVideoPts = 0;
@@ -59,71 +64,98 @@ public final class PlaybackEngine implements AutoCloseable {
private boolean audioPtsSet = false; private boolean audioPtsSet = false;
private long audioFramesEmitted = 0; 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 { private PlaybackEngine(UmdIsoReader iso, String streamName, int audioSubstream) throws IOException {
this.demuxer = MpsDemuxer.open(iso, streamName); this.demuxer = MpsDemuxer.open(iso, streamName);
this.audioSubstream = audioSubstream; 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 { 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;
} }
/** /** The single decode thread: routes every packet and fills both queues. */
* Decodes until the next video frame is available, or end of stream. private void decodeLoop() {
* Returns {@code null} at end of stream. try {
*/ while (true) {
public synchronized VideoFrame nextVideoFrame() throws IOException { MpsDemuxer.Packet packet = demuxer.nextPacket();
int budget = 1000; if (packet == null) {
while (videoQueue.isEmpty() && budget-- > 0) { finish();
if (!routeOne()) { break;
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();
} }
/** /** Waits for the next audio chunk, or returns {@code null} at end of stream. */
* Decodes until the next audio chunk is available, or end of stream. public AudioChunk nextAudioChunk() throws IOException {
* Returns {@code null} at end of stream. while (true) {
*/ AudioChunk chunk;
public synchronized AudioChunk nextAudioChunk() throws IOException { try {
int budget = 1000; chunk = audioQueue.poll(50, TimeUnit.MILLISECONDS);
while (audioQueue.isEmpty() && budget-- > 0) { } catch (InterruptedException e) {
if (!routeOne()) { Thread.currentThread().interrupt();
break; 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. */ /** True once the stream has been fully decoded and drained. */
public synchronized boolean isEof() { public boolean isEof() {
return eof && videoQueue.isEmpty() && audioQueue.isEmpty(); return eof && videoQueue.isEmpty() && audioQueue.isEmpty();
} }
/** Reads and routes one packet. Returns false at end of stream. */ private void handleVideo(MpsDemuxer.Packet packet) throws InterruptedException {
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) {
if (packet.pts() >= 0) { if (packet.pts() >= 0) {
if (pendingVideoPts < 0) { if (pendingVideoPts < 0) {
nextVideoPts = packet.pts(); nextVideoPts = packet.pts();
@@ -137,7 +169,7 @@ public final class PlaybackEngine implements AutoCloseable {
videoDecoder.feedAccessUnit(accessUnit); videoDecoder.feedAccessUnit(accessUnit);
VideoDecoder.Frame frame; VideoDecoder.Frame frame;
while ((frame = videoDecoder.receive()) != null) { while ((frame = videoDecoder.receive()) != null) {
videoQueue.add(new VideoFrame( videoQueue.put(new VideoFrame(
assignVideoPts(), frame.width(), frame.height(), assignVideoPts(), frame.width(), frame.height(),
frame.pixelFormat(), frame.yPlane(), frame.yStride(), frame.pixelFormat(), frame.yPlane(), frame.yStride(),
frame.uPlane(), frame.uStride(), frame.vPlane(), frame.vStride())); frame.uPlane(), frame.uStride(), frame.vPlane(), frame.vStride()));
@@ -167,7 +199,7 @@ public final class PlaybackEngine implements AutoCloseable {
return videoFrameDurationTicks; return videoFrameDurationTicks;
} }
private void handleAudio(MpsDemuxer.Packet packet) { private void handleAudio(MpsDemuxer.Packet packet) throws InterruptedException {
if (packet.pts() >= 0) { if (packet.pts() >= 0) {
if (!audioPtsSet) { if (!audioPtsSet) {
audioFirstPts = packet.pts(); audioFirstPts = packet.pts();
@@ -191,12 +223,12 @@ public final class PlaybackEngine implements AutoCloseable {
audioDecoder.feedFrame(frame); audioDecoder.feedFrame(frame);
byte[] pcm; byte[] pcm;
while ((pcm = audioDecoder.receive()) != null) { 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(); byte[] tail = videoSplitter.drain();
if (tail != null && tail.length > 0) { if (tail != null && tail.length > 0) {
videoDecoder.feedAccessUnit(tail); videoDecoder.feedAccessUnit(tail);
@@ -204,7 +236,7 @@ public final class PlaybackEngine implements AutoCloseable {
videoDecoder.flush(); videoDecoder.flush();
VideoDecoder.Frame frame; VideoDecoder.Frame frame;
while ((frame = videoDecoder.receive()) != null) { while ((frame = videoDecoder.receive()) != null) {
videoQueue.add(new VideoFrame( videoQueue.put(new VideoFrame(
assignVideoPts(), frame.width(), frame.height(), assignVideoPts(), frame.width(), frame.height(),
frame.pixelFormat(), frame.yPlane(), frame.yStride(), frame.pixelFormat(), frame.yPlane(), frame.yStride(),
frame.uPlane(), frame.uStride(), frame.vPlane(), frame.vStride())); frame.uPlane(), frame.uStride(), frame.vPlane(), frame.vStride()));
@@ -214,7 +246,7 @@ public final class PlaybackEngine implements AutoCloseable {
audioDecoder.flush(); audioDecoder.flush();
byte[] pcm; byte[] pcm;
while ((pcm = audioDecoder.receive()) != null) { while ((pcm = audioDecoder.receive()) != null) {
audioQueue.add(new AudioChunk( audioQueue.put(new AudioChunk(
audioFirstPts + audioFramesEmitted * AUDIO_FRAME_TICKS, pcm)); audioFirstPts + audioFramesEmitted * AUDIO_FRAME_TICKS, pcm));
} }
} }
@@ -222,6 +254,12 @@ public final class PlaybackEngine implements AutoCloseable {
@Override @Override
public void close() { public void close() {
decodeThread.interrupt();
try {
decodeThread.join(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
videoDecoder.close(); videoDecoder.close();
if (audioDecoder != null) { if (audioDecoder != null) {
audioDecoder.close(); audioDecoder.close();
@@ -64,6 +64,7 @@ public final class PlayerApp extends Application {
private final WritableImage[] videoImages = new WritableImage[2]; private final WritableImage[] videoImages = new WritableImage[2];
private final int[][] argbBuffers = new int[2][]; private final int[][] argbBuffers = new int[2][];
private int videoBufferIndex = 0; private int videoBufferIndex = 0;
private volatile boolean skipRequested = false;
@Override @Override
public void start(Stage stage) { public void start(Stage stage) {
@@ -114,6 +115,10 @@ public final class PlayerApp extends Application {
togglePause(); togglePause();
return; return;
} }
case S, TRACK_NEXT -> {
skipRequested = true;
return;
}
default -> { } default -> { }
} }
@@ -202,6 +207,10 @@ public final class PlayerApp extends Application {
PlaybackEngine.VideoFrame frame; PlaybackEngine.VideoFrame frame;
while ((frame = engine.nextVideoFrame()) != null) { while ((frame = engine.nextVideoFrame()) != null) {
if (skipRequested) {
skipRequested = false;
break;
}
int index = videoBufferIndex++ & 1; int index = videoBufferIndex++ & 1;
int[] argb = argbBuffers[index]; int[] argb = argbBuffers[index];
int pixelCount = frame.width() * frame.height(); int pixelCount = frame.width() * frame.height();