mirror of
https://github.com/hrydgard/ppsspp.git
synced 2025-04-02 11:01:50 -04:00
Break out AtracTrack into its own file. Add a little atrac analysis tool to ImDebugger (for future use)
This commit is contained in:
parent
24d859f773
commit
e70dd3b2df
21 changed files with 571 additions and 443 deletions
|
@ -2413,6 +2413,8 @@ add_library(${CoreLibName} ${CoreLinkType}
|
|||
Core/System.h
|
||||
Core/ThreadPools.cpp
|
||||
Core/ThreadPools.h
|
||||
Core/Util/AtracTrack.cpp
|
||||
Core/Util/AtracTrack.h
|
||||
Core/Util/AudioFormat.cpp
|
||||
Core/Util/AudioFormat.h
|
||||
Core/Util/GameManager.cpp
|
||||
|
|
|
@ -104,6 +104,7 @@ enum class BrowseFileType {
|
|||
SOUND_EFFECT,
|
||||
ZIP,
|
||||
SYMBOL_MAP,
|
||||
ATRAC3,
|
||||
ANY,
|
||||
};
|
||||
|
||||
|
|
|
@ -1086,6 +1086,7 @@
|
|||
<ClCompile Include="System.cpp" />
|
||||
<ClCompile Include="ThreadPools.cpp" />
|
||||
<ClCompile Include="TiltEventProcessor.cpp" />
|
||||
<ClCompile Include="Util\AtracTrack.cpp" />
|
||||
<ClCompile Include="Util\AudioFormat.cpp" />
|
||||
<ClCompile Include="Util\BlockAllocator.cpp" />
|
||||
<ClCompile Include="Util\DisArm64.cpp" />
|
||||
|
@ -1471,6 +1472,7 @@
|
|||
<ClInclude Include="ThreadEventQueue.h" />
|
||||
<ClInclude Include="ThreadPools.h" />
|
||||
<ClInclude Include="TiltEventProcessor.h" />
|
||||
<ClInclude Include="Util\AtracTrack.h" />
|
||||
<ClInclude Include="Util\AudioFormat.h" />
|
||||
<ClInclude Include="Util\BlockAllocator.h" />
|
||||
<ClInclude Include="Util\DisArm64.h" />
|
||||
|
|
|
@ -1342,6 +1342,9 @@
|
|||
<ClCompile Include="HLE\sceAac.cpp">
|
||||
<Filter>HLE\Libraries</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="Util\AtracTrack.cpp">
|
||||
<Filter>Util</Filter>
|
||||
</ClCompile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="ELF\ElfReader.h">
|
||||
|
@ -2166,6 +2169,9 @@
|
|||
<ClInclude Include="HLE\ErrorCodes.h">
|
||||
<Filter>HLE</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="Util\AtracTrack.h">
|
||||
<Filter>Util</Filter>
|
||||
</ClInclude>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="..\LICENSE.TXT" />
|
||||
|
|
|
@ -32,13 +32,6 @@
|
|||
|
||||
const size_t overAllocBytes = 16384;
|
||||
|
||||
const int RIFF_CHUNK_MAGIC = 0x46464952;
|
||||
const int RIFF_WAVE_MAGIC = 0x45564157;
|
||||
const int FMT_CHUNK_MAGIC = 0x20746D66;
|
||||
const int DATA_CHUNK_MAGIC = 0x61746164;
|
||||
const int SMPL_CHUNK_MAGIC = 0x6C706D73;
|
||||
const int FACT_CHUNK_MAGIC = 0x74636166;
|
||||
|
||||
Atrac::~Atrac() {
|
||||
ResetData();
|
||||
}
|
||||
|
@ -238,284 +231,6 @@ void Track::DebugLog() const {
|
|||
DEBUG_LOG(Log::ME, "sampleSize: %d (%03x", bytesPerFrame, bytesPerFrame);
|
||||
}
|
||||
|
||||
int AnalyzeAtracTrack(u32 addr, u32 size, Track *track) {
|
||||
// 72 is about the size of the minimum required data to even be valid.
|
||||
|
||||
// TODO: Validate stuff more.
|
||||
if (Memory::ReadUnchecked_U32(addr) != RIFF_CHUNK_MAGIC) {
|
||||
ERROR_LOG(Log::ME, "Couldn't find RIFF header");
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
|
||||
struct RIFFFmtChunk {
|
||||
u16_le fmtTag;
|
||||
u16_le channels;
|
||||
u32_le samplerate;
|
||||
u32_le avgBytesPerSec;
|
||||
u16_le blockAlign;
|
||||
};
|
||||
|
||||
u32 offset = 8;
|
||||
track->firstSampleOffset = 0;
|
||||
|
||||
while (Memory::Read_U32(addr + offset) != RIFF_WAVE_MAGIC) {
|
||||
// Get the size preceding the magic.
|
||||
int chunk = Memory::Read_U32(addr + offset - 4);
|
||||
// Round the chunk size up to the nearest 2.
|
||||
offset += chunk + (chunk & 1);
|
||||
if (offset + 12 > size) {
|
||||
ERROR_LOG(Log::ME, "AnalyzeAtracTrack(%08x, %d): too small for WAVE chunk at offset %d", addr, size, offset);
|
||||
return SCE_ERROR_ATRAC_SIZE_TOO_SMALL;
|
||||
}
|
||||
if (Memory::Read_U32(addr + offset) != RIFF_CHUNK_MAGIC) {
|
||||
ERROR_LOG(Log::ME, "AnalyzeAtracTrack(%08x, %d): RIFF chunk did not contain WAVE", addr, size);
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
offset += 8;
|
||||
}
|
||||
offset += 4;
|
||||
|
||||
if (offset != 12) {
|
||||
WARN_LOG(Log::ME, "RIFF chunk at offset: %d", offset);
|
||||
}
|
||||
|
||||
// RIFF size excluding chunk header.
|
||||
track->fileSize = Memory::Read_U32(addr + offset - 8) + 8;
|
||||
|
||||
// Even if the RIFF size is too low, it may simply be incorrect. This works on real firmware.
|
||||
u32 maxSize = std::max(track->fileSize, size);
|
||||
|
||||
bool bfoundData = false;
|
||||
u32 dataChunkSize = 0;
|
||||
int sampleOffsetAdjust = 0;
|
||||
|
||||
while (maxSize >= offset + 8 && !bfoundData) {
|
||||
int chunkMagic = Memory::Read_U32(addr + offset);
|
||||
u32 chunkSize = Memory::Read_U32(addr + offset + 4);
|
||||
// Account for odd sized chunks.
|
||||
if (chunkSize & 1) {
|
||||
WARN_LOG_REPORT_ONCE(oddchunk, Log::ME, "RIFF chunk had uneven size");
|
||||
}
|
||||
chunkSize += (chunkSize & 1);
|
||||
offset += 8;
|
||||
if (chunkSize > maxSize - offset)
|
||||
break;
|
||||
switch (chunkMagic) {
|
||||
case FMT_CHUNK_MAGIC:
|
||||
{
|
||||
if (track->codecType != 0) {
|
||||
ERROR_LOG(Log::ME, "AnalyzeTrack: multiple fmt chunks is not valid");
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
|
||||
auto at3fmt = PSPPointer<const RIFFFmtChunk>::Create(addr + offset);
|
||||
if (chunkSize < 32 || (at3fmt->fmtTag == AT3_PLUS_MAGIC && chunkSize < 52)) {
|
||||
ERROR_LOG(Log::ME, "AnalyzeTrack: fmt definition too small(%d)", chunkSize);
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
|
||||
if (at3fmt->fmtTag == AT3_MAGIC)
|
||||
track->codecType = PSP_MODE_AT_3;
|
||||
else if (at3fmt->fmtTag == AT3_PLUS_MAGIC)
|
||||
track->codecType = PSP_MODE_AT_3_PLUS;
|
||||
else {
|
||||
ERROR_LOG(Log::ME, "AnalyzeTrack: invalid fmt magic: %04x", at3fmt->fmtTag);
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
track->channels = at3fmt->channels;
|
||||
if (track->channels != 1 && track->channels != 2) {
|
||||
ERROR_LOG_REPORT(Log::ME, "AnalyzeTrack: unsupported channel count %d", track->channels);
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
if (at3fmt->samplerate != 44100) {
|
||||
ERROR_LOG_REPORT(Log::ME, "AnalyzeTrack: unsupported sample rate %d", at3fmt->samplerate);
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
track->bitrate = at3fmt->avgBytesPerSec * 8;
|
||||
track->bytesPerFrame = at3fmt->blockAlign;
|
||||
if (track->bytesPerFrame == 0) {
|
||||
ERROR_LOG_REPORT(Log::ME, "invalid bytes per frame: %d", track->bytesPerFrame);
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
|
||||
// TODO: There are some format specific bytes here which seem to have fixed values?
|
||||
// Probably don't need them.
|
||||
|
||||
if (at3fmt->fmtTag == AT3_MAGIC) {
|
||||
// This is the offset to the jointStereo_ field.
|
||||
track->jointStereo = Memory::Read_U16(addr + offset + 24);
|
||||
// Then there are more fields here.
|
||||
u16 unknown1_2 = Memory::Read_U16(addr + offset + 30);
|
||||
|
||||
}
|
||||
if (chunkSize > 16) {
|
||||
// Read and format extra bytes as hexadecimal
|
||||
std::string hex;
|
||||
DataToHexString(Memory::GetPointer(addr + offset + 16), chunkSize - 16, &hex, false);
|
||||
DEBUG_LOG(Log::ME, "Additional chunk data (beyond 16 bytes): %s", hex.c_str());
|
||||
}
|
||||
break;
|
||||
}
|
||||
case FACT_CHUNK_MAGIC:
|
||||
{
|
||||
track->endSample = Memory::Read_U32(addr + offset);
|
||||
if (chunkSize >= 8) {
|
||||
track->firstSampleOffset = Memory::Read_U32(addr + offset + 4);
|
||||
}
|
||||
if (chunkSize >= 12) {
|
||||
u32 largerOffset = Memory::Read_U32(addr + offset + 8);
|
||||
// Works, but "largerOffset"??
|
||||
sampleOffsetAdjust = track->firstSampleOffset - largerOffset;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SMPL_CHUNK_MAGIC:
|
||||
{
|
||||
if (chunkSize < 32) {
|
||||
ERROR_LOG(Log::ME, "AnalyzeTrack: smpl chunk too small (%d)", chunkSize);
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
int checkNumLoops = Memory::Read_U32(addr + offset + 28);
|
||||
if (checkNumLoops != 0 && chunkSize < 36 + 20) {
|
||||
ERROR_LOG(Log::ME, "AnalyzeTrack: smpl chunk too small for loop (%d, %d)", checkNumLoops, chunkSize);
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
if (checkNumLoops < 0) {
|
||||
ERROR_LOG(Log::ME, "bad checkNumLoops (%d)", checkNumLoops);
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
|
||||
track->loopinfo.resize(checkNumLoops);
|
||||
u32 loopinfoAddr = addr + offset + 36;
|
||||
// The PSP only cares about the first loop start and end, it seems.
|
||||
// Most likely can skip the rest of this data, but it's not hurting anyone.
|
||||
for (int i = 0; i < checkNumLoops && 36 + (u32)i < chunkSize; i++, loopinfoAddr += 24) {
|
||||
track->loopinfo[i].cuePointID = Memory::Read_U32(loopinfoAddr);
|
||||
track->loopinfo[i].type = Memory::Read_U32(loopinfoAddr + 4);
|
||||
track->loopinfo[i].startSample = Memory::Read_U32(loopinfoAddr + 8);
|
||||
track->loopinfo[i].endSample = Memory::Read_U32(loopinfoAddr + 12);
|
||||
track->loopinfo[i].fraction = Memory::Read_U32(loopinfoAddr + 16);
|
||||
track->loopinfo[i].playCount = Memory::Read_U32(loopinfoAddr + 20);
|
||||
|
||||
if (i == 0 && track->loopinfo[i].startSample >= track->loopinfo[i].endSample) {
|
||||
ERROR_LOG(Log::ME, "AnalyzeTrack: loop starts after it ends");
|
||||
return SCE_ERROR_ATRAC_BAD_CODEC_PARAMS;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DATA_CHUNK_MAGIC:
|
||||
{
|
||||
bfoundData = true;
|
||||
track->dataByteOffset = offset;
|
||||
dataChunkSize = chunkSize;
|
||||
if (track->fileSize < offset + chunkSize) {
|
||||
WARN_LOG_REPORT(Log::ME, "Atrac data chunk extends beyond riff chunk");
|
||||
track->fileSize = offset + chunkSize;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
offset += chunkSize;
|
||||
}
|
||||
|
||||
if (track->codecType == 0) {
|
||||
ERROR_LOG(Log::ME, "AnalyzeTrack: Could not detect codec");
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
|
||||
if (!bfoundData) {
|
||||
ERROR_LOG(Log::ME, "AnalyzeTrack: No data chunk found");
|
||||
return SCE_ERROR_ATRAC_SIZE_TOO_SMALL;
|
||||
}
|
||||
|
||||
// set the loopStartSample_ and loopEndSample_ by loopinfo_
|
||||
if (track->loopinfo.size() > 0) {
|
||||
track->loopStartSample = track->loopinfo[0].startSample + track->FirstOffsetExtra() + sampleOffsetAdjust;
|
||||
track->loopEndSample = track->loopinfo[0].endSample + track->FirstOffsetExtra() + sampleOffsetAdjust;
|
||||
} else {
|
||||
track->loopStartSample = -1;
|
||||
track->loopEndSample = -1;
|
||||
}
|
||||
|
||||
// if there is no correct endsample, try to guess it
|
||||
if (track->endSample <= 0 && track->bytesPerFrame != 0) {
|
||||
track->endSample = (dataChunkSize / track->bytesPerFrame) * track->SamplesPerFrame();
|
||||
track->endSample -= track->FirstSampleOffsetFull();
|
||||
}
|
||||
track->endSample -= 1;
|
||||
|
||||
if (track->loopEndSample != -1 && track->loopEndSample > track->endSample + track->FirstSampleOffsetFull()) {
|
||||
ERROR_LOG(Log::ME, "AnalyzeTrack: loop after end of data");
|
||||
return SCE_ERROR_ATRAC_BAD_CODEC_PARAMS;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int AnalyzeAA3Track(u32 addr, u32 size, u32 fileSize, Track *track) {
|
||||
if (size < 10) {
|
||||
return SCE_ERROR_ATRAC_AA3_SIZE_TOO_SMALL;
|
||||
}
|
||||
// TODO: Make sure this validation is correct, more testing.
|
||||
|
||||
const u8 *buffer = Memory::GetPointer(addr);
|
||||
if (buffer[0] != 'e' || buffer[1] != 'a' || buffer[2] != '3') {
|
||||
return SCE_ERROR_ATRAC_AA3_INVALID_DATA;
|
||||
}
|
||||
|
||||
// It starts with an id3 header (replaced with ea3.) This is the size.
|
||||
u32 tagSize = buffer[9] | (buffer[8] << 7) | (buffer[7] << 14) | (buffer[6] << 21);
|
||||
if (size < tagSize + 36) {
|
||||
return SCE_ERROR_ATRAC_AA3_SIZE_TOO_SMALL;
|
||||
}
|
||||
|
||||
// EA3 header starts at id3 header (10) + tagSize.
|
||||
buffer = Memory::GetPointer(addr + 10 + tagSize);
|
||||
if (buffer[0] != 'E' || buffer[1] != 'A' || buffer[2] != '3') {
|
||||
ERROR_LOG(Log::ME, "AnalyzeAA3Track: Invalid EA3 magic bytes");
|
||||
return SCE_ERROR_ATRAC_AA3_INVALID_DATA;
|
||||
}
|
||||
|
||||
track->fileSize = fileSize;
|
||||
|
||||
// Based on FFmpeg's code.
|
||||
u32 codecParams = buffer[33] | (buffer[34] << 8) | (buffer[35] << 16);
|
||||
const u32 at3SampleRates[8] = { 32000, 44100, 48000, 88200, 96000, 0 };
|
||||
|
||||
switch (buffer[32]) {
|
||||
case 0:
|
||||
track->codecType = PSP_MODE_AT_3;
|
||||
track->bytesPerFrame = (codecParams & 0x03FF) * 8;
|
||||
track->bitrate = at3SampleRates[(codecParams >> 13) & 7] * track->bytesPerFrame * 8 / 1024;
|
||||
track->channels = 2;
|
||||
track->jointStereo = (codecParams >> 17) & 1;
|
||||
break;
|
||||
case 1:
|
||||
track->codecType = PSP_MODE_AT_3_PLUS;
|
||||
track->bytesPerFrame = ((codecParams & 0x03FF) * 8) + 8;
|
||||
track->bitrate = at3SampleRates[(codecParams >> 13) & 7] * track->bytesPerFrame * 8 / 2048;
|
||||
track->channels = (codecParams >> 10) & 7;
|
||||
break;
|
||||
case 3:
|
||||
case 4:
|
||||
case 5:
|
||||
ERROR_LOG(Log::ME, "AnalyzeAA3Track: unsupported codec type %d", buffer[32]);
|
||||
return SCE_ERROR_ATRAC_AA3_INVALID_DATA;
|
||||
default:
|
||||
ERROR_LOG(Log::ME, "AnalyzeAA3Track: invalid codec type %d", buffer[32]);
|
||||
return SCE_ERROR_ATRAC_AA3_INVALID_DATA;
|
||||
}
|
||||
|
||||
track->dataByteOffset = 10 + tagSize + 96;
|
||||
track->firstSampleOffset = 0;
|
||||
if (track->endSample < 0 && track->bytesPerFrame != 0) {
|
||||
track->endSample = ((track->fileSize - track->dataByteOffset) / track->bytesPerFrame) * track->SamplesPerFrame();
|
||||
}
|
||||
track->endSample -= 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int Atrac::GetSoundSample(int *endSample, int *loopStartSample, int *loopEndSample) const {
|
||||
*endSample = track_.endSample;
|
||||
*loopStartSample = track_.loopStartSample == -1 ? -1 : track_.loopStartSample - track_.FirstSampleOffsetFull();
|
||||
|
|
|
@ -37,10 +37,6 @@ struct AtracResetBufferInfo {
|
|||
AtracSingleResetBufferInfo second;
|
||||
};
|
||||
|
||||
#define AT3_MAGIC 0x0270
|
||||
#define AT3_PLUS_MAGIC 0xFFFE
|
||||
#define PSP_MODE_AT_3_PLUS 0x00001000
|
||||
#define PSP_MODE_AT_3 0x00001001
|
||||
|
||||
const int PSP_ATRAC_ALLDATA_IS_ON_MEMORY = -1;
|
||||
const int PSP_ATRAC_NONLOOP_STREAM_DATA_IS_ON_MEMORY = -2;
|
||||
|
@ -66,107 +62,8 @@ struct InputBuffer {
|
|||
u32 fileoffset;
|
||||
};
|
||||
|
||||
struct AtracLoopInfo {
|
||||
int cuePointID;
|
||||
int type;
|
||||
int startSample;
|
||||
int endSample;
|
||||
int fraction;
|
||||
int playCount;
|
||||
};
|
||||
|
||||
class AudioDecoder;
|
||||
|
||||
// This is (mostly) constant info, once a track has been loaded.
|
||||
struct Track {
|
||||
// This both does and doesn't belong in Track - it's fixed for an Atrac instance. Oh well.
|
||||
u32 codecType = 0;
|
||||
|
||||
// Size of the full track being streamed or played. Can be a lot larger than the in-memory buffer in the streaming modes.
|
||||
u32 fileSize = 0;
|
||||
|
||||
// Not really used for much except queries, this keeps track of the bitrate of the track (kbps).
|
||||
u32 bitrate = 64;
|
||||
|
||||
// Signifies whether to use a more efficient coding mode with less stereo separation. For our purposes, just metadata,
|
||||
// not actually used in decoding.
|
||||
int jointStereo = 0;
|
||||
|
||||
// Number of audio channels in the track.
|
||||
u16 channels = 2;
|
||||
|
||||
// The size of an encoded frame in bytes.
|
||||
u16 bytesPerFrame = 0;
|
||||
|
||||
// Byte offset of the first encoded frame in the input buffer. Note: Some samples may be skipped according to firstSampleOffset.
|
||||
int dataByteOffset = 0;
|
||||
|
||||
// How many samples to skip from the beginning of a track when decoding.
|
||||
// Actually, the real number is this added to FirstOffsetExtra(codecType). You can call
|
||||
// FirstSampleOffset2() to get that.
|
||||
// Some use of these offsets around the code seem to be inconsistent, sometimes the extra is included,
|
||||
// sometimes not.
|
||||
int firstSampleOffset = 0;
|
||||
|
||||
// Last sample number. Inclusive. Though, we made it so that in Analyze, it's exclusive in the file.
|
||||
// Does not take firstSampleOffset into account.
|
||||
int endSample = -1;
|
||||
|
||||
// NOTE: The below CAN be written.
|
||||
// Loop configuration. The PSP only supports one loop but we store them all.
|
||||
std::vector<AtracLoopInfo> loopinfo;
|
||||
// The actual used loop offsets. These appear to be raw offsets, not taking FirstSampleOffset2() into account.
|
||||
int loopStartSample = -1;
|
||||
int loopEndSample = -1;
|
||||
|
||||
// Input frame size
|
||||
int BytesPerFrame() const {
|
||||
return bytesPerFrame;
|
||||
}
|
||||
|
||||
inline int FirstOffsetExtra() const {
|
||||
// These first samples are skipped, after first possibly skipping 0-2 full frames, it seems.
|
||||
return codecType == PSP_MODE_AT_3_PLUS ? 0x170 : 0x45;
|
||||
}
|
||||
|
||||
// Includes the extra offset. See firstSampleOffset comment above.
|
||||
int FirstSampleOffsetFull() const {
|
||||
return FirstOffsetExtra() + firstSampleOffset;
|
||||
}
|
||||
|
||||
// Output frame size, different between the two supported codecs.
|
||||
int SamplesPerFrame() const {
|
||||
return codecType == PSP_MODE_AT_3_PLUS ? ATRAC3PLUS_MAX_SAMPLES : ATRAC3_MAX_SAMPLES;
|
||||
}
|
||||
|
||||
int Bitrate() const {
|
||||
int bitrate = (bytesPerFrame * 352800) / 1000;
|
||||
if (codecType == PSP_MODE_AT_3_PLUS)
|
||||
bitrate = ((bitrate >> 11) + 8) & 0xFFFFFFF0;
|
||||
else
|
||||
bitrate = (bitrate + 511) >> 10;
|
||||
return bitrate;
|
||||
}
|
||||
|
||||
// This appears to be buggy, should probably include FirstOffsetExtra?
|
||||
// Actually the units don't even make sense here.
|
||||
int DecodePosBySample(int sample) const {
|
||||
return (u32)(firstSampleOffset + sample / (int)SamplesPerFrame() * bytesPerFrame);
|
||||
}
|
||||
|
||||
// This appears to be buggy, should probably include FirstOffsetExtra?
|
||||
int FileOffsetBySample(int sample) const {
|
||||
int offsetSample = sample + firstSampleOffset;
|
||||
int frameOffset = offsetSample / (int)SamplesPerFrame();
|
||||
return (u32)(dataByteOffset + bytesPerFrame + frameOffset * bytesPerFrame);
|
||||
}
|
||||
|
||||
void DebugLog() const;
|
||||
};
|
||||
|
||||
int AnalyzeAA3Track(u32 addr, u32 size, u32 filesize, Track *track);
|
||||
int AnalyzeAtracTrack(u32 addr, u32 size, Track *track);
|
||||
|
||||
class AtracBase {
|
||||
public:
|
||||
virtual ~AtracBase();
|
||||
|
|
|
@ -503,7 +503,7 @@ u32 Atrac2::AddStreamDataSas(u32 bufPtr, u32 bytesToAdd) {
|
|||
// Sol Trigger is the only game I know that uses this.
|
||||
_dbg_assert_(false);
|
||||
|
||||
u8 *dest = Memory::GetPointerWrite(info.buffer);
|
||||
u8 *dest = Memory::GetPointerWrite(sasBasePtr_ + sasReadOffset_);
|
||||
memcpy(dest, Memory::GetPointer(bufPtr), bytesToAdd);
|
||||
info.buffer += bytesToAdd;
|
||||
info.streamDataByte += bytesToAdd;
|
||||
|
|
|
@ -635,9 +635,10 @@ static u32 sceAtracSetHalfwayBuffer(int atracID, u32 buffer, u32 readSize, u32 b
|
|||
}
|
||||
|
||||
Track track;
|
||||
int ret = AnalyzeAtracTrack(buffer, readSize, &track);
|
||||
std::string error;
|
||||
int ret = AnalyzeAtracTrack(Memory::GetPointer(buffer), readSize, &track, &error);
|
||||
if (ret < 0) {
|
||||
return hleLogError(Log::ME, ret);
|
||||
return hleLogError(Log::ME, ret, "%s", error.c_str());
|
||||
}
|
||||
if (track.codecType != atracContextTypes[atracID]) {
|
||||
// TODO: Should this not change the buffer size?
|
||||
|
@ -670,9 +671,10 @@ static u32 sceAtracSetData(int atracID, u32 buffer, u32 bufferSize) {
|
|||
}
|
||||
|
||||
Track track;
|
||||
int ret = AnalyzeAtracTrack(buffer, bufferSize, &track);
|
||||
std::string error;
|
||||
int ret = AnalyzeAtracTrack(Memory::GetPointer(buffer), bufferSize, &track, &error);
|
||||
if (ret < 0) {
|
||||
return hleLogError(Log::ME, ret);
|
||||
return hleLogError(Log::ME, ret, "%s", error.c_str());
|
||||
}
|
||||
if (track.codecType != atracContextTypes[atracID]) {
|
||||
// TODO: Should this not change the buffer size?
|
||||
|
@ -697,9 +699,10 @@ static int sceAtracSetDataAndGetID(u32 buffer, int bufferSize) {
|
|||
}
|
||||
|
||||
Track track;
|
||||
int ret = AnalyzeAtracTrack(buffer, bufferSize, &track);
|
||||
std::string error;
|
||||
int ret = AnalyzeAtracTrack(Memory::GetPointer(buffer), bufferSize, &track, &error);
|
||||
if (ret < 0) {
|
||||
return hleLogError(Log::ME, ret);
|
||||
return hleLogError(Log::ME, ret, "%s", error.c_str());
|
||||
}
|
||||
|
||||
int atracID = AllocAndRegisterAtrac(track.codecType);
|
||||
|
@ -722,9 +725,10 @@ static int sceAtracSetHalfwayBufferAndGetID(u32 buffer, u32 readSize, u32 buffer
|
|||
}
|
||||
|
||||
Track track;
|
||||
int ret = AnalyzeAtracTrack(buffer, bufferSize, &track);
|
||||
std::string error;
|
||||
int ret = AnalyzeAtracTrack(Memory::GetPointer(buffer), readSize, &track, &error);
|
||||
if (ret < 0) {
|
||||
return hleLogError(Log::ME, ret);
|
||||
return hleLogError(Log::ME, ret, "%s", error.c_str());
|
||||
}
|
||||
|
||||
int atracID = AllocAndRegisterAtrac(track.codecType);
|
||||
|
@ -841,9 +845,10 @@ static int sceAtracSetMOutHalfwayBuffer(int atracID, u32 buffer, u32 readSize, u
|
|||
}
|
||||
|
||||
Track track;
|
||||
int ret = AnalyzeAtracTrack(buffer, bufferSize, &track);
|
||||
std::string error;
|
||||
int ret = AnalyzeAtracTrack(Memory::GetPointer(buffer), readSize, &track, &error);
|
||||
if (ret < 0) {
|
||||
return hleLogError(Log::ME, ret);
|
||||
return hleLogError(Log::ME, ret, "%s", error.c_str());
|
||||
}
|
||||
|
||||
ret = atrac->SetData(track, buffer, readSize, bufferSize, 1);
|
||||
|
@ -865,9 +870,10 @@ static u32 sceAtracSetMOutData(int atracID, u32 buffer, u32 bufferSize) {
|
|||
}
|
||||
|
||||
Track track;
|
||||
int ret = AnalyzeAtracTrack(buffer, bufferSize, &track);
|
||||
std::string error;
|
||||
int ret = AnalyzeAtracTrack(Memory::GetPointer(buffer), bufferSize, &track, &error);
|
||||
if (ret < 0) {
|
||||
return hleLogError(Log::ME, ret);
|
||||
return hleLogError(Log::ME, ret, "%s", error.c_str());
|
||||
}
|
||||
|
||||
ret = atrac->SetData(track, buffer, bufferSize, bufferSize, 1);
|
||||
|
@ -883,10 +889,12 @@ static u32 sceAtracSetMOutData(int atracID, u32 buffer, u32 bufferSize) {
|
|||
// See note in above function.
|
||||
static int sceAtracSetMOutDataAndGetID(u32 buffer, u32 bufferSize) {
|
||||
Track track;
|
||||
int ret = AnalyzeAtracTrack(buffer, bufferSize, &track);
|
||||
std::string error;
|
||||
int ret = AnalyzeAtracTrack(Memory::GetPointer(buffer), bufferSize, &track, &error);
|
||||
if (ret < 0) {
|
||||
return hleLogError(Log::ME, ret);
|
||||
return hleLogError(Log::ME, ret, "%s", error.c_str());
|
||||
}
|
||||
|
||||
if (track.channels != 1) {
|
||||
return hleReportError(Log::ME, SCE_ERROR_ATRAC_NOT_MONO, "not mono data");
|
||||
}
|
||||
|
@ -910,10 +918,12 @@ static int sceAtracSetMOutHalfwayBufferAndGetID(u32 buffer, u32 readSize, u32 bu
|
|||
}
|
||||
|
||||
Track track;
|
||||
int ret = AnalyzeAtracTrack(buffer, bufferSize, &track);
|
||||
std::string error;
|
||||
int ret = AnalyzeAtracTrack(Memory::GetPointer(buffer), readSize, &track, &error);
|
||||
if (ret < 0) {
|
||||
return hleLogError(Log::ME, ret);
|
||||
return hleLogError(Log::ME, ret, "%s", error.c_str());
|
||||
}
|
||||
|
||||
if (track.channels != 1) {
|
||||
return hleReportError(Log::ME, SCE_ERROR_ATRAC_NOT_MONO, "not mono data");
|
||||
}
|
||||
|
@ -933,9 +943,10 @@ static int sceAtracSetMOutHalfwayBufferAndGetID(u32 buffer, u32 readSize, u32 bu
|
|||
|
||||
static int sceAtracSetAA3DataAndGetID(u32 buffer, u32 bufferSize, u32 fileSize, u32 metadataSizeAddr) {
|
||||
Track track;
|
||||
int ret = AnalyzeAtracTrack(buffer, bufferSize, &track);
|
||||
std::string error;
|
||||
int ret = AnalyzeAtracTrack(Memory::GetPointer(buffer), bufferSize, &track, &error);
|
||||
if (ret < 0) {
|
||||
return hleLogError(Log::ME, ret);
|
||||
return hleLogError(Log::ME, ret, "%s", error.c_str());
|
||||
}
|
||||
|
||||
int atracID = AllocAndRegisterAtrac(track.codecType);
|
||||
|
@ -952,6 +963,32 @@ static int sceAtracSetAA3DataAndGetID(u32 buffer, u32 bufferSize, u32 fileSize,
|
|||
return hleDelayResult(hleLogDebug(Log::ME, atracID), "atrac set aa3 data", 100);
|
||||
}
|
||||
|
||||
static int sceAtracSetAA3HalfwayBufferAndGetID(u32 buffer, u32 readSize, u32 bufferSize, u32 fileSize) {
|
||||
if (readSize > bufferSize) {
|
||||
return hleLogError(Log::ME, SCE_ERROR_ATRAC_INCORRECT_READ_SIZE, "read size too large");
|
||||
}
|
||||
|
||||
Track track;
|
||||
std::string error;
|
||||
int ret = AnalyzeAtracTrack(Memory::GetPointer(buffer), readSize, &track, &error);
|
||||
if (ret < 0) {
|
||||
return hleLogError(Log::ME, ret, "%s", error.c_str());
|
||||
}
|
||||
|
||||
int atracID = AllocAndRegisterAtrac(track.codecType);
|
||||
if (atracID < 0) {
|
||||
return hleLogError(Log::ME, atracID, "no free ID");
|
||||
}
|
||||
|
||||
ret = atracContexts[atracID]->SetData(track, buffer, readSize, bufferSize, 2);
|
||||
if (ret < 0) {
|
||||
UnregisterAndDeleteAtrac(atracID);
|
||||
return hleLogError(Log::ME, ret);
|
||||
}
|
||||
|
||||
return hleDelayResult(hleLogDebug(Log::ME, atracID), "atrac set data", 100);
|
||||
}
|
||||
|
||||
// TODO: Should see if these are stored contiguously in memory somewhere, or if there really are
|
||||
// individual allocations being used.
|
||||
static u32 _sceAtracGetContextAddress(int atracID) {
|
||||
|
@ -1048,31 +1085,6 @@ static int sceAtracLowLevelDecode(int atracID, u32 sourceAddr, u32 sourceBytesCo
|
|||
return hleDelayResult(hleLogDebug(Log::ME, retval), "low level atrac decode data", atracDecodeDelay);
|
||||
}
|
||||
|
||||
static int sceAtracSetAA3HalfwayBufferAndGetID(u32 buffer, u32 readSize, u32 bufferSize, u32 fileSize) {
|
||||
if (readSize > bufferSize) {
|
||||
return hleLogError(Log::ME, SCE_ERROR_ATRAC_INCORRECT_READ_SIZE, "read size too large");
|
||||
}
|
||||
|
||||
Track track;
|
||||
int ret = AnalyzeAtracTrack(buffer, bufferSize, &track);
|
||||
if (ret < 0) {
|
||||
return hleLogError(Log::ME, ret);
|
||||
}
|
||||
|
||||
int atracID = AllocAndRegisterAtrac(track.codecType);
|
||||
if (atracID < 0) {
|
||||
return hleLogError(Log::ME, atracID, "no free ID");
|
||||
}
|
||||
|
||||
ret = atracContexts[atracID]->SetData(track, buffer, readSize, bufferSize, 2);
|
||||
if (ret < 0) {
|
||||
UnregisterAndDeleteAtrac(atracID);
|
||||
return hleLogError(Log::ME, ret);
|
||||
}
|
||||
|
||||
return hleDelayResult(hleLogDebug(Log::ME, atracID), "atrac set data", 100);
|
||||
}
|
||||
|
||||
// These three are the external interface used by sceSas' AT3 integration.
|
||||
|
||||
// NOTE: There are special rules.
|
||||
|
|
|
@ -17,7 +17,8 @@
|
|||
|
||||
#pragma once
|
||||
|
||||
#include "sceAudiocodec.h"
|
||||
#include "Core/HLE/sceAudiocodec.h"
|
||||
#include "Core/Util/AtracTrack.h"
|
||||
|
||||
class PointerWrap;
|
||||
|
||||
|
@ -29,9 +30,6 @@ void __AtracShutdown();
|
|||
void __AtracNotifyLoadModule(int version, u32 crc, u32 bssAddr, int bssSize);
|
||||
void __AtracNotifyUnloadModule();
|
||||
|
||||
constexpr u32 ATRAC3_MAX_SAMPLES = 0x400; // 1024
|
||||
constexpr u32 ATRAC3PLUS_MAX_SAMPLES = 0x800; // 2048
|
||||
|
||||
// The "state" member of SceAtracIdInfo.
|
||||
enum AtracStatus : u8 {
|
||||
ATRAC_STATUS_UNINITIALIZED = 0, // bad state
|
||||
|
@ -68,6 +66,9 @@ const char *AtracStatusToString(AtracStatus status);
|
|||
inline bool AtracStatusIsStreaming(AtracStatus status) {
|
||||
return (status & ATRAC_STATUS_STREAMED_MASK) != 0;
|
||||
}
|
||||
inline bool AtracStatusIsNormal(AtracStatus status) {
|
||||
return (int)status >= ATRAC_STATUS_ALL_DATA_LOADED && (int)status <= ATRAC_STATUS_STREAMED_LOOP_WITH_TRAILER;
|
||||
}
|
||||
|
||||
struct SceAtracIdInfo {
|
||||
s32 decodePos; // Sample position in the song that we'll next be decoding from.
|
||||
|
|
300
Core/Util/AtracTrack.cpp
Normal file
300
Core/Util/AtracTrack.cpp
Normal file
|
@ -0,0 +1,300 @@
|
|||
#include "Common/Log.h"
|
||||
#include "Common/StringUtils.h"
|
||||
#include "Core/Util/AtracTrack.h"
|
||||
#include "Core/HLE/ErrorCodes.h"
|
||||
#include "Core/MemMap.h"
|
||||
|
||||
const int RIFF_CHUNK_MAGIC = 0x46464952;
|
||||
const int RIFF_WAVE_MAGIC = 0x45564157;
|
||||
const int FMT_CHUNK_MAGIC = 0x20746D66;
|
||||
const int DATA_CHUNK_MAGIC = 0x61746164;
|
||||
const int SMPL_CHUNK_MAGIC = 0x6C706D73;
|
||||
const int FACT_CHUNK_MAGIC = 0x74636166;
|
||||
|
||||
static u16 Read16(const u8 *buffer, int offset) {
|
||||
u16 value;
|
||||
memcpy(&value, buffer + offset, sizeof(u16));
|
||||
return value;
|
||||
}
|
||||
|
||||
static u32 Read32(const u8 *buffer, int offset) {
|
||||
u32 value;
|
||||
memcpy(&value, buffer + offset, sizeof(u32));
|
||||
return value;
|
||||
}
|
||||
|
||||
int AnalyzeAtracTrack(const u8 *buffer, u32 size, Track *track, std::string *error) {
|
||||
// 72 is about the size of the minimum required data to even be valid.
|
||||
|
||||
// TODO: Validate stuff more.
|
||||
if (Read32(buffer, 0) != RIFF_CHUNK_MAGIC) {
|
||||
ERROR_LOG(Log::ME, "Couldn't find RIFF header");
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
|
||||
struct RIFFFmtChunk {
|
||||
u16 fmtTag;
|
||||
u16 channels;
|
||||
u32 samplerate;
|
||||
u32 avgBytesPerSec;
|
||||
u16 blockAlign;
|
||||
};
|
||||
|
||||
u32 offset = 8;
|
||||
track->firstSampleOffset = 0;
|
||||
|
||||
while (Read32(buffer, offset) != RIFF_WAVE_MAGIC) {
|
||||
// Get the size preceding the magic.
|
||||
int chunk = Read32(buffer, offset - 4);
|
||||
// Round the chunk size up to the nearest 2.
|
||||
offset += chunk + (chunk & 1);
|
||||
if (offset + 12 > size) {
|
||||
*error = StringFromFormat("%d too small for WAVE chunk at offset %d", size, offset);
|
||||
return SCE_ERROR_ATRAC_SIZE_TOO_SMALL;
|
||||
}
|
||||
if (Read32(buffer, offset) != RIFF_CHUNK_MAGIC) {
|
||||
*error = "RIFF chunk did not contain WAVE";
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
offset += 8;
|
||||
}
|
||||
offset += 4;
|
||||
|
||||
if (offset != 12) {
|
||||
WARN_LOG(Log::ME, "RIFF chunk at offset: %d", offset);
|
||||
}
|
||||
|
||||
// RIFF size excluding chunk header.
|
||||
track->fileSize = Read32(buffer, offset - 8) + 8;
|
||||
|
||||
// Even if the RIFF size is too low, it may simply be incorrect. This works on real firmware.
|
||||
u32 maxSize = std::max(track->fileSize, size);
|
||||
|
||||
bool bfoundData = false;
|
||||
u32 dataChunkSize = 0;
|
||||
int sampleOffsetAdjust = 0;
|
||||
|
||||
while (maxSize >= offset + 8 && !bfoundData) {
|
||||
int chunkMagic = Read32(buffer, offset);
|
||||
u32 chunkSize = Read32(buffer, offset + 4);
|
||||
// Account for odd sized chunks.
|
||||
if (chunkSize & 1) {
|
||||
WARN_LOG(Log::ME, "RIFF chunk had uneven size");
|
||||
}
|
||||
chunkSize += (chunkSize & 1);
|
||||
offset += 8;
|
||||
if (chunkSize > maxSize - offset)
|
||||
break;
|
||||
switch (chunkMagic) {
|
||||
case FMT_CHUNK_MAGIC:
|
||||
{
|
||||
if (track->codecType != 0) {
|
||||
*error = "AnalyzeTrack: multiple fmt chunks is not valid";
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
|
||||
auto at3fmt = (const RIFFFmtChunk *)(buffer + offset);
|
||||
if (chunkSize < 32 || (at3fmt->fmtTag == AT3_PLUS_MAGIC && chunkSize < 52)) {
|
||||
*error = "AnalyzeTrack: fmt definition too small(%d)";
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
|
||||
if (at3fmt->fmtTag == AT3_MAGIC)
|
||||
track->codecType = PSP_MODE_AT_3;
|
||||
else if (at3fmt->fmtTag == AT3_PLUS_MAGIC)
|
||||
track->codecType = PSP_MODE_AT_3_PLUS;
|
||||
else {
|
||||
*error = "AnalyzeTrack: invalid fmt magic: %04x";
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
track->channels = at3fmt->channels;
|
||||
if (track->channels != 1 && track->channels != 2) {
|
||||
*error = "AnalyzeTrack: unsupported channel count %d";
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
if (at3fmt->samplerate != 44100) {
|
||||
*error = "AnalyzeTrack: unsupported sample rate %d";
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
track->bitrate = at3fmt->avgBytesPerSec * 8;
|
||||
track->bytesPerFrame = at3fmt->blockAlign;
|
||||
if (track->bytesPerFrame == 0) {
|
||||
*error = "invalid bytes per frame: %d";
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
|
||||
// TODO: There are some format specific bytes here which seem to have fixed values?
|
||||
// Probably don't need them.
|
||||
|
||||
if (at3fmt->fmtTag == AT3_MAGIC) {
|
||||
// This is the offset to the jointStereo_ field.
|
||||
track->jointStereo = Read16(buffer, offset + 24);
|
||||
// Then there are more fields here.
|
||||
u16 unknown1_2 = Read16(buffer, offset + 30);
|
||||
|
||||
}
|
||||
if (chunkSize > 16) {
|
||||
// Read and format extra bytes as hexadecimal
|
||||
std::string hex;
|
||||
DataToHexString(buffer + offset + 16, chunkSize - 16, &hex, false);
|
||||
DEBUG_LOG(Log::ME, "Additional chunk data (beyond 16 bytes): %s", hex.c_str());
|
||||
}
|
||||
break;
|
||||
}
|
||||
case FACT_CHUNK_MAGIC:
|
||||
{
|
||||
track->endSample = Read32(buffer, offset);
|
||||
if (chunkSize >= 8) {
|
||||
track->firstSampleOffset = Read32(buffer, offset + 4);
|
||||
}
|
||||
if (chunkSize >= 12) {
|
||||
u32 largerOffset = Read32(buffer, offset + 8);
|
||||
// Works, but "largerOffset"??
|
||||
sampleOffsetAdjust = track->firstSampleOffset - largerOffset;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SMPL_CHUNK_MAGIC:
|
||||
{
|
||||
if (chunkSize < 32) {
|
||||
*error = StringFromFormat("smpl chunk too small (%d)", chunkSize);
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
int checkNumLoops = Read32(buffer, offset + 28);
|
||||
if (checkNumLoops != 0 && chunkSize < 36 + 20) {
|
||||
*error = StringFromFormat("smpl chunk too small for loop (%d, %d)", checkNumLoops, chunkSize);
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
if (checkNumLoops < 0) {
|
||||
*error = StringFromFormat("bad checkNumLoops (%d)", checkNumLoops);
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
|
||||
track->loopinfo.resize(checkNumLoops);
|
||||
u32 loopinfoOffset = offset + 36;
|
||||
// The PSP only cares about the first loop start and end, it seems.
|
||||
// Most likely can skip the rest of this data, but it's not hurting anyone.
|
||||
for (int i = 0; i < checkNumLoops && 36 + (u32)i < chunkSize; i++, loopinfoOffset += 24) {
|
||||
track->loopinfo[i].cuePointID = Read32(buffer, loopinfoOffset + 0);
|
||||
track->loopinfo[i].type = Read32(buffer, loopinfoOffset + 4);
|
||||
track->loopinfo[i].startSample = Read32(buffer, loopinfoOffset + 8);
|
||||
track->loopinfo[i].endSample = Read32(buffer, loopinfoOffset + 12);
|
||||
track->loopinfo[i].fraction = Read32(buffer, loopinfoOffset + 16);
|
||||
track->loopinfo[i].playCount = Read32(buffer, loopinfoOffset + 20);
|
||||
if (i == 0 && track->loopinfo[i].startSample >= track->loopinfo[i].endSample) {
|
||||
*error = "AnalyzeTrack: loop starts after it ends";
|
||||
return SCE_ERROR_ATRAC_BAD_CODEC_PARAMS;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DATA_CHUNK_MAGIC:
|
||||
{
|
||||
bfoundData = true;
|
||||
track->dataByteOffset = offset;
|
||||
dataChunkSize = chunkSize;
|
||||
if (track->fileSize < offset + chunkSize) {
|
||||
WARN_LOG(Log::ME, "Atrac data chunk extends beyond riff chunk");
|
||||
track->fileSize = offset + chunkSize;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
offset += chunkSize;
|
||||
}
|
||||
|
||||
if (track->codecType == 0) {
|
||||
*error = "Could not detect codec";
|
||||
return SCE_ERROR_ATRAC_UNKNOWN_FORMAT;
|
||||
}
|
||||
|
||||
if (!bfoundData) {
|
||||
*error = "AnalyzeTrack: No data chunk found";
|
||||
return SCE_ERROR_ATRAC_SIZE_TOO_SMALL;
|
||||
}
|
||||
|
||||
// set the loopStartSample_ and loopEndSample_ by loopinfo_
|
||||
if (track->loopinfo.size() > 0) {
|
||||
track->loopStartSample = track->loopinfo[0].startSample + track->FirstOffsetExtra() + sampleOffsetAdjust;
|
||||
track->loopEndSample = track->loopinfo[0].endSample + track->FirstOffsetExtra() + sampleOffsetAdjust;
|
||||
} else {
|
||||
track->loopStartSample = -1;
|
||||
track->loopEndSample = -1;
|
||||
}
|
||||
|
||||
// if there is no correct endsample, try to guess it
|
||||
if (track->endSample <= 0 && track->bytesPerFrame != 0) {
|
||||
track->endSample = (dataChunkSize / track->bytesPerFrame) * track->SamplesPerFrame();
|
||||
track->endSample -= track->FirstSampleOffsetFull();
|
||||
}
|
||||
track->endSample -= 1;
|
||||
|
||||
if (track->loopEndSample != -1 && track->loopEndSample > track->endSample + track->FirstSampleOffsetFull()) {
|
||||
*error = "AnalyzeTrack: loop after end of data";
|
||||
return SCE_ERROR_ATRAC_BAD_CODEC_PARAMS;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int AnalyzeAA3Track(const u8 *buffer, u32 size, u32 fileSize, Track *track, std::string *error) {
|
||||
if (size < 10) {
|
||||
return SCE_ERROR_ATRAC_AA3_SIZE_TOO_SMALL;
|
||||
}
|
||||
// TODO: Make sure this validation is correct, more testing.
|
||||
|
||||
if (buffer[0] != 'e' || buffer[1] != 'a' || buffer[2] != '3') {
|
||||
return SCE_ERROR_ATRAC_AA3_INVALID_DATA;
|
||||
}
|
||||
|
||||
// It starts with an id3 header (replaced with ea3.) This is the size.
|
||||
u32 tagSize = buffer[9] | (buffer[8] << 7) | (buffer[7] << 14) | (buffer[6] << 21);
|
||||
if (size < tagSize + 36) {
|
||||
return SCE_ERROR_ATRAC_AA3_SIZE_TOO_SMALL;
|
||||
}
|
||||
|
||||
// EA3 header starts at id3 header (10) + tagSize.
|
||||
buffer = buffer + 10 + tagSize;
|
||||
if (buffer[0] != 'E' || buffer[1] != 'A' || buffer[2] != '3') {
|
||||
ERROR_LOG(Log::ME, "AnalyzeAA3Track: Invalid EA3 magic bytes");
|
||||
return SCE_ERROR_ATRAC_AA3_INVALID_DATA;
|
||||
}
|
||||
|
||||
track->fileSize = fileSize;
|
||||
|
||||
// Based on FFmpeg's code.
|
||||
u32 codecParams = buffer[33] | (buffer[34] << 8) | (buffer[35] << 16);
|
||||
const u32 at3SampleRates[8] = { 32000, 44100, 48000, 88200, 96000, 0 };
|
||||
|
||||
switch (buffer[32]) {
|
||||
case 0:
|
||||
track->codecType = PSP_MODE_AT_3;
|
||||
track->bytesPerFrame = (codecParams & 0x03FF) * 8;
|
||||
track->bitrate = at3SampleRates[(codecParams >> 13) & 7] * track->bytesPerFrame * 8 / 1024;
|
||||
track->channels = 2;
|
||||
track->jointStereo = (codecParams >> 17) & 1;
|
||||
break;
|
||||
case 1:
|
||||
track->codecType = PSP_MODE_AT_3_PLUS;
|
||||
track->bytesPerFrame = ((codecParams & 0x03FF) * 8) + 8;
|
||||
track->bitrate = at3SampleRates[(codecParams >> 13) & 7] * track->bytesPerFrame * 8 / 2048;
|
||||
track->channels = (codecParams >> 10) & 7;
|
||||
break;
|
||||
case 3:
|
||||
case 4:
|
||||
case 5:
|
||||
ERROR_LOG(Log::ME, "AnalyzeAA3Track: unsupported codec type %d", buffer[32]);
|
||||
return SCE_ERROR_ATRAC_AA3_INVALID_DATA;
|
||||
default:
|
||||
ERROR_LOG(Log::ME, "AnalyzeAA3Track: invalid codec type %d", buffer[32]);
|
||||
return SCE_ERROR_ATRAC_AA3_INVALID_DATA;
|
||||
}
|
||||
|
||||
track->dataByteOffset = 10 + tagSize + 96;
|
||||
track->firstSampleOffset = 0;
|
||||
if (track->endSample < 0 && track->bytesPerFrame != 0) {
|
||||
track->endSample = ((track->fileSize - track->dataByteOffset) / track->bytesPerFrame) * track->SamplesPerFrame();
|
||||
}
|
||||
track->endSample -= 1;
|
||||
return 0;
|
||||
}
|
114
Core/Util/AtracTrack.h
Normal file
114
Core/Util/AtracTrack.h
Normal file
|
@ -0,0 +1,114 @@
|
|||
#pragma once
|
||||
|
||||
#include "Common/CommonTypes.h"
|
||||
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
// Atrac file parsing.
|
||||
#define AT3_MAGIC 0x0270
|
||||
#define AT3_PLUS_MAGIC 0xFFFE
|
||||
#define PSP_MODE_AT_3_PLUS 0x00001000
|
||||
#define PSP_MODE_AT_3 0x00001001
|
||||
|
||||
constexpr u32 ATRAC3_MAX_SAMPLES = 0x400; // 1024
|
||||
constexpr u32 ATRAC3PLUS_MAX_SAMPLES = 0x800; // 2048
|
||||
|
||||
struct AtracLoopInfo {
|
||||
int cuePointID;
|
||||
int type;
|
||||
int startSample;
|
||||
int endSample;
|
||||
int fraction;
|
||||
int playCount;
|
||||
};
|
||||
|
||||
// This is (mostly) constant info, once a track has been loaded.
|
||||
struct Track {
|
||||
// This both does and doesn't belong in Track - it's fixed for an Atrac instance. Oh well.
|
||||
u32 codecType = 0;
|
||||
|
||||
// Size of the full track being streamed or played. Can be a lot larger than the in-memory buffer in the streaming modes.
|
||||
u32 fileSize = 0;
|
||||
|
||||
// Not really used for much except queries, this keeps track of the bitrate of the track (kbps).
|
||||
u32 bitrate = 64;
|
||||
|
||||
// Signifies whether to use a more efficient coding mode with less stereo separation. For our purposes, just metadata,
|
||||
// not actually used in decoding.
|
||||
int jointStereo = 0;
|
||||
|
||||
// Number of audio channels in the track.
|
||||
u16 channels = 2;
|
||||
|
||||
// The size of an encoded frame in bytes.
|
||||
u16 bytesPerFrame = 0;
|
||||
|
||||
// Byte offset of the first encoded frame in the input buffer. Note: Some samples may be skipped according to firstSampleOffset.
|
||||
int dataByteOffset = 0;
|
||||
|
||||
// How many samples to skip from the beginning of a track when decoding.
|
||||
// Actually, the real number is this added to FirstOffsetExtra(codecType). You can call
|
||||
// FirstSampleOffset2() to get that.
|
||||
// Some use of these offsets around the code seem to be inconsistent, sometimes the extra is included,
|
||||
// sometimes not.
|
||||
int firstSampleOffset = 0;
|
||||
|
||||
// Last sample number. Inclusive. Though, we made it so that in Analyze, it's exclusive in the file.
|
||||
// Does not take firstSampleOffset into account.
|
||||
int endSample = -1;
|
||||
|
||||
// NOTE: The below CAN be written.
|
||||
// Loop configuration. The PSP only supports one loop but we store them all.
|
||||
std::vector<AtracLoopInfo> loopinfo;
|
||||
// The actual used loop offsets. These appear to be raw offsets, not taking FirstSampleOffset2() into account.
|
||||
int loopStartSample = -1;
|
||||
int loopEndSample = -1;
|
||||
|
||||
// Input frame size
|
||||
int BytesPerFrame() const {
|
||||
return bytesPerFrame;
|
||||
}
|
||||
|
||||
inline int FirstOffsetExtra() const {
|
||||
// These first samples are skipped, after first possibly skipping 0-2 full frames, it seems.
|
||||
return codecType == PSP_MODE_AT_3_PLUS ? 0x170 : 0x45;
|
||||
}
|
||||
|
||||
// Includes the extra offset. See firstSampleOffset comment above.
|
||||
int FirstSampleOffsetFull() const {
|
||||
return FirstOffsetExtra() + firstSampleOffset;
|
||||
}
|
||||
|
||||
// Output frame size, different between the two supported codecs.
|
||||
int SamplesPerFrame() const {
|
||||
return codecType == PSP_MODE_AT_3_PLUS ? ATRAC3PLUS_MAX_SAMPLES : ATRAC3_MAX_SAMPLES;
|
||||
}
|
||||
|
||||
int Bitrate() const {
|
||||
int bitrate = (bytesPerFrame * 352800) / 1000;
|
||||
if (codecType == PSP_MODE_AT_3_PLUS)
|
||||
bitrate = ((bitrate >> 11) + 8) & 0xFFFFFFF0;
|
||||
else
|
||||
bitrate = (bitrate + 511) >> 10;
|
||||
return bitrate;
|
||||
}
|
||||
|
||||
// This appears to be buggy, should probably include FirstOffsetExtra?
|
||||
// Actually the units don't even make sense here.
|
||||
int DecodePosBySample(int sample) const {
|
||||
return (u32)(firstSampleOffset + sample / (int)SamplesPerFrame() * bytesPerFrame);
|
||||
}
|
||||
|
||||
// This appears to be buggy, should probably include FirstOffsetExtra?
|
||||
int FileOffsetBySample(int sample) const {
|
||||
int offsetSample = sample + firstSampleOffset;
|
||||
int frameOffset = offsetSample / (int)SamplesPerFrame();
|
||||
return (u32)(dataByteOffset + bytesPerFrame + frameOffset * bytesPerFrame);
|
||||
}
|
||||
|
||||
void DebugLog() const;
|
||||
};
|
||||
|
||||
int AnalyzeAA3Track(const u8 *buffer, u32 size, u32 filesize, Track *track, std::string *error);
|
||||
int AnalyzeAtracTrack(const u8 *buffer, u32 size, Track *track, std::string *error);
|
|
@ -326,6 +326,9 @@ bool MainUI::HandleCustomEvent(QEvent *e) {
|
|||
case BrowseFileType::ZIP:
|
||||
filter = "ZIP files (*.zip)";
|
||||
break;
|
||||
case BrowseFileType::ATRAC3:
|
||||
filter = "AT3 files (*.at3)";
|
||||
break;
|
||||
case BrowseFileType::ANY:
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -90,6 +90,9 @@ void DarwinFileSystemServices::presentDirectoryPanel(
|
|||
case BrowseFileType::SOUND_EFFECT:
|
||||
[panel setAllowedFileTypes:[NSArray arrayWithObject:@"wav"]];
|
||||
break;
|
||||
case BrowseFileType::ATRAC3:
|
||||
[panel setAllowedFileTypes:[NSArray arrayWithObject:@"at3"]];
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@
|
|||
#include "Core/HLE/sceNetAdhocMatching.h"
|
||||
#include "Common/System/Request.h"
|
||||
|
||||
#include "Core/Util/AtracTrack.h"
|
||||
#include "Core/HLE/sceAtrac.h"
|
||||
#include "Core/HLE/sceAudio.h"
|
||||
#include "Core/HLE/sceAudiocodec.h"
|
||||
|
@ -1000,7 +1001,7 @@ void DrawAudioDecodersView(ImConfig &cfg, ImControl &control) {
|
|||
ImGui::TableNextColumn();
|
||||
ImGui::Text("in:%d out:%d", ctx->Channels(), ctx->GetOutputChannels());
|
||||
ImGui::TableNextColumn();
|
||||
if (ctx->BufferState() != ATRAC_STATUS_LOW_LEVEL) {
|
||||
if (AtracStatusIsNormal(ctx->BufferState())) {
|
||||
int pos;
|
||||
ctx->GetNextDecodePosition(&pos);
|
||||
ImGui::Text("%d", pos);
|
||||
|
@ -1008,7 +1009,7 @@ void DrawAudioDecodersView(ImConfig &cfg, ImControl &control) {
|
|||
ImGui::TextUnformatted("N/A");
|
||||
}
|
||||
ImGui::TableNextColumn();
|
||||
if (ctx->BufferState() <= ATRAC_STATUS_STREAMED_LOOP_WITH_TRAILER) {
|
||||
if (AtracStatusIsNormal(ctx->BufferState())) {
|
||||
ImGui::Text("%d", ctx->RemainingFrames());
|
||||
} else {
|
||||
ImGui::TextUnformatted("N/A");
|
||||
|
@ -1479,6 +1480,44 @@ static void DrawModules(const MIPSDebugInterface *debug, ImConfig &cfg, ImContro
|
|||
ImGui::End();
|
||||
}
|
||||
|
||||
void ImAtracToolWindow::Draw(ImConfig &cfg) {
|
||||
if (!ImGui::Begin("Atrac Tool", &cfg.atracToolOpen) || !g_symbolMap) {
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::InputText("File", atracPath_, sizeof(atracPath_));
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Choose...")) {
|
||||
System_BrowseForFile(cfg.requesterToken, "Choose AT3 file", BrowseFileType::ATRAC3, [&](const std::string &filename, int) {
|
||||
truncate_cpy(atracPath_, filename);
|
||||
}, nullptr);
|
||||
}
|
||||
|
||||
if (strlen(atracPath_) > 0) {
|
||||
if (ImGui::Button("Load")) {
|
||||
track_.reset(new Track());
|
||||
std::string data;
|
||||
if (File::ReadBinaryFileToString(Path(atracPath_), &data)) {
|
||||
AnalyzeAtracTrack((const u8 *)data.data(), (u32)data.size(), track_.get(), &error_);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (track_.get() != 0) {
|
||||
ImGui::Text("Codec: %s", track_->codecType != PSP_CODEC_AT3 ? "at3+" : "at3");
|
||||
ImGui::Text("Bitrate: %d kbps Channels: %d", track_->Bitrate(), track_->channels);
|
||||
ImGui::Text("Frame size in bytes: %d Output frame in samples: %d", track_->BytesPerFrame(), track_->SamplesPerFrame());
|
||||
ImGui::Text("First valid sample: %08x", track_->FirstSampleOffsetFull());
|
||||
}
|
||||
|
||||
if (!error_.empty()) {
|
||||
ImGui::TextUnformatted(error_.c_str());
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
void DrawHLEModules(ImConfig &config) {
|
||||
if (!ImGui::Begin("HLE Modules", &config.hleModulesOpen)) {
|
||||
ImGui::End();
|
||||
|
@ -1679,6 +1718,7 @@ void ImDebugger::Frame(MIPSDebugInterface *mipsDebug, GPUDebugInterface *gpuDebu
|
|||
ImGui::MenuItem("Debug stats", nullptr, &cfg_.debugStatsOpen);
|
||||
ImGui::MenuItem("Struct viewer", nullptr, &cfg_.structViewerOpen);
|
||||
ImGui::MenuItem("Log channels", nullptr, &cfg_.logConfigOpen);
|
||||
ImGui::MenuItem("Atrac Tool", nullptr, &cfg_.atracToolOpen);
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
if (ImGui::BeginMenu("Misc")) {
|
||||
|
@ -1761,6 +1801,10 @@ void ImDebugger::Frame(MIPSDebugInterface *mipsDebug, GPUDebugInterface *gpuDebu
|
|||
DrawHLEModules(cfg_);
|
||||
}
|
||||
|
||||
if (cfg_.atracToolOpen) {
|
||||
atracToolWindow_.Draw(cfg_);
|
||||
}
|
||||
|
||||
if (cfg_.framebuffersOpen) {
|
||||
DrawFramebuffersWindow(cfg_, gpuDebug->GetFramebufferManagerCommon());
|
||||
}
|
||||
|
|
|
@ -150,6 +150,7 @@ struct ImConfig {
|
|||
bool sasAudioOpen;
|
||||
bool logConfigOpen;
|
||||
bool utilityModulesOpen;
|
||||
bool atracToolOpen;
|
||||
bool memViewOpen[4];
|
||||
|
||||
// HLE explorer settings
|
||||
|
@ -176,6 +177,7 @@ struct ImConfig {
|
|||
|
||||
bool sasShowAllVoices = false;
|
||||
|
||||
|
||||
// We use a separate ini file from the main PPSSPP config.
|
||||
void LoadConfig(const Path &iniFile);
|
||||
void SaveConfig(const Path &iniFile);
|
||||
|
@ -183,6 +185,16 @@ struct ImConfig {
|
|||
void SyncConfig(IniFile *ini, bool save);
|
||||
};
|
||||
|
||||
struct Track;
|
||||
class ImAtracToolWindow {
|
||||
public:
|
||||
void Draw(ImConfig &cfg);
|
||||
|
||||
char atracPath_[1024]{};
|
||||
std::unique_ptr<Track> track_;
|
||||
std::string error_;
|
||||
};
|
||||
|
||||
enum class ImCmd {
|
||||
NONE = 0,
|
||||
TRIGGER_FIND_POPUP,
|
||||
|
@ -233,6 +245,7 @@ private:
|
|||
ImStructViewer structViewer_;
|
||||
ImGePixelViewerWindow pixelViewer_;
|
||||
ImMemDumpWindow memDumpWindow_;
|
||||
ImAtracToolWindow atracToolWindow_;
|
||||
|
||||
ImSnapshotState newSnapshot_;
|
||||
ImSnapshotState snapshot_;
|
||||
|
|
|
@ -333,6 +333,7 @@
|
|||
<ClInclude Include="..\..\Core\ThreadEventQueue.h" />
|
||||
<ClInclude Include="..\..\Core\ThreadPools.h" />
|
||||
<ClInclude Include="..\..\Core\TiltEventProcessor.h" />
|
||||
<ClInclude Include="..\..\Core\Util\AtracTrack.h" />
|
||||
<ClInclude Include="..\..\Core\Util\GameDB.h" />
|
||||
<ClInclude Include="..\..\Core\Util\MemStick.h" />
|
||||
<ClInclude Include="..\..\Core\Util\PortManager.h" />
|
||||
|
@ -632,6 +633,7 @@
|
|||
<ClCompile Include="..\..\Core\System.cpp" />
|
||||
<ClCompile Include="..\..\Core\ThreadPools.cpp" />
|
||||
<ClCompile Include="..\..\Core\TiltEventProcessor.cpp" />
|
||||
<ClCompile Include="..\..\Core\Util\AtracTrack.cpp" />
|
||||
<ClCompile Include="..\..\Core\Util\GameDB.cpp" />
|
||||
<ClCompile Include="..\..\Core\Util\MemStick.cpp" />
|
||||
<ClCompile Include="..\..\Core\Util\PortManager.cpp" />
|
||||
|
@ -1040,4 +1042,4 @@
|
|||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
<ImportGroup Label="ExtensionTargets">
|
||||
</ImportGroup>
|
||||
</Project>
|
||||
</Project>
|
|
@ -1235,6 +1235,9 @@
|
|||
<ClCompile Include="..\..\Core\HLE\SocketManager.cpp">
|
||||
<Filter>HLE</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\..\Core\Util\AtracTrack.cpp">
|
||||
<Filter>Util</Filter>
|
||||
</ClCompile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="pch.h" />
|
||||
|
@ -1954,10 +1957,13 @@
|
|||
<ClInclude Include="..\..\Core\HLE\SocketManager.h">
|
||||
<Filter>HLE</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\..\Core\Util\AtracTrack.h">
|
||||
<Filter>Util</Filter>
|
||||
</ClInclude>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="..\..\ext\gason\LICENSE">
|
||||
<Filter>Ext\gason</Filter>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
|
@ -517,7 +517,7 @@ bool System_MakeRequest(SystemRequestType type, int requestId, const std::string
|
|||
supportedExtensions = { ".zip" };
|
||||
break;
|
||||
case BrowseFileType::SYMBOL_MAP:
|
||||
supportedExtensions = { ".map" };
|
||||
supportedExtensions = { ".ppmap" };
|
||||
break;
|
||||
case BrowseFileType::DB:
|
||||
supportedExtensions = { ".db" };
|
||||
|
@ -525,6 +525,9 @@ bool System_MakeRequest(SystemRequestType type, int requestId, const std::string
|
|||
case BrowseFileType::SOUND_EFFECT:
|
||||
supportedExtensions = { ".wav", ".mp3" };
|
||||
break;
|
||||
case BrowseFileType::ATRAC3:
|
||||
supportedExtensions = { ".at3" };
|
||||
break;
|
||||
case BrowseFileType::ANY:
|
||||
// 'ChooseFile' will added '*' by default when there are no extensions assigned
|
||||
break;
|
||||
|
|
|
@ -537,6 +537,8 @@ static std::wstring MakeWindowsFilter(BrowseFileType type) {
|
|||
return FinalizeFilter(L"Sound effect files (*.wav *.mp3)|*.wav;*.mp3|All files (*.*)|*.*||");
|
||||
case BrowseFileType::SYMBOL_MAP:
|
||||
return FinalizeFilter(L"Symbol map files (*.ppmap)|*.ppmap|All files (*.*)|*.*||");
|
||||
case BrowseFileType::ATRAC3:
|
||||
return FinalizeFilter(L"ATRAC3/3+ files (*.at3)|*.at3|All files (*.*)|*.*||");
|
||||
case BrowseFileType::ANY:
|
||||
return FinalizeFilter(L"All files (*.*)|*.*||");
|
||||
default:
|
||||
|
|
|
@ -753,6 +753,7 @@ EXEC_AND_LIB_FILES := \
|
|||
$(SRC)/Core/MIPS/JitCommon/JitCommon.cpp \
|
||||
$(SRC)/Core/MIPS/JitCommon/JitBlockCache.cpp \
|
||||
$(SRC)/Core/MIPS/JitCommon/JitState.cpp \
|
||||
$(SRC)/Core/Util/AtracTrack.cpp \
|
||||
$(SRC)/Core/Util/AudioFormat.cpp \
|
||||
$(SRC)/Core/Util/MemStick.cpp \
|
||||
$(SRC)/Core/Util/PortManager.cpp \
|
||||
|
|
|
@ -690,8 +690,8 @@ SOURCES_CXX += \
|
|||
$(COREDIR)/Font/PGF.cpp \
|
||||
$(COREDIR)/HLE/HLE.cpp \
|
||||
$(COREDIR)/HLE/KUBridge.cpp \
|
||||
$(COREDIR)/HLE/NetInetConstants.cpp \
|
||||
$(COREDIR)/HLE/SocketManager.cpp \
|
||||
$(COREDIR)/HLE/NetInetConstants.cpp \
|
||||
$(COREDIR)/HLE/SocketManager.cpp \
|
||||
$(COREDIR)/HLE/Plugins.cpp \
|
||||
$(COREDIR)/HLE/sceSha256.cpp \
|
||||
$(COREDIR)/HLE/sceSircs.cpp \
|
||||
|
@ -826,6 +826,7 @@ SOURCES_CXX += \
|
|||
$(COREDIR)/Screenshot.cpp \
|
||||
$(COREDIR)/System.cpp \
|
||||
$(COREDIR)/ThreadPools.cpp \
|
||||
$(COREDIR)/Util/AtracTrack.cpp \
|
||||
$(COREDIR)/Util/BlockAllocator.cpp \
|
||||
$(COREDIR)/Util/MemStick.cpp \
|
||||
$(COREDIR)/Util/PPGeDraw.cpp \
|
||||
|
|
Loading…
Add table
Reference in a new issue