From 09d13984a1ddd93ca77e188cca1b1373a2c24e9c Mon Sep 17 00:00:00 2001 From: Leon van Kammen Date: Thu, 16 May 2024 19:11:43 +0200 Subject: [PATCH] feat/godot: src audio handler works --- example/godot/AudioLoader.gd | 250 +++++++++++++++++++++++++++++++++++ example/godot/main.gd | 4 +- example/godot/xrfragment.gd | 138 +++++++------------ 3 files changed, 298 insertions(+), 94 deletions(-) create mode 100644 example/godot/AudioLoader.gd diff --git a/example/godot/AudioLoader.gd b/example/godot/AudioLoader.gd new file mode 100644 index 0000000..2041438 --- /dev/null +++ b/example/godot/AudioLoader.gd @@ -0,0 +1,250 @@ +# GDScriptAudioImport v0.1 +# https://github.com/Gianclgar/GDScriptAudioImport/pull/20 +# MIT License +# +# Copyright (c) 2020 Gianclgar (Giannino Clemente) gianclgar@gmail.com +# +#Permission is hereby granted, free of charge, to any person obtaining a copy +#of this software and associated documentation files (the "Software"), to deal +#in the Software without restriction, including without limitation the rights +#to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +#copies of the Software, and to permit persons to whom the Software is +#furnished to do so, subject to the following conditions: +# +#The above copyright notice and this permission notice shall be included in all +#copies or substantial portions of the Software. +# +#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +#IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +#FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +#AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +#LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +#OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +#SOFTWARE. + +#I honestly don't care that much, Kopimi ftw, but it's my little baby and I want it to look nice :3 + +class_name AudioLoader + +func report_errors(err, filepath): + # See: https://docs.godotengine.org/en/latest/classes/class_@globalscope.html#enum-globalscope-error + var result_hash = { + ERR_FILE_NOT_FOUND: "File: not found", + ERR_FILE_BAD_DRIVE: "File: Bad drive error", + ERR_FILE_BAD_PATH: "File: Bad path error.", + ERR_FILE_NO_PERMISSION: "File: No permission error.", + ERR_FILE_ALREADY_IN_USE: "File: Already in use error.", + ERR_FILE_CANT_OPEN: "File: Can't open error.", + ERR_FILE_CANT_WRITE: "File: Can't write error.", + ERR_FILE_CANT_READ: "File: Can't read error.", + ERR_FILE_UNRECOGNIZED: "File: Unrecognized error.", + ERR_FILE_CORRUPT: "File: Corrupt error.", + ERR_FILE_MISSING_DEPENDENCIES: "File: Missing dependencies error.", + ERR_FILE_EOF: "File: End of file (EOF) error." + } + if err in result_hash: + print("Error: ", result_hash[err], " ", filepath) + else: + print("Unknown error with file ", filepath, " error code: ", err) + +func loadfile(filepath, bytes = null): + var file = null + if bytes == null: + file = FileAccess.open(filepath, FileAccess.READ) + var err = file.get_error() + if err != OK: + report_errors(err, filepath) + file.close() + return AudioStreamWAV.new() + bytes = file.get_buffer(file.get_length()) + + # if File is wav + if filepath.ends_with(".wav"): + var newstream = AudioStreamWAV.new() + + #--------------------------- + #parrrrseeeeee!!! :D + + var bits_per_sample = 0 + + for i in range(0, 100): + var those4bytes = str(char(bytes[i])+char(bytes[i+1])+char(bytes[i+2])+char(bytes[i+3])) + + if those4bytes == "RIFF": + print ("RIFF OK at bytes " + str(i) + "-" + str(i+3)) + #RIP bytes 4-7 integer for now + if those4bytes == "WAVE": + print ("WAVE OK at bytes " + str(i) + "-" + str(i+3)) + + if those4bytes == "fmt ": + print ("fmt OK at bytes " + str(i) + "-" + str(i+3)) + + #get format subchunk size, 4 bytes next to "fmt " are an int32 + var formatsubchunksize = bytes[i+4] + (bytes[i+5] << 8) + (bytes[i+6] << 16) + (bytes[i+7] << 24) + print ("Format subchunk size: " + str(formatsubchunksize)) + + #using formatsubchunk index so it's easier to understand what's going on + var fsc0 = i+8 #fsc0 is byte 8 after start of "fmt " + + #get format code [Bytes 0-1] + var format_code = bytes[fsc0] + (bytes[fsc0+1] << 8) + var format_name + if format_code == 0: format_name = "8_BITS" + elif format_code == 1: format_name = "16_BITS" + elif format_code == 2: format_name = "IMA_ADPCM" + else: + format_name = "UNKNOWN (trying to interpret as 16_BITS)" + format_code = 1 + print ("Format: " + str(format_code) + " " + format_name) + #assign format to our AudioStreamSample + newstream.format = format_code + + #get channel num [Bytes 2-3] + var channel_num = bytes[fsc0+2] + (bytes[fsc0+3] << 8) + print ("Number of channels: " + str(channel_num)) + #set our AudioStreamSample to stereo if needed + if channel_num == 2: newstream.stereo = true + + #get sample rate [Bytes 4-7] + var sample_rate = bytes[fsc0+4] + (bytes[fsc0+5] << 8) + (bytes[fsc0+6] << 16) + (bytes[fsc0+7] << 24) + print ("Sample rate: " + str(sample_rate)) + #set our AudioStreamSample mixrate + newstream.mix_rate = sample_rate + + #get byte_rate [Bytes 8-11] because we can + var byte_rate = bytes[fsc0+8] + (bytes[fsc0+9] << 8) + (bytes[fsc0+10] << 16) + (bytes[fsc0+11] << 24) + print ("Byte rate: " + str(byte_rate)) + + #same with bits*sample*channel [Bytes 12-13] + var bits_sample_channel = bytes[fsc0+12] + (bytes[fsc0+13] << 8) + print ("BitsPerSample * Channel / 8: " + str(bits_sample_channel)) + + #aaaand bits per sample/bitrate [Bytes 14-15] + bits_per_sample = bytes[fsc0+14] + (bytes[fsc0+15] << 8) + print ("Bits per sample: " + str(bits_per_sample)) + + if those4bytes == "data": + assert(bits_per_sample != 0) + + var audio_data_size = bytes[i+4] + (bytes[i+5] << 8) + (bytes[i+6] << 16) + (bytes[i+7] << 24) + print ("Audio data/stream size is " + str(audio_data_size) + " bytes") + + var data_entry_point = (i+8) + print ("Audio data starts at byte " + str(data_entry_point)) + + var data = bytes.slice(data_entry_point, data_entry_point + audio_data_size) + + if bits_per_sample in [24, 32]: + newstream.data = convert_to_16bit(data, bits_per_sample) + else: + newstream.data = data + # end of parsing + #--------------------------- + + #Calculate the size of each sample based on bits_per_sample + var sample_size = bits_per_sample / 8 + #get samples and set loop end + var samplenum = newstream.data.size() / sample_size + newstream.loop_end = samplenum + newstream.loop_mode = 0 #change to 0 or delete this line if you don't want loop, also check out modes 2 and 3 in the docs + return newstream #:D + + #if file is ogg + elif filepath.ends_with(".ogg"): + var newstream = AudioStreamOggVorbis.load_from_buffer(bytes) + newstream.loop = true #set to false or delete this line if you don't want to loop + return newstream + + #if file is mp3 + elif filepath.ends_with(".mp3"): + var newstream = AudioStreamMP3.new() + newstream.loop = true #set to false or delete this line if you don't want to loop + newstream.data = bytes + return newstream + + else: + print ("ERROR: Wrong filetype or format") + if file != null: + file.close() + +# Converts .wav data from 24 or 32 bits to 16 +# +# These conversions are SLOW in GDScript +# on my one test song, 32 -> 16 was around 3x slower than 24 -> 16 +# +# I couldn't get threads to help very much +# They made the 24bit case about 2x faster in my test file +# And the 32bit case abour 50% slower +# I don't wanna risk it always being slower on other files +# And really, the solution would be to handle it in a low-level language +func convert_to_16bit(data: PackedByteArray, from: int) -> PackedByteArray: + print("converting to 16-bit from %d" % from) + var time = Time.get_ticks_msec() + if from == 24: + var j = 0 + for i in range(0, data.size(), 3): + data[j] = data[i+1] + data[j+1] = data[i+2] + j += 2 + data.resize(data.size() * 2 / 3) + if from == 32: + var spb := StreamPeerBuffer.new() + var single_float: float + var value: int + for i in range(0, data.size(), 4): + var sub_array = data.slice(i, i+4) + spb.data_array = PackedByteArray(sub_array) + single_float = spb.get_float() + value = single_float * 32768 + data[i/2] = value + data[i/2+1] = value >> 8 + data.resize(data.size() / 2) + print("Took %f seconds for slow conversion" % ((Time.get_ticks_msec() - time) / 1000.0)) + return data + +# ---------- REFERENCE --------------- +# note: typical values doesn't always match + +#Positions Typical Value Description +# +#1 - 4 "RIFF" Marks the file as a RIFF multimedia file. +# Characters are each 1 byte long. +# +#5 - 8 (integer) The overall file size in bytes (32-bit integer) +# minus 8 bytes. Typically, you'd fill this in after +# file creation is complete. +# +#9 - 12 "WAVE" RIFF file format header. For our purposes, it +# always equals "WAVE". +# +#13-16 "fmt " Format sub-chunk marker. Includes trailing null. +# +#17-20 16 Length of the rest of the format sub-chunk below. +# +#21-22 1 Audio format code, a 2 byte (16 bit) integer. +# 1 = PCM (pulse code modulation). +# +#23-24 2 Number of channels as a 2 byte (16 bit) integer. +# 1 = mono, 2 = stereo, etc. +# +#25-28 44100 Sample rate as a 4 byte (32 bit) integer. Common +# values are 44100 (CD), 48000 (DAT). Sample rate = +# number of samples per second, or Hertz. +# +#29-32 176400 (SampleRate * BitsPerSample * Channels) / 8 +# This is the Byte rate. +# +#33-34 4 (BitsPerSample * Channels) / 8 +# 1 = 8 bit mono, 2 = 8 bit stereo or 16 bit mono, 4 +# = 16 bit stereo. +# +#35-36 16 Bits per sample. +# +#37-40 "data" Data sub-chunk header. Marks the beginning of the +# raw data section. +# +#41-44 (integer) The number of bytes of the data section below this +# point. Also equal to (#ofSamples * #ofChannels * +# BitsPerSample) / 8 +# +#45+ The raw audio data. diff --git a/example/godot/main.gd b/example/godot/main.gd index 83a08d1..a886514 100644 --- a/example/godot/main.gd +++ b/example/godot/main.gd @@ -7,11 +7,11 @@ var player:CharacterBody3D func _ready(): xrf = preload("res://xrfragment.gd").new() - print( xrf.parseURL2("https://foo.com/abc.gltf#foo=2") ) - return xrf.src.addExtension.call("wav", xrf.src.audio ) # extensible support for xrf.src.addExtension.call("ogg", xrf.src.audio ) # src-metadata (a la carte) + xrf.src.addExtension.call("mp3", xrf.src.audio ) # + add_child(xrf) #xrf.to("https://xrfragment.org/other.glb", _onXRF ) xrf.to("http://localhost:8080/example/assets/other.glb", _onXRF ) diff --git a/example/godot/xrfragment.gd b/example/godot/xrfragment.gd index 1eb7601..af05efb 100644 --- a/example/godot/xrfragment.gd +++ b/example/godot/xrfragment.gd @@ -1,5 +1,7 @@ # https://xrfragment.org" # SPDX-License-Identifier: MPL-2.0" +# author: Leon van Kammen +# date: 16-05-2024 extends Node @@ -32,102 +34,47 @@ func _process(delta): #################################################################################################### # URI Related functions +# based on https://gist.github.com/coderofsalvation/b2b111a2631fbdc8e76d6cab3bea8f17 #################################################################################################### - -func parseURL2(url: String) -> Dictionary: - var regex:RegEx = RegEx.new() - regex.compile("\\S+") # Negated whitespace character class. - var results = [] - for result in regex.search_all(url.replace("/"," ")): - results.push_back(result.get_string()) - print(results) - - return {} # Return empty dictionary if URL doesn't match - - var groups = regex.get_group_list(url) - var parsed_url = { - "source": url, - "scheme": groups[1] ||"", # Use nullish coalescing for optional scheme - "authority": groups[2] ||"", - "userInfo": (groups[4]||"") + ":" + (groups[5]||""), # Combine user and password - "user": groups[4] ||"", - "password": groups[5] ||"", - "host": groups[6] ||"", - "port": groups[7] || "", # Convert port to int or default to 0 - "relative": groups[8] ||"", - "path": groups[9] ||"", - "directory": (groups[9]||"").split("/")[0] || "", # Extract directory path - "file": groups[9].split("/")[-1] ||"", # Extract filename - "query": groups[10] ||"", - "fragment": groups[11] ||"", - } - return parsed_url - func parseURL(url: String) -> Dictionary: - var URI = {"string":url, "next": null} - - # Split URL by '://' to get protocol and the rest of the URL - var parts = url.split("://") - if parts.size() > 1: - URI["protocol"] = parts[0] - url = parts[1] - else: - URI["protocol"] = "http" # Default to http if protocol is missing - - # Split URL by '/' to separate domain, path, and file - parts = url.split("/") - URI["domain"] = parts[0] - parts.remove_at(0) - - if parts.size() > 0: - var path_and_file = "/".join(parts) - path_and_file = path_and_file.split("?")[0] - path_and_file = path_and_file.split("#")[0] - var path_and_file_parts = path_and_file.split("/") - if path_and_file_parts.size() > 1: - URI["path"] = "/".join(path_and_file_parts) - else: - URI["path"] = path_and_file - - if !URI.has("path"): - URI["path"] = self.URI.path # copy from current URI - if !URI.has("domain"): - URI["domain"] = self.URI.domain - - # Check if there's a query string - if url.find("?") != -1: - parts = url.split("?") - URI["path"] = parts[0] - var args = parts[1] - if args.find("#"): - args = args.split("#")[0] - URI["query"] = parseArgs(args) - else: - URI["query"] = {} - - # Check if there's a fragment - if url.find("#") != -1: - parts = url.split("#") - URI["fragment"] = parseArgs(parts[1]) - else: - URI["fragment"] = {} - URI['isLocal'] = url[0] == '#' + var URI = {"domain":"","fragment":"","file":"","URN":""} + var parts = ["string","protocol","path","query","hash"] + var urlregex = RegEx.new() + urlregex.compile("(\\w+:\\/\\/)?([^#\\?]+)?(\\?[^#]+)?(#.*)?") + var match = urlregex.search(url) + for i in range(0,parts.size()): + URI[ parts[i] ] = match.strings[i] if match.strings[i] else "" + if URI["path"]: + var pathParts:Array = URI["path"].split("/") + if pathParts.size() > 1 and (pathParts[0].find(".") != -1 || pathParts[0].find(":") != -1): + URI["domain"] = pathParts.pop_front() + URI["path"] = "/".join(pathParts) + pathParts = URI["path"].split("/") + if pathParts[-1].find(".") != -1: + URI["file"] = pathParts[-1] + URI["path"] = "/".join(pathParts) + URI["protocol"] = URI["protocol"].replace("://","") if URI["protocol"] else "" + URI["fragment"] = parseArgs( URI["hash"].substr(1) ) if URI["hash"] else {} + URI["query"] = parseArgs( URI["query"].substr(1) ) if URI["query"] else {} + URI["URN"] = URI["string"].replace("\\?.*","") if URI["domain"] else "" + URI["isLocal"] = true if !URI["domain"] else false + # make relative URL's absolute + if URI["isLocal"]: + URI["domain"] = self.URI["domain"] + URI["protocol"] = self.URI["protocol"] + if URI["path"].match("\\/"): + URI["path"] = self.URI["path"] + URI["path"] return URI func parseArgs(fragment: String) -> Dictionary: var ARG = {} - - # Split fragment by '&' to separate items var items = fragment.split("&") - for item in items: - # Split item by '=' to separate key and value var key_value = item.split("=") if key_value.size() > 1: ARG[key_value[0]] = guess_type(key_value[1]) else: ARG[key_value[0]] = "" - return ARG func guess_type(str: String) -> Dictionary: @@ -170,6 +117,7 @@ func forward(): # Download model by HTTP and run `downloadModelSuccess` if OK func to(url, f:Callable ): print("navigating to "+url) + cleanup() var URI = self.parseURL(url) callback = f @@ -193,9 +141,10 @@ func fetchURL(url:String, f:Callable) -> HTTPRequest: var http_request = HTTPRequest.new() _orphans.push_back(http_request) add_child(http_request) - http_request.request_completed.connect(downloadModelSuccess) + http_request.request_completed.connect(f) var error = http_request.request(url) if error != OK: + print("could not request "+url) push_error("An error occurred in the HTTP request.") return http_request @@ -216,7 +165,6 @@ func downloadModelSuccess(result, response_code, headers, body): traverse( scene, href.init ) traverse( scene, src.init ) setPredefinedSceneView() - cleanup() callback.call("scene_loaded", scene) func loadModelFromBufferByGLTFDocument(body): @@ -388,16 +336,22 @@ var src = { for ext in src.extension: _regex.compile("^.*."+ext+"$") if _regex.search(XRF.src.path): - var url:String = XRF.src.protocol+"://"+XRF.src.domain+"/"+XRF.src.path + var url:String = XRF.src.protocol+"://"+XRF.src.domain+XRF.src.path print("src: fetching "+url) - print(XRF.src) - fetchURL(url, src.extension[ext] ) + var handler:Callable = src.extension[ext].call(node,ext) + fetchURL(url, handler ) callback.call("src", {"node":node,"XRF":XRF} ), # some builtin handlers - "audio": func audio(node:Node, extension:String): + "audio": func audio(node:Node, extension:String) -> Callable: return func onFile(result, response_code, headers, body): - var src = node.XRF.src - print(src.string+" audioooo "+extension+" "+response_code) - + var src = node.get_meta("XRF").src + var music = AudioStreamPlayer.new() + add_child(music) + var audio_loader = AudioLoader.new() + music.set_stream( audio_loader.loadfile( src.file, body ) ) + music.volume_db = 1 + music.pitch_scale = 1 + music.play() + add_child(music) }