engine: common: soundlib: add support for MP3 looping through custom ID3v2.4.0 tagging

This commit is contained in:
Alibek Omarov 2023-02-19 12:28:46 +03:00
parent 2c77f4c566
commit fcda7517fe
4 changed files with 207 additions and 1 deletions

View File

@ -0,0 +1,22 @@
## Looping MP3 extension
It is now possible to loop MP3 file in Xash3D FWGS by adding a custom text tag with `LOOP_START` or `LOOPSTART` in description and time point (in raw samples) in value.
### Example with foobar2000
1. Open Foobar2000
2. Add your .mp3 file to playlist
3. Right click to newly added file and select Properties
4. In Metadata tab, at the bottom of the table, select "+add new"
5. In newly added line replace `«input field name»` with `LOOP_START` (without any symbols).
6. Press Tab and enter loop time point in raw samples. For example, `0` will replay sound file from beginning to end indefinitely.
### Possible alternatives
1. Classic WAV files looping. HQ WAV files can take too much disk space, and recommended software supporting cue points is paid, outdated and can't run on modern systems. (Although there is alternative that's proven to work with idTech-based engines called LoopAuditioneer.)
2. Vorbis looping through comment. Engine doesn't support Vorbis but this extension was highly inspired by this hack.
### Known bugs and limitations
1. At this time using MP3 as SFX requires complete decoding. This can cause noticeable stutters, so keep MP3 file length in mind.
2. We deliberately only support modern ID3v2.3 and ID3v2.4 tags. Using ID3v1 is not possible.

View File

