aboutsummaryrefslogtreecommitdiff
path: root/game/addons/MusicMetadata/MusicMetadata.gd
diff options
context:
space:
mode:
author Nemrav <50055236+Nemrav@users.noreply.github.com>2024-08-06 01:48:36 +0200
committer GitHub <noreply@github.com>2024-08-06 01:48:36 +0200
commit82b16bcca7c74607a8885b882ec36f5202e7ef70 (patch)
tree0a9bd4f52c01315c3b38ce641a78c33bd8562be2 /game/addons/MusicMetadata/MusicMetadata.gd
parentfde15e554dc9ed458a838683c69d10262764db12 (diff)
parent9506f4160f0bd351f0853e6e8263ea927d9ec771 (diff)
Merge pull request #243 from Nemrav/music
Music and sound effects
Diffstat (limited to 'game/addons/MusicMetadata/MusicMetadata.gd')
-rw-r--r--game/addons/MusicMetadata/MusicMetadata.gd545
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