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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user