diff options
Diffstat (limited to 'game/addons/MusicMetadata/MusicMetadata.gd')
-rw-r--r-- | game/addons/MusicMetadata/MusicMetadata.gd | 545 |
1 files changed, 545 insertions, 0 deletions
diff --git a/game/addons/MusicMetadata/MusicMetadata.gd b/game/addons/MusicMetadata/MusicMetadata.gd new file mode 100644 index 0000000..49370eb --- /dev/null +++ b/game/addons/MusicMetadata/MusicMetadata.gd @@ -0,0 +1,545 @@ +@tool +@icon("res://addons/MusicMeta/icon.svg") +extends Resource +class_name MusicMetadata + +## Parses and contains common music file metadata. +## +## This class contains common metadata parsed from music files, with the ability to parse +## (currently only ID3 formatted) metadata from files. The user may also use this as a means to +## store manually defined metadata about music tracks, as the data contained is not automatically +## linked to any changes made after the parsing of a file. +## See [code] MusicMetadataUIBehave [/code] for a UI implementation example (or use it directly). + +## Seconds per minute, used for internal calculations. +const SEC_PER_MIN = 60 + +## Maps the series of preset ID3 genere's to their readable genere name +const ID3_GENERE_IDS:Dictionary = { + '0' : "Blues", + '1' : "Classic Rock", + '2' : "Country", + '3' : "Dance", + '4' : "Disco", + '5' : "Funk", + '6' : "Grunge", + '7' : "Hip-Hop", + '8' : "Jazz", + '9' : 'Metal', + '10' : "New Age", + '11' : "Oldies", + '12' : "Other", + '13' : "Pop", + '14' : "R&B", + '15' : "Rap", + '16' : "Reggae", + '17' : "Rock", + '18' : "Techno", + '19': "Industrial", + '20' : "Alternative", + '21' : "Ska", + '22' : "Death Metal", + '23' : "Pranks", + '24' : "Soundtrack", + '25' : "Euro-Techno", + '26' : "Ambient", + '27' : "Trip-Hop", + '28' : "Vocal", + '29' : "Jazz+Funk", + '30' : "Fusion", + '31' : "Trance", + '32' : "Classical", + '33' : "Instrumental", + '34' : "Acid", + '35' : "House", + '36' : "Game", + '37' : "Sound Clip", + '38' : "Gospel", + '39' : "Noise", + '40' : "Alt. Rock", + '41' : "Bass", + '42' : "Soul", + '43' : "Punk", + '44' : "Space", + '45' : "Meditative", + '46' : "Instrumental Pop", + '47' : "Instrumental Rock", + '48' : "Ethnic", + '49' : "Gothic", + '50' : "Darkwave", + '51' : "Techno-Industrial", + '52' : "Electronic", + '53' : "Pop-Folk", + '54' : "Eurodance", + '55' : "Dream", + '56' : "Southern Rock", + '57' : "Comedy", + '58' : "Cult", + '59' : "Gangsta Rap", + '60' : "Top 40", + '61' : "Christian Rap", + '62' : "Pop/Funk", + '63' : "Jungle", + '64' : "Native American", + '65' : "Cabaret", + '66' : "New Wave", + '67' : "Psychedelic", + '68' : "Rave", + '69' : "Showtunes", + '70' : "Trailer", + '71' : "Lo-Fi", + '72' : "Tribal", + '73' : "Acid Punk", + '74' : "Acid Jazz", + '75' : "Polka", + '76' : "Retro", + '77' : 'Musical', + '78' : "Rock & Roll", + '79' : 'Hard Rock', + '80' : "Folk", + '81' : "Folk-Rock", + '82' : 'National Folk', + '83' : "Swing", + '84' : 'Fast-Fusion', + '85' : 'Bebop', + '86' : 'Latin', + '87' : 'Revival', + '88' : 'Celtic', + '89' : 'Bluegrass', + '90' : 'Avantgarde', + '91' : 'Gothic Rock', + '92' : 'Progressive Rock', + '93' : 'Psychedelic Rock', + '94' : 'Symphonic Rock', + '95' : 'Slow Rock', + '96' : 'Big Band', + '97' : 'Chorus', + '98' : 'Easy Listening', + '99' : 'Acoustic', + '100' : 'Humour', + '101' : 'Speech', + '102' : 'Chanson', + '103' : 'Opera', + '104' : 'Chamber Music', + '105' : 'Sonata', + '106' : 'Symphony', + '107' : 'Booty Bass', + '108' : 'Primus', + '109' : 'Porn Groove', + '110' : 'Satire', + '111' : 'Slow Jam', + '112' : 'Club', + '113' : 'Tango', + '114' : 'Samba', + '115' : 'Folklore', + '116' : 'Ballad', + '117' : 'Power Ballad', + '118' : 'Rhythmic Soul', + '119' : 'Freestyle', + '120' : 'Duet', + '121' : 'Punk Rock', + '122' : 'Drum Solo', + '123' : 'A Cappella', + '124' : 'Euro-House', + '125' : 'Dance Hall', + '126' : 'Goa', + '127' : 'Drum & Bass', + '128' : 'Club-House', + '129' : 'Hardcore', + '130' : 'Terror', + '131' : 'Indie', + '132' : 'BritPop', + '133' : 'Afro-Punk', + '134' : 'Polsk Punk', + '135' : 'Beat', + '136' : 'Christian Gangsta Rap', + '137' : 'Heavy Metal', + '138' : 'Black Metal', + '139' : 'Crossover', + '140' : 'Contemporary Christian', + '141' : 'Christian Rock', + '142' : 'Merengue', + '143' : 'Salsa', + '144' : 'Thrash Metal', + '145' : 'Anime', + '146' : 'JPop', + '147' : 'Synthpop', + '148' : 'Abstract', + '149' : 'Art Rock', + '150' : 'Baroque', + '151' : 'Bhangra', + '152' : 'Big Beat', + '153' : 'Breakbeat', + '154' : 'Chillout', + '155' : 'Downtempo', + '156' : 'Dub', + '157' : 'EBM', + '158' : 'Eclectic', + '159' : 'Electro', + '160' : 'Electroclash', + '161' : 'Emo', + '162' : 'Experimental', + '163' : 'Garage', + '164' : 'Global', + '165' : 'IDM', + '166' : 'Illbient', + '167' : 'Industro-Goth', + '168' : 'Jam Band', + '169' : 'Krautrock', + '170' : 'Leftfield', + '171' : 'Lounge', + '172' : 'Math Rock', + '173' : 'New Romantic', + '174' : 'Nu-Breakz', + '175' : 'Post-Punk', + '176' : 'Post-Rock', + '177' : 'Psytrance', + '178' : 'Shoegaze', + '179' : 'Space Rock', + '180' : 'Trop Rock', + '181' : 'World Music', + '182' : 'Neoclassical', + '183' : 'Audiobook', + '184' : 'Audio Theatre', + '185' : 'Neue Deutsche Welle', + '186' : 'Podcast', + '187' : 'Indie Rock', + '188' : 'G-Funk', + '189' : 'Dubstep', + '190' : 'Garage Rock', + '191' : 'Psybient', + 'CR' : 'Cover', + 'RX' : 'Remix' +} + +## Maps Some ID3 frame names to their human readable names +const ID3_FRAME_ID_TO_URL_NAME:Dictionary = { + "WCOM" : "Commercial", + 'WCOP' : "Copyright", + 'WFED' : "Podcast", + 'WOAF' : "File", + 'WOAR' : "Artist", + 'WOAS' : "Source", + 'WORS' : "InternetRadioStation", + 'WPAY' : "Payment", + 'WPUB' : "Publisher", + 'WXXX' : "Custom", + 'WAF' : "File", + 'WAR' : "Artist", + 'WAS' : "Source", + 'WCM' : "Commercial", + 'WCP' : "Copyright", + 'WPB' : "Publisher", + 'WXX' : "UserDefined" +} + +## Used during parsing as the preferred newline to use when parsing requires a newline to be inserted. +var preferred_newline = "\n".to_ascii_buffer()[0] + +## The track's [i]Beats Per Minute[/i]. +@export var bpm: int = 0 +## The track's [i]Beats Per Second[/i]. Uses [member bpm] as a backing varible. +@export var bps:int: + get: + return bpm * SEC_PER_MIN + set(_value): + bpm = int(_value / SEC_PER_MIN) +## The track's [i]Title[/i]. +@export var title: String = "" +## The track's [i]Album Name[/i]. +@export var album: String = "" +## The track's [i]Number[/i]. +@export var track_no:int = -1 +## The track's [i]Artist[/i]. +@export var artist: String = "" +## The track's [i]Album's Artist[/i]. This is also known as the [i]Band Name[/i]. +@export var album_artist: String = "" +## The track's [i]Cover Image[/i]. +@export var cover: ImageTexture = null +## The track's [i]Genere[/i]. +@export var genere:String = "" +## The track's [i]Year[/i]. +@export var year: int = 0 +## The track's [i]Date[/i]. +@export var date:String = "" +## The track's [i]Comments[/i]. +@export_multiline var comments: String = "" +## The track's [i]User Defined Text[/i]. +@export_multiline var user_defined_text: String = "" +## The track's [i]Urls[/i]. +## It's keys are of [String]s with the type of url, it's values are of [String]s with the url. +@export var urls:Dictionary = {} +## The track's [i]Copyright Message[/i]. +@export var copyright:String = "" +## The track's [i]Terms Of Use[/i]. +@export var terms_of_use:String = "" + +## Create a [MusicMetadata] [Resource]. +## If not [code] null [/code], [param source] will update the new [MusicMetadata] [Resource] +## with any appropriate data found. +func _init(source:Variant = null): + if source != null: + if source is Array: + source = PackedByteArray(source) + + if source is PackedByteArray: + set_from_data(source) + elif source is AudioStream: + set_from_stream(source) + +## Updates the metadata object's values based off of the data found in [param stream]. +## Only works with [AudioStreamMP3], [AudioStreamOggVorbis], and [AudioStreamWAV] streams. +## See the [b]note[/b] in [method MusicMetadata.set_from_wav_stream] +## for information regarding parsing [AudioStreamWAV] stream's metadata. +func set_from_stream(stream:AudioStream): + if stream is AudioStreamMP3: + set_from_MP3_stream(stream) + elif stream is AudioStreamOggVorbis: + set_from_oggvorbis_stream(stream) + elif stream is AudioStreamWAV: + set_from_wav_stream(stream) + else: + assert(false, "Stream type not supported") + +## Updates the metadata object's values from the data found in the [AudioStreamMP3] [param stream]. +func set_from_MP3_stream(stream:AudioStreamMP3): + assert(stream != null and stream.data != null, "Stream must contain data") + set_from_data(stream.data) + +## Updates the metadata object's values from the data found in the [AudioStreamOggVorbis] [param stream]. +func set_from_oggvorbis_stream(stream:AudioStreamOggVorbis): + assert(stream != null and stream.data != null, "Stream must contain data") + set_from_data(stream.data) + +## Updates the metadata object's values based from data found in the [AudioStreamWAV] [param stream]. +## NOTE: Due to the way Godot handles WAV streams, it is likely the data contained within a +## [AudioStreamWAV] object will have its metadata stripped form it. +## because of this, it is strongly suggested to instead pars the raw data form the file itself using +## [method MusicMetadata.set_from_data] instead, unless you are sure that the metadata required will not +## be stripped by Godot. +func set_from_wav_stream(stream:AudioStreamWAV): + assert(stream != null and stream.data != null, "Stream must contain data") + set_from_data(stream.data) + +## Updates the metadata object's values based from data found in the [PackedByteArray] [param data]. +func set_from_data(data:PackedByteArray): + if data.size() < 10: + push_error("Error: Stream data is too small. ") + return null + + var header = data.slice(0, 10) + var id3_id = header.slice(0, 3).get_string_from_ascii() + if id3_id == "ID3": + var v = "ID3v2.%d.%d" % [header[3], header[4]] + set_from_ID3_data(data, v) + + #try idv1 header + var header2 = data.slice(len(data)-128,len(data)) + var id2 = header2.slice(0, 3).get_string_from_ascii() + if id2 == "TAG": + set_value_from_ID3_v1(data) + +## Updates the metadata object's values from the ID3 data found in the [PackedByteArray] [param data]. +## The specific version of ID3 data must also be specified in [param ver]. +func set_from_ID3_data(data: PackedByteArray, ver:String): + var header = data.slice(0, 10) + var null_as_seperator:bool = (ver == "ID3v2.4.0" or ver == "ID3v2.3.0") + var flags:int = header[5] + var _unsync:bool = flags & 0x80 > 0 + var extended:bool = flags & 0x40 > 0 + var _experimental:bool = flags & 0x20 > 0 + var _has_footer:bool = flags & 0x10 > 0 + var idx:int = 10 + var end:int = idx + _bytes_to_int(header.slice(6, 10)) + if extended: + idx += _bytes_to_int(data.slice(idx, idx + 4)) + + while idx < end: + var frame_id = data.slice(idx, idx + 4).get_string_from_ascii() + var size = _bytes_to_int(data.slice(idx + 4, idx + 8), frame_id != "APIC") + + # if greater than byte, not sync safe number (0b0111_1111 -> 0x7f) + if size > 0x7f: + size = _bytes_to_int(data.slice(idx + 4, idx + 8), false) + idx += 10 + + var frame_data = data.slice(idx, idx+size) + if frame_data.size() > 0: + set_value_from_ID3_frame(frame_id, frame_data, null_as_seperator) + + idx += size + +func set_value_from_ID3_v1(data:PackedByteArray) -> void: + var header = data.slice(len(data)-128,len(data)) + var id = header.slice(0, 3).get_string_from_ascii() + if id != "TAG": + push_error("Error: Stream data's header '%s' is not ID3v1."%id) + return; + + title = header.slice(3,33).get_string_from_ascii() + artist = header.slice(33,63).get_string_from_ascii() + album = header.slice(63,93).get_string_from_ascii() + year = int(header.slice(93,97).get_string_from_ascii()) + var comment = header.slice(97,127) + #handle id3v1.1, which added track_no + if comment[28] == 0x0: + track_no = int(comment[29]) + comments = comment.slice(0,29).get_string_from_ascii() + else: + comments = comment.get_string_from_ascii() + var gen_key = header[127] + if gen_key in ID3_GENERE_IDS: + genere = ID3_GENERE_IDS[gen_key] + + #idv1 didn't have bpm or cover pic, so those fields remain unfilled + +## Prints some of the metadata info to the output. +func print_info(): + print("bpm: ", bpm) + print("title: ", title) + print("album: ", album) + print("comments: ", comments) + print("year: ", year) + print("cover: ", cover) + print("artist: ", artist) + +## Updates a specific value from the given ID3 data frame's value. +## [param frame_name] is the [String] name of the frame. +## [param sliced_frame_data] in the specific binary data found in the ID3 frame. +## [param null_as_sep] is an optional setting. +## When true, a [code] null [/code] value will be treated as a newline, +## instead of terminating the data, if its to be read as a string. +## Used internally when a ID3 frame is found when parsing binary data. +func set_value_from_ID3_frame(frame_name:String, sliced_frame_data:PackedByteArray, null_as_sep:bool = false): + if sliced_frame_data.size() <= 0: + assert(false, "bad data provided") + return + + match frame_name: + "TBPM", 'TBP': + bpm = int(_get_string_from_ID3data(sliced_frame_data)) + "TIT2", 'TT2': + title = _get_string_from_ID3data(sliced_frame_data, null_as_sep) + "TALB", 'TAL': + album = _get_string_from_ID3data(sliced_frame_data, null_as_sep) + "COMM", "COM": + comments = _get_string_from_ID3data(sliced_frame_data, null_as_sep) + "TXX", "TXXX": + user_defined_text += _get_string_from_ID3data(sliced_frame_data, null_as_sep) + "TCOP", "TCR": + copyright += _get_string_from_ID3data(sliced_frame_data, null_as_sep) + "TDAT", "TDA": + date = _get_string_from_ID3data(sliced_frame_data, null_as_sep) + "TYER", "TYE": + year = int(_get_string_from_ID3data(sliced_frame_data)) + "TPE1", 'TP1': + artist = _get_string_from_ID3data(sliced_frame_data, null_as_sep) + "TPE2", 'TP2': + album_artist = _get_string_from_ID3data(sliced_frame_data, null_as_sep) + "TRCK", 'TRK': + track_no = int(_get_string_from_ID3data(sliced_frame_data)) + "USER": + terms_of_use = _get_string_from_ID3data(sliced_frame_data, null_as_sep) + "TCON", 'TCO': + var gen_key = _get_string_from_ID3data(sliced_frame_data, null_as_sep) + gen_key = gen_key.strip_escapes().strip_edges() + while gen_key[0] == "(" and gen_key[-1] == ")": + gen_key = gen_key.substr(1,gen_key.length()-2) + if gen_key.is_valid_int(): + gen_key = str(int(gen_key)) + if gen_key in ID3_GENERE_IDS: + genere = ID3_GENERE_IDS[gen_key] + else: + genere = gen_key + "APIC", 'PIC': + sliced_frame_data = sliced_frame_data.slice(1) + var zero1 = sliced_frame_data.find(0) + + if zero1 <= 0: + assert(false, "bad cover photo") + return + + var mime_type = sliced_frame_data.slice(0, zero1).get_string_from_ascii() + + zero1 += 1 # Picture type + if zero1 >= sliced_frame_data.size(): + assert(false, "bad cover photo") + return + + zero1 += 1 + if zero1 >= sliced_frame_data.size(): + assert(false, "bad cover photo") + return + + var zero2 = sliced_frame_data.find(0, zero1) + var image_bytes = sliced_frame_data.slice(zero2 + 1) + + var img = Image.new() + match mime_type: + "image/png": + img.load_png_from_buffer(image_bytes) + "image/jpeg", "image/jpg": + img.load_jpg_from_buffer(image_bytes) + _: + assert(false, "mime type %s not yet supported..." % [mime_type]) + return + cover = ImageTexture.create_from_image(img) + var fr_id when fr_id in ID3_FRAME_ID_TO_URL_NAME.keys(): + urls[ID3_FRAME_ID_TO_URL_NAME[fr_id]] = _get_string_from_ID3data(sliced_frame_data) + +# ## This is intended to be private. +# ## The hash of a USC string declaration. Used for compairson. +var _USC_STRING_DECLARATION_HASH:int = [1, 0xff, 0xfe].hash() +# ## This method is intended to be private. +# ## Gets a string from the given ID3 formated [param data]. Accounts for USC formated strings. +func _get_string_from_ID3data(data, null_to_newline:bool = false) -> String: + var ret = "" + + if data.size() > 3 and Array(data.slice(0, 3)).hash() == _USC_STRING_DECLARATION_HASH: + # Null-terminated string of ucs2 chars + ret = _get_string_from_ucs2(data.slice(3), null_to_newline) + + if ret == "" and data[0] == 0: + # Simple utf8 string + if null_to_newline: + data = _byte_array_replace(data, 0, preferred_newline) + ret = data.slice(1).get_string_from_utf8() + + return ret + +# ## This method is intended to be private. +# ## Gets a [String] from a USC formated [Array] of bytes. +# ## Assumes that the given [param bytes] are USC formated (does not check). +func _get_string_from_ucs2(bytes: Array, null_to_newline:bool = false) -> String: + var s:String = "" + var idx:int = 0 + while idx < (bytes.size() - 1): + var c = bytes[idx] + 256 * bytes[idx + 1] + if null_to_newline and c == 0: + c = preferred_newline + c = char(c) + s += c + idx += 2 + return s + +# ## This method is intended to be private. +# ## Replaces a instance of byte '[param this]' with the byte '[param with]' in the byte array. +# ## Instead of modifying the original [param byte_array], this returns a modified copy. +func _byte_array_replace(byte_array:PackedByteArray, this:int, with:int) -> PackedByteArray: + byte_array = byte_array.duplicate() + while byte_array.has(this): + var ind = byte_array.find(this) + byte_array[ind] = with + return byte_array + +# ## This method is intended to be private. +# ## Converts a given [Array] of [param bytes] into a [int], +# ## also accounting for a syncsafe formated int when [param is_syncsafe] is set. +func _bytes_to_int(bytes: Array, is_syncsafe = true) -> int: + # Syncsafe uses 0x80 multiplier otherwise use 0x100 multiplier + var mult:int = 0x80 if is_syncsafe else 0x100 + var n:int = 0 + for byte in bytes: + n *= mult + n += byte + return n |