diff --git a/ui/src/main/java/dev/quoije/umd/ui/PlayerApp.java b/ui/src/main/java/dev/quoije/umd/ui/PlayerApp.java index 56378c8..b89cf95 100644 --- a/ui/src/main/java/dev/quoije/umd/ui/PlayerApp.java +++ b/ui/src/main/java/dev/quoije/umd/ui/PlayerApp.java @@ -29,6 +29,10 @@ import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.stage.Stage; +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.SourceDataLine; + import java.io.IOException; import java.nio.file.Path; @@ -45,6 +49,10 @@ public final class PlayerApp extends Application { private volatile MoviePlayer moviePlayer; private Stage stage; + /** Master A/V clock anchor, set by the audio thread. */ + private volatile long clockStartPts = -1; + private volatile long clockStartNanos = -1; + @Override public void start(Stage stage) { // Gradle's --args splits on whitespace; rejoin so paths with spaces work. @@ -164,8 +172,10 @@ public final class PlayerApp extends Application { try (UmdIsoReader iso = new UmdIsoReader(Path.of(isoPath)); PlaybackEngine engine = PlaybackEngine.open(iso, streamName, 0)) { - long basePts = -1; - long baseNanos = -1; + startAudio(engine); + awaitClock(); + long basePts = clockStartPts; + long baseNanos = clockStartNanos; PlaybackEngine.VideoFrame frame; while ((frame = engine.nextVideoFrame()) != null) { @@ -175,17 +185,8 @@ public final class PlayerApp extends Application { frame.vPlane(), frame.vStride()); blitVideo(argb, frame.width(), frame.height()); - if (basePts < 0) { - basePts = frame.pts(); - baseNanos = System.nanoTime(); - } long targetNanos = baseNanos + (frame.pts() - basePts) * 1_000_000_000L / PlaybackEngine.MPEG_TIMESTAMP; - long sleepNanos = targetNanos - System.nanoTime(); - if (sleepNanos > 0) { - long millis = sleepNanos / 1_000_000; - int nanos = (int) (sleepNanos % 1_000_000); - Thread.sleep(millis, nanos); - } + sleepUntil(targetNanos); } MoviePlayer mp = moviePlayer; @@ -200,6 +201,67 @@ public final class PlayerApp extends Application { player.start(); } + /** Plays decoded PCM through the system audio line, acting as the master A/V clock. */ + private void startAudio(PlaybackEngine engine) { + Thread audio = new Thread(() -> { + SourceDataLine line = null; + try { + line = openLine(); + PlaybackEngine.AudioChunk chunk; + while ((chunk = engine.nextAudioChunk()) != null) { + if (clockStartPts < 0) { + clockStartPts = chunk.pts(); + clockStartNanos = System.nanoTime(); + } + long targetNanos = clockStartNanos + + (chunk.pts() - clockStartPts) * 1_000_000_000L / PlaybackEngine.MPEG_TIMESTAMP; + sleepUntil(targetNanos); + line.write(chunk.pcm(), 0, chunk.pcm().length); + } + line.drain(); + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (line != null) { + line.close(); + } + if (clockStartPts < 0) { + clockStartPts = 0; + clockStartNanos = System.nanoTime(); + } + } + }, "audio-player"); + audio.setDaemon(true); + audio.start(); + } + + private static SourceDataLine openLine() throws Exception { + AudioFormat format = new AudioFormat(PlaybackEngine.AUDIO_SAMPLE_RATE, 16, 2, true, false); + SourceDataLine line = AudioSystem.getSourceDataLine(format); + line.open(format, PlaybackEngine.AUDIO_SAMPLE_RATE * 4); + line.start(); + return line; + } + + /** Waits until the audio thread anchors the master clock, or falls back. */ + private void awaitClock() throws InterruptedException { + long deadline = System.nanoTime() + 5_000_000_000L; + while (clockStartPts < 0 && System.nanoTime() < deadline) { + Thread.sleep(2); + } + if (clockStartPts < 0) { + clockStartPts = 0; + clockStartNanos = System.nanoTime(); + } + } + + private static void sleepUntil(long targetNanos) throws InterruptedException { + long sleepNanos = targetNanos - System.nanoTime(); + if (sleepNanos > 0) { + Thread.sleep(sleepNanos / 1_000_000, (int) (sleepNanos % 1_000_000)); + } + } + private void blitVideo(int[] argb, int width, int height) { WritableImage image = new WritableImage(width, height); image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getIntArgbInstance(), argb, 0, width);