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:
@@ -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 <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() {
|
||||
}
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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