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.
This commit is contained in:
2026-09-15 22:12:15 -04:00
parent 4821267d0e
commit 8267750207
4 changed files with 411 additions and 1 deletions
@@ -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.
*
* <p>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.</p>
*/
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<byte[]> 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);
}
}
@@ -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.
*
* <p>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.</p>
*/
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;
}
}
@@ -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);
}
}