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
+99 -1
View File
@@ -3,7 +3,9 @@ package dev.quoije.umd.cli;
import dev.quoije.umd.iso.UmdIsoFile; import dev.quoije.umd.iso.UmdIsoFile;
import dev.quoije.umd.iso.UmdIsoReader; import dev.quoije.umd.iso.UmdIsoReader;
import dev.quoije.umd.media.Atrac3pDecoder;
import dev.quoije.umd.media.VideoDecoder; import dev.quoije.umd.media.VideoDecoder;
import dev.quoije.umd.mps.AudioFramer;
import dev.quoije.umd.mps.MpsDemuxer; import dev.quoije.umd.mps.MpsDemuxer;
import dev.quoije.umd.playlist.Playlist; import dev.quoije.umd.playlist.Playlist;
import dev.quoije.umd.sfo.Sfo; import dev.quoije.umd.sfo.Sfo;
@@ -17,7 +19,7 @@ import java.util.Map;
/** Command-line front-end for the UMD player library. */ /** Command-line front-end for the UMD player library. */
public final class Main { public final class Main {
private static final String USAGE = "usage: umdplayer dump <image.iso>\n umdplayer mps <image.iso> <streamName>\n umdplayer decode <image.iso> <streamName>"; private static final String USAGE = "usage: umdplayer dump <image.iso>\n umdplayer mps <image.iso> <streamName>\n umdplayer decode <image.iso> <streamName>\n umdplayer audio <image.iso> <streamName> [count]\n umdplayer adecode <image.iso> <streamName> [maxPackets]";
private Main() { private Main() {
} }
@@ -34,6 +36,8 @@ public final class Main {
case "dump" -> dump(requireArg(args, 1)); case "dump" -> dump(requireArg(args, 1));
case "mps" -> mps(requireArg(args, 1), requireArg(args, 2)); case "mps" -> mps(requireArg(args, 1), requireArg(args, 2));
case "decode" -> decode(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" -> { case "help", "--help", "-h" -> {
System.out.println(USAGE); System.out.println(USAGE);
yield 0; yield 0;
@@ -235,6 +239,100 @@ public final class Main {
return 0; 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) { private static String hexPrefix(byte[] data, int max) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (int i = 0; i < Math.min(data.length, max); i++) { for (int i = 0; i < Math.min(data.length, max); i++) {
@@ -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);
}
}