@ -280,7 +280,6 @@ void FS_FreeStream( stream_t *stream )
}
#if XASH_ENGINE_TESTS
#define IMPLEMENT_SOUNDLIB_FUZZ_TARGET( export, target ) \
int EXPORT export( const uint8_t *Data, size_t Size ) \
{ \

View File

@ -16,6 +16,184 @@ GNU General Public License for more details.
#include "soundlib.h"
#include "libmpg/libmpg.h"
#pragma pack( push, 1 )
typedef struct did3v2_header_s
{
char ident[3]; // must be "ID3"
uint8_t major_ver; // must be 4
uint8_t minor_ver; // must be 0
uint8_t flags;
uint32_t length; // size of extended header, padding and frames
} did3v2_header_t;
STATIC_ASSERT( sizeof( did3v2_header_t ) == 10,
"invalid did3v2_header_t size" );
typedef struct did3v2_extended_header_s
{
uint32_t length;
uint8_t flags_length;
uint8_t flags[1];
} did3v2_extended_header_t;
STATIC_ASSERT( sizeof( did3v2_extended_header_t ) == 6,
"invalid did3v2_extended_header_t size" );
typedef struct did3v2_frame_s
{
char frame_id[4];
uint32_t length;
uint8_t flags[2];
} did3v2_frame_t;
STATIC_ASSERT( sizeof( did3v2_frame_t ) == 10,
"invalid did3v2_frame_t size" );
#pragma pack( pop )
typedef enum did3v2_header_flags_e
{
ID3V2_HEADER_UNSYHCHRONIZATION = BIT( 7U ),
ID3V2_HEADER_EXTENDED_HEADER = BIT( 6U ),
ID3V2_HEADER_EXPERIMENTAL = BIT( 5U ),
ID3V2_HEADER_FOOTER_PRESENT = BIT( 4U ),
} did3v2_header_flags_t;
#define CHECK_IDENT( ident, b0, b1, b2 ) ((( ident )[0]) == ( b0 ) && (( ident )[1]) == ( b1 ) && (( ident )[2]) == ( b2 ))
#define CHECK_FRAME_ID( ident, b0, b1, b2, b3 ) ( CHECK_IDENT( ident, b0, b1, b2 ) && (( ident )[3]) == ( b3 ))
static uint32_t Sound_ParseSynchInteger( uint32_t v )
{
uint32_t res = 0;
// read as big endian
res |= (( v >> 24 ) & 0x7f ) << 0;
res |= (( v >> 16 ) & 0x7f ) << 7;
res |= (( v >> 8 ) & 0x7f ) << 14;
res |= (( v >> 0 ) & 0x7f ) << 21;
return res;
}
static void Sound_HandleCustomID3Comment( const char *key, const char *value )
{
if( !Q_strcmp( key, "LOOP_START" ) || !Q_strcmp( key, "LOOPSTART" ))
sound.loopstart = Q_atoi( value );
// unknown comment is not an error
}
static qboolean Sound_ParseID3Frame( const did3v2_frame_t *frame, const byte *buffer, size_t frame_length )
{
if( CHECK_FRAME_ID( frame->frame_id, 'T', 'X', 'X', 'X' ))
{
string key, value;
int32_t key_len, value_len;
if( buffer[0] == 0x00 || buffer[1] == 0x03 )
{
key_len = Q_strncpy( key, &buffer[1], sizeof( key ));
value_len = frame_length - (1 + key_len + 1);
if( value_len <= 0 || value_len >= sizeof( value ))
{
Con_Printf( S_ERROR "Sound_ParseID3Frame: invalid TXXX description, possibly broken file.\n" );
return false;
}
memcpy( value, &buffer[1 + key_len + 1], value_len );
value[value_len + 1] = 0;
Sound_HandleCustomID3Comment( key, value );
}
else
{
if( buffer[0] == 0x01 || buffer[0] == 0x02 ) // UTF-16 with BOM
Con_Printf( S_ERROR "Sound_ParseID3Frame: UTF-16 encoding is unsupported. Use UTF-8 or ISO-8859!\n" );
else
Con_Printf( S_ERROR "Sound_ParseID3Frame: unknown TXXX tag encoding %d, possibly broken file.\n", buffer[0] );
return false;
}
}
return true;
}
static qboolean Sound_ParseID3Tag( const byte *buffer, fs_offset_t filesize )
{
const did3v2_header_t *header = (const did3v2_header_t *)buffer;
const byte *buffer_begin = buffer;
uint32_t tag_length;
if( filesize < sizeof( *header ))
return false;
buffer += sizeof( *header );
// support only id3v2
if( !CHECK_IDENT( header->ident, 'I', 'D', '3' ))
{
// old id3v1 header found
if( CHECK_IDENT( header->ident, 'T', 'A', 'G' ))
Con_Printf( S_ERROR "Sound_ParseID3Tag: ID3v1 is not supported! Convert to ID3v2.4!\n", header->major_ver );
return true; // missing tag header is not an error
}
// support only latest id3 v2.4
if( header->major_ver != 4 || header->minor_ver == 0xff )
{
Con_Printf( S_ERROR "Sound_ParseID3Tag: invalid ID3v2 tag version 2.%d.%d. Convert to ID3v2.4!\n", header->major_ver, header->minor_ver );
return false;
}
tag_length = Sound_ParseSynchInteger( header->length );
if( tag_length > filesize - sizeof( *header ))
{
Con_Printf( S_ERROR "Sound_ParseID3Tag: invalid tag length %u, possibly broken file.\n", tag_length );
return false;
}
// just skip extended header
if( FBitSet( header->flags, ID3V2_HEADER_EXTENDED_HEADER ))
{
const did3v2_extended_header_t *ext_header = (const did3v2_extended_header_t *)buffer;
uint32_t ext_length = Sound_ParseSynchInteger( ext_header->length );
if( ext_length > tag_length )
{
Con_Printf( S_ERROR "Sound_ParseID3Tag: invalid extended header length %u, possibly broken file.\n", ext_length );
return false;
}
buffer += ext_length;
}
while( buffer - buffer_begin < tag_length )
{
const did3v2_frame_t *frame = (const did3v2_frame_t *)buffer;
uint32_t frame_length = Sound_ParseSynchInteger( frame->length );
if( frame_length > tag_length )
{
Con_Printf( S_ERROR "Sound_ParseID3Tag: invalid frame length %u, possibly broken file.\n", frame_length );
return false;
}
buffer += sizeof( *frame );
// parse can fail, but it's ok to continue
Sound_ParseID3Frame( frame, buffer, frame_length );
buffer += frame_length;
}
return true;
}
#if XASH_ENGINE_TESTS
int EXPORT Fuzz_Sound_ParseID3Tag( const uint8_t *Data, size_t Size )
{
memset( &sound, 0, sizeof( sound ));
Sound_ParseID3Tag( Data, Size );
return 0;
}
#endif
/*
=================================================================
@ -59,6 +237,12 @@ qboolean Sound_LoadMPG( const char *name, const byte *buffer, fs_offset_t filesi
padsize = sound.size % FRAME_SIZE;
pos += FRAME_SIZE; // evaluate pos
if( !Sound_ParseID3Tag( buffer, filesize ))
{
Con_DPrintf( S_WARN "Sound_LoadMPG: (%s) failed to extract LOOP_START tag\n", name );
sound.loopstart = -1;
}
if( !sound.size )
{
// bad mpeg file ?

View File

@ -33,6 +33,7 @@ def add_runner_target(bld, lib, func):
def build(bld):
add_runner_target(bld, 'libxash.so', 'Sound_LoadMPG')
add_runner_target(bld, 'libxash.so', 'Sound_ParseID3Tag')
add_runner_target(bld, 'libxash.so', 'Sound_LoadWAV')
add_runner_target(bld, 'libxash.so', 'Image_LoadBMP')
add_runner_target(bld, 'libxash.so', 'Image_LoadPNG')