Add playback engine with A/V timestamping

- AnnexBSplitter incrementally splits the Annex-B stream into access
  units, holding partial slices across packet feeds.
- VideoDecoder exposes feedAccessUnit and the decoded frame rate.
- PlaybackEngine demuxes and decodes in lockstep, tagging video frames
  and audio chunks with 90 kHz PTS (audio from PES headers, video from
  PES anchors interpolated by decoder frame rate).
- CLI 'play' command runs the engine headlessly and reports A/V drift.
- Validated against Batman Begins: 95 frames in 4.0s, max drift -78.6 ms
  (video trails audio at stream start), avg frame duration 3833.6 ticks.
- Tests for AnnexBSplitter.
This commit is contained in:
2026-09-15 22:17:49 -04:00
parent 8267750207
commit 3768e50603
6 changed files with 440 additions and 52 deletions
+52 -1
View File
@@ -7,6 +7,7 @@ import dev.quoije.umd.media.Atrac3pDecoder;
import dev.quoije.umd.media.VideoDecoder;
import dev.quoije.umd.mps.AudioFramer;
import dev.quoije.umd.mps.MpsDemuxer;
import dev.quoije.umd.player.PlaybackEngine;
import dev.quoije.umd.playlist.Playlist;
import dev.quoije.umd.sfo.Sfo;
@@ -19,7 +20,7 @@ import java.util.Map;
/** Command-line front-end for the UMD player library. */
public final class Main {
private static final String USAGE = "usage: umdplayer dump <image.iso>\n umdplayer mps <image.iso> <streamName>\n umdplayer decode <image.iso> <streamName>\n umdplayer audio <image.iso> <streamName> [count]\n umdplayer adecode <image.iso> <streamName> [maxPackets]";
private static final String USAGE = "usage: umdplayer dump <image.iso>\n umdplayer mps <image.iso> <streamName>\n umdplayer decode <image.iso> <streamName>\n umdplayer audio <image.iso> <streamName> [count]\n umdplayer adecode <image.iso> <streamName> [maxPackets]\n umdplayer play <image.iso> <streamName> [seconds]";
private Main() {
}
@@ -38,6 +39,7 @@ public final class Main {
case "decode" -> decode(requireArg(args, 1), requireArg(args, 2));
case "audio" -> audio(requireArg(args, 1), requireArg(args, 2), args.length > 3 ? Integer.parseInt(args[3]) : 8);
case "adecode" -> adecode(requireArg(args, 1), requireArg(args, 2), args.length > 3 ? Integer.parseInt(args[3]) : 500);
case "play" -> play(requireArg(args, 1), requireArg(args, 2), args.length > 3 ? Double.parseDouble(args[3]) : 5.0);
case "help", "--help", "-h" -> {
System.out.println(USAGE);
yield 0;
@@ -333,6 +335,55 @@ public final class Main {
return 0;
}
private static int play(String imagePath, String streamName, double seconds) throws IOException {
long targetPts = (long) (seconds * PlaybackEngine.MPEG_TIMESTAMP);
try (UmdIsoReader iso = new UmdIsoReader(Path.of(imagePath));
PlaybackEngine engine = PlaybackEngine.open(iso, streamName, 0)) {
int frames = 0;
long firstPts = -1;
long lastPts = -1;
long audioClock = -1;
long maxDrift = 0;
PlaybackEngine.VideoFrame frame;
while ((frame = engine.nextVideoFrame()) != null) {
if (firstPts < 0) {
firstPts = frame.pts();
}
lastPts = frame.pts();
frames++;
// Advance the audio clock to at least this frame's timestamp.
while (audioClock < frame.pts()) {
PlaybackEngine.AudioChunk chunk = engine.nextAudioChunk();
if (chunk == null) {
break;
}
audioClock = chunk.pts() + PlaybackEngine.AUDIO_FRAME_TICKS;
}
long drift = frame.pts() - audioClock;
if (Math.abs(drift) > Math.abs(maxDrift)) {
maxDrift = drift;
}
if (frame.pts() >= targetPts) {
break;
}
}
double spanSeconds = (lastPts - firstPts) / (double) PlaybackEngine.MPEG_TIMESTAMP;
double frameDuration = frames > 1 ? (lastPts - firstPts) / (double) (frames - 1) : 0;
System.out.printf("video: %d frames, pts %d..%d (%.2fs), avg frame duration %.2f ticks%n",
frames, firstPts, lastPts, spanSeconds, frameDuration);
System.out.printf("audio clock: %d (%.2fs), max |video-audio| drift: %d ticks (%.1f ms)%n",
audioClock, audioClock / (double) PlaybackEngine.MPEG_TIMESTAMP,
maxDrift, maxDrift * 1000.0 / PlaybackEngine.MPEG_TIMESTAMP);
}
return 0;
}
private static String hexPrefix(byte[] data, int max) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < Math.min(data.length, max); i++) {