From 826775020753bb73e7c732702146d3920774628e Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Tue, 15 Sep 2026 22:12:15 -0400 Subject: [PATCH] Add ATRAC3+ audio decode via FFmpeg - AudioFramer splits concatenated audio payloads into 696-byte frames (8-byte PSP header + 688-byte ATRAC3+ data), spanning PES packets. - Atrac3pDecoder decodes raw ATRAC3+ via JavaCPP FFmpeg, converting FLTP output to interleaved S16 PCM. - CLI 'adecode' command decodes both audio substreams (each a full stereo track at 48kHz) and reports stats. - Validated against Batman Begins: 69 frames/channel, peak ~4900, ~64% nonzero samples. --- .../main/java/dev/quoije/umd/cli/Main.java | 100 +++++++++- .../dev/quoije/umd/media/Atrac3pDecoder.java | 187 ++++++++++++++++++ .../java/dev/quoije/umd/mps/AudioFramer.java | 60 ++++++ .../dev/quoije/umd/mps/AudioFramerTest.java | 65 ++++++ 4 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/dev/quoije/umd/media/Atrac3pDecoder.java create mode 100644 core/src/main/java/dev/quoije/umd/mps/AudioFramer.java create mode 100644 core/src/test/java/dev/quoije/umd/mps/AudioFramerTest.java diff --git a/cli/src/main/java/dev/quoije/umd/cli/Main.java b/cli/src/main/java/dev/quoije/umd/cli/Main.java index b4c224a..aa6572a 100644 --- a/cli/src/main/java/dev/quoije/umd/cli/Main.java +++ b/cli/src/main/java/dev/quoije/umd/cli/Main.java @@ -3,7 +3,9 @@ package dev.quoije.umd.cli; import dev.quoije.umd.iso.UmdIsoFile; import dev.quoije.umd.iso.UmdIsoReader; +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.playlist.Playlist; import dev.quoije.umd.sfo.Sfo; @@ -17,7 +19,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 \n umdplayer mps \n umdplayer decode "; + private static final String USAGE = "usage: umdplayer dump \n umdplayer mps \n umdplayer decode \n umdplayer audio [count]\n umdplayer adecode [maxPackets]"; private Main() { } @@ -34,6 +36,8 @@ public final class Main { case "dump" -> dump(requireArg(args, 1)); case "mps" -> mps(requireArg(args, 1), requireArg(args, 2)); 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 "help", "--help", "-h" -> { System.out.println(USAGE); yield 0; @@ -235,6 +239,100 @@ public final class Main { return 0; } + private static int audio(String imagePath, String streamName, int count) throws IOException { + try (UmdIsoReader iso = new UmdIsoReader(Path.of(imagePath)); + MpsDemuxer demuxer = MpsDemuxer.open(iso, streamName)) { + int seen = 0; + while (seen < count) { + MpsDemuxer.Packet packet = demuxer.nextPacket(); + if (packet == null) { + break; + } + if (!packet.isAudio()) { + continue; + } + seen++; + byte[] payload = packet.payload(); + System.out.printf("audio pkt %d: ch=%d pts=%d len=%d%n", + seen, packet.channel(), packet.pts(), payload.length); + System.out.println(" " + hexPrefix(payload, 32)); + } + } + return 0; + } + + private static int adecode(String imagePath, String streamName, int maxPackets) throws IOException { + try (UmdIsoReader iso = new UmdIsoReader(Path.of(imagePath)); + MpsDemuxer demuxer = MpsDemuxer.open(iso, streamName)) { + + AudioFramer[] framers = {new AudioFramer(), new AudioFramer()}; + Atrac3pDecoder[] decoders = { + new Atrac3pDecoder(48000, 2, 688), + new Atrac3pDecoder(48000, 2, 688), + }; + try { + int[] framesFed = new int[2]; + int[] framesDecoded = new int[2]; + int[] totalSamples = new int[2]; + long[] checksum = new long[2]; + int[] peak = new int[2]; + int[] nonzero = new int[2]; + boolean[] first = {true, true}; + int packets = 0; + + while (packets < maxPackets) { + MpsDemuxer.Packet pkt = demuxer.nextPacket(); + if (pkt == null) { + break; + } + packets++; + if (!pkt.isAudio() || pkt.channel() < 0 || pkt.channel() > 1) { + continue; + } + int ch = pkt.channel(); + framers[ch].feed(pkt.payload()); + byte[] frame; + while ((frame = framers[ch].nextFrame()) != null) { + decoders[ch].feedFrame(frame); + framesFed[ch]++; + } + } + + for (int ch = 0; ch < 2; ch++) { + decoders[ch].flush(); + byte[] pcm; + while ((pcm = decoders[ch].receive()) != null) { + int interleavedSamples = pcm.length / 2; + totalSamples[ch] += interleavedSamples; + framesDecoded[ch]++; + for (int i = 0; i < pcm.length; i += 2) { + int s = (pcm[i] & 0xFF) | (pcm[i + 1] << 8); + int abs = Math.abs(s); + if (abs > peak[ch]) { + peak[ch] = abs; + } + if (s != 0) { + nonzero[ch]++; + } + if (first[ch]) { + checksum[ch] = checksum[ch] * 31 + s; + } + } + first[ch] = false; + } + System.out.printf("ch%d: %d frames fed, %d frames decoded, %d interleaved samples, first-frame checksum=%d, peak=%d, nonzero=%d%n", + ch, framesFed[ch], framesDecoded[ch], totalSamples[ch], checksum[ch], peak[ch], nonzero[ch]); + } + System.out.printf("Processed %d packets (%d audio frames total)%n", + packets, framesFed[0] + framesFed[1]); + } finally { + decoders[0].close(); + decoders[1].close(); + } + } + 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++) { diff --git a/core/src/main/java/dev/quoije/umd/media/Atrac3pDecoder.java b/core/src/main/java/dev/quoije/umd/media/Atrac3pDecoder.java new file mode 100644 index 0000000..12683de --- /dev/null +++ b/core/src/main/java/dev/quoije/umd/media/Atrac3pDecoder.java @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.quoije.umd.media; + +import org.bytedeco.ffmpeg.avcodec.AVCodec; +import org.bytedeco.ffmpeg.avcodec.AVCodecContext; +import org.bytedeco.ffmpeg.avcodec.AVPacket; +import org.bytedeco.ffmpeg.avutil.AVChannelLayout; +import org.bytedeco.ffmpeg.avutil.AVFrame; +import org.bytedeco.ffmpeg.global.avcodec; +import org.bytedeco.ffmpeg.global.avutil; +import org.bytedeco.javacpp.FloatPointer; +import org.bytedeco.javacpp.PointerPointer; +import org.bytedeco.javacpp.ShortPointer; + +import java.util.ArrayDeque; + +/** + * ATRAC3+ audio decoder backed by FFmpeg. + * + *

