Play ATRAC3+ audio through the system audio line

The audio thread writes decoded PCM to a SourceDataLine and acts as the
master A/V clock; video frames are now paced against that shared clock so
audio and video stay in sync.
This commit is contained in:
2026-09-15 23:29:33 -04:00
parent 67b3c4a8cd
commit e035d0c118
@@ -29,6 +29,10 @@ import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color; import javafx.scene.paint.Color;
import javafx.stage.Stage; 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.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
@@ -45,6 +49,10 @@ public final class PlayerApp extends Application {
private volatile MoviePlayer moviePlayer; private volatile MoviePlayer moviePlayer;
private Stage stage; private Stage stage;
/** Master A/V clock anchor, set by the audio thread. */
private volatile long clockStartPts = -1;
private volatile long clockStartNanos = -1;
@Override @Override
public void start(Stage stage) { public void start(Stage stage) {
// Gradle's --args splits on whitespace; rejoin so paths with spaces work. // 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)); try (UmdIsoReader iso = new UmdIsoReader(Path.of(isoPath));
PlaybackEngine engine = PlaybackEngine.open(iso, streamName, 0)) { PlaybackEngine engine = PlaybackEngine.open(iso, streamName, 0)) {
long basePts = -1; startAudio(engine);
long baseNanos = -1; awaitClock();
long basePts = clockStartPts;
long baseNanos = clockStartNanos;
PlaybackEngine.VideoFrame frame; PlaybackEngine.VideoFrame frame;
while ((frame = engine.nextVideoFrame()) != null) { while ((frame = engine.nextVideoFrame()) != null) {
@@ -175,17 +185,8 @@ public final class PlayerApp extends Application {
frame.vPlane(), frame.vStride()); frame.vPlane(), frame.vStride());
blitVideo(argb, frame.width(), frame.height()); 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 targetNanos = baseNanos + (frame.pts() - basePts) * 1_000_000_000L / PlaybackEngine.MPEG_TIMESTAMP;
long sleepNanos = targetNanos - System.nanoTime(); sleepUntil(targetNanos);
if (sleepNanos > 0) {
long millis = sleepNanos / 1_000_000;
int nanos = (int) (sleepNanos % 1_000_000);
Thread.sleep(millis, nanos);
}
} }
MoviePlayer mp = moviePlayer; MoviePlayer mp = moviePlayer;
@@ -200,6 +201,67 @@ public final class PlayerApp extends Application {
player.start(); 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) { private void blitVideo(int[] argb, int width, int height) {
WritableImage image = new WritableImage(width, height); WritableImage image = new WritableImage(width, height);
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getIntArgbInstance(), argb, 0, width); image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getIntArgbInstance(), argb, 0, width);