Decodes raw ATRAC3+ frames (the 688-byte payload following the 8-byte + * PSP frame header) into interleaved signed 16-bit PCM. The sample rate and + * channel count are not carried in the bitstream and must be supplied.

+ */ +public final class Atrac3pDecoder implements AutoCloseable { + + /** Number of PCM samples per ATRAC3+ frame. */ + public static final int FRAME_SAMPLES = 2048; + + private final AVCodecContext context; + private final AVPacket packet; + private final AVFrame frame; + private final AVChannelLayout channelLayout; + private final ArrayDeque pcmChunks = new ArrayDeque<>(); + private final int channels; + + public Atrac3pDecoder(int sampleRate, int channels, int blockAlign) { + this.channels = channels; + + AVCodec codec = avcodec.avcodec_find_decoder(avcodec.AV_CODEC_ID_ATRAC3P); + if (codec == null || codec.isNull()) { + throw new IllegalStateException("ATRAC3+ decoder not available"); + } + + context = avcodec.avcodec_alloc_context3(codec); + if (context == null || context.isNull()) { + throw new IllegalStateException("Could not allocate codec context"); + } + + context.sample_rate(sampleRate); + context.block_align(blockAlign); + + channelLayout = new AVChannelLayout(); + avutil.av_channel_layout_default(channelLayout, channels); + context.ch_layout(channelLayout); + + int ret = avcodec.avcodec_open2(context, codec, (PointerPointer) null); + if (ret < 0) { + throw new IllegalStateException("avcodec_open2 failed: " + ret); + } + + packet = avcodec.av_packet_alloc(); + frame = avutil.av_frame_alloc(); + } + + /** Decodes one raw ATRAC3+ frame. */ + public void feedFrame(byte[] atrac3Data) { + if (avcodec.av_new_packet(packet, atrac3Data.length) < 0) { + throw new IllegalStateException("av_new_packet failed"); + } + packet.data().put(atrac3Data); + + int ret = avcodec.avcodec_send_packet(context, packet); + avcodec.av_packet_unref(packet); + if (ret < 0) { + throw new IllegalStateException("avcodec_send_packet failed: " + ret); + } + drain(); + } + + /** Flushes any buffered frames. */ + public void flush() { + avcodec.avcodec_send_packet(context, null); + drain(); + } + + private void drain() { + while (true) { + int ret = avcodec.avcodec_receive_frame(context, frame); + if (ret < 0) { + break; + } + pcmChunks.add(toInterleavedS16(frame)); + } + } + + /** Returns the next chunk of interleaved S16 PCM, or {@code null}. */ + public byte[] receive() { + return pcmChunks.poll(); + } + + public int channels() { + return channels; + } + + private byte[] toInterleavedS16(AVFrame f) { + int nbSamples = f.nb_samples(); + int ch = f.ch_layout().nb_channels(); + if (ch <= 0) { + ch = channels; + } + + byte[] out = new byte[nbSamples * ch * 2]; + int fmt = f.format(); + int o = 0; + + if (fmt == avutil.AV_SAMPLE_FMT_FLTP) { + float[][] planes = readFloatPlanes(f, ch, nbSamples); + for (int i = 0; i < nbSamples; i++) { + for (int c = 0; c < ch; c++) { + o = writeS16(out, o, floatToS16(planes[c][i])); + } + } + } else if (fmt == avutil.AV_SAMPLE_FMT_S16P) { + short[][] planes = readShortPlanes(f, ch, nbSamples); + for (int i = 0; i < nbSamples; i++) { + for (int c = 0; c < ch; c++) { + o = writeS16(out, o, planes[c][i]); + } + } + } else if (fmt == avutil.AV_SAMPLE_FMT_S16) { + ShortPointer sp = new ShortPointer(f.extended_data(0)); + for (int i = 0; i < nbSamples * ch; i++) { + o = writeS16(out, o, sp.get(i)); + } + } else { + throw new IllegalStateException("Unsupported sample format: " + fmt); + } + + return out; + } + + private static int writeS16(byte[] out, int offset, short value) { + out[offset++] = (byte) (value & 0xFF); + out[offset++] = (byte) ((value >> 8) & 0xFF); + return offset; + } + + private static float[][] readFloatPlanes(AVFrame f, int ch, int nbSamples) { + float[][] planes = new float[ch][nbSamples]; + for (int c = 0; c < ch; c++) { + FloatPointer fp = new FloatPointer(f.extended_data(c)); + for (int i = 0; i < nbSamples; i++) { + planes[c][i] = fp.get(i); + } + } + return planes; + } + + private static short[][] readShortPlanes(AVFrame f, int ch, int nbSamples) { + short[][] planes = new short[ch][nbSamples]; + for (int c = 0; c < ch; c++) { + ShortPointer sp = new ShortPointer(f.extended_data(c)); + for (int i = 0; i < nbSamples; i++) { + planes[c][i] = sp.get(i); + } + } + return planes; + } + + private static short floatToS16(float value) { + int i = Math.round(value * 32768.0f); + if (i > 32767) { + i = 32767; + } else if (i < -32768) { + i = -32768; + } + return (short) i; + } + + @Override + public void close() { + avutil.av_frame_free(frame); + avcodec.av_packet_free(packet); + avcodec.avcodec_free_context(context); + avutil.av_channel_layout_uninit(channelLayout); + } +} diff --git a/core/src/main/java/dev/quoije/umd/mps/AudioFramer.java b/core/src/main/java/dev/quoije/umd/mps/AudioFramer.java new file mode 100644 index 0000000..c815b92 --- /dev/null +++ b/core/src/main/java/dev/quoije/umd/mps/AudioFramer.java @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.quoije.umd.mps; + +import java.util.Arrays; + +/** + * Extracts ATRAC3+ frames from the concatenated audio payloads of a UMD + * {@code .MPS} stream. + * + *

Each frame is 696 bytes on disc: an 8-byte PSP header + * ({@code 0F D0 XX YY 00 00 00 00}) followed by 688 bytes of ATRAC3+ data. + * The frame data length is encoded in header bytes 2-3 as + * {@code ((header23 & 0x3FF) << 3) + 8}. Frames may span PES packet + * boundaries, so this class buffers across {@link #feed} calls.

+ */ +public final class AudioFramer { + + public static final int FRAME_HEADER_SIZE = 8; + public static final int HEADER_MAGIC_0 = 0x0F; + public static final int HEADER_MAGIC_1 = 0xD0; + + private byte[] buffer = new byte[0]; + + /** Appends the raw payload of one audio PES packet. */ + public void feed(byte[] data) { + if (data == null || data.length == 0) { + return; + } + byte[] merged = Arrays.copyOf(buffer, buffer.length + data.length); + System.arraycopy(data, 0, merged, buffer.length, data.length); + buffer = merged; + } + + /** + * Returns the next complete ATRAC3+ frame (without the 8-byte PSP + * header), or {@code null} if more data is needed. Leading bytes that are + * not part of a frame header (e.g. packet padding) are discarded. + */ + public byte[] nextFrame() { + while (buffer.length >= FRAME_HEADER_SIZE) { + if ((buffer[0] & 0xFF) != HEADER_MAGIC_0 || (buffer[1] & 0xFF) != HEADER_MAGIC_1) { + buffer = Arrays.copyOfRange(buffer, 1, buffer.length); + continue; + } + + int frameHeader23 = ((buffer[2] & 0xFF) << 8) | (buffer[3] & 0xFF); + int dataLength = ((frameHeader23 & 0x3FF) << 3) + FRAME_HEADER_SIZE; + int totalLength = FRAME_HEADER_SIZE + dataLength; + + if (buffer.length < totalLength) { + return null; + } + + byte[] frame = Arrays.copyOfRange(buffer, FRAME_HEADER_SIZE, totalLength); + buffer = Arrays.copyOfRange(buffer, totalLength, buffer.length); + return frame; + } + return null; + } +} diff --git a/core/src/test/java/dev/quoije/umd/mps/AudioFramerTest.java b/core/src/test/java/dev/quoije/umd/mps/AudioFramerTest.java new file mode 100644 index 0000000..00df165 --- /dev/null +++ b/core/src/test/java/dev/quoije/umd/mps/AudioFramerTest.java @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.quoije.umd.mps; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class AudioFramerTest { + + @Test + void splitsFramesAcrossFeedCalls() { + byte[] frame0 = buildFrame(0x00); + byte[] frame1 = buildFrame(0x11); + + AudioFramer framer = new AudioFramer(); + + // First frame header arrives in two pieces. + framer.feed(Arrays.copyOfRange(frame0, 0, 4)); + assertThat(framer.nextFrame()).isNull(); + + // Rest of frame 0 plus the first 100 bytes of frame 1. + byte[] middle = new byte[frame0.length - 4 + 100]; + System.arraycopy(frame0, 4, middle, 0, frame0.length - 4); + System.arraycopy(frame1, 0, middle, frame0.length - 4, 100); + framer.feed(middle); + + assertThat(framer.nextFrame()).isEqualTo(frameData(frame0)); + + // Feed the remainder of frame 1. + framer.feed(Arrays.copyOfRange(frame1, 100, frame1.length)); + assertThat(framer.nextFrame()).isEqualTo(frameData(frame1)); + assertThat(framer.nextFrame()).isNull(); + } + + @Test + void skipsLeadingPadding() { + byte[] frame = buildFrame(0x22); + AudioFramer framer = new AudioFramer(); + + byte[] padded = new byte[5 + frame.length]; + Arrays.fill(padded, 0, 5, (byte) 0x01); + System.arraycopy(frame, 0, padded, 5, frame.length); + framer.feed(padded); + + assertThat(framer.nextFrame()).isEqualTo(frameData(frame)); + assertThat(framer.nextFrame()).isNull(); + } + + /** Builds a 696-byte frame: 8-byte PSP header + 688-byte data. */ + private static byte[] buildFrame(int fill) { + byte[] frame = new byte[696]; + frame[0] = (byte) 0x0F; + frame[1] = (byte) 0xD0; + frame[2] = 0x48; // header23 = 0x4855 -> data length 688 + frame[3] = 0x55; + Arrays.fill(frame, 8, 696, (byte) fill); + return frame; + } + + private static byte[] frameData(byte[] frame) { + return Arrays.copyOfRange(frame, 8, 696); + } +}