From a7505ee0bcb6881b86aeade03b12ab12089aeca8 Mon Sep 17 00:00:00 2001
From: Ryan Braganza <ryan.braganza@gmail.com>
Date: Tue, 27 Aug 2024 20:22:09 +1000
Subject: [PATCH] Add Web MIDI support

Co-authored-by: Adam Scott <ascott.ca@gmail.com>
Co-authored-by: A Thousand Ships <96648715+AThousandShips@users.noreply.github.com>
---
 doc/classes/InputEventMIDI.xml                |  1 +
 doc/classes/OS.xml                            | 10 +-
 platform/web/SCsub                            |  2 +
 platform/web/godot_midi.h                     | 51 ++++++++++
 platform/web/js/libs/library_godot_webmidi.js | 94 ++++++++++++++++++
 platform/web/os_web.h                         |  3 +
 platform/web/webmidi_driver.cpp               | 98 +++++++++++++++++++
 platform/web/webmidi_driver.h                 | 61 ++++++++++++
 8 files changed, 317 insertions(+), 3 deletions(-)
 create mode 100644 platform/web/godot_midi.h
 create mode 100644 platform/web/js/libs/library_godot_webmidi.js
 create mode 100644 platform/web/webmidi_driver.cpp
 create mode 100644 platform/web/webmidi_driver.h

diff --git a/doc/classes/InputEventMIDI.xml b/doc/classes/InputEventMIDI.xml
index 4dcaf987473e..c3b49408c20c 100644
--- a/doc/classes/InputEventMIDI.xml
+++ b/doc/classes/InputEventMIDI.xml
@@ -58,6 +58,7 @@
 		[/csharp]
 		[/codeblocks]
 		[b]Note:[/b] Godot does not support MIDI output, so there is no way to emit MIDI messages from Godot. Only MIDI input is supported.
+		[b]Note:[/b] On the Web platform, using MIDI input requires a browser permission to be granted first. This permission request is performed when calling [method OS.open_midi_inputs]. MIDI input will not work until the user accepts the permission request.
 	</description>
 	<tutorials>
 		<link title="MIDI Message Status Byte List">https://www.midi.org/specifications-old/item/table-2-expanded-messages-list-status-bytes</link>
diff --git a/doc/classes/OS.xml b/doc/classes/OS.xml
index 669c71778d2e..9ab37b1482f9 100644
--- a/doc/classes/OS.xml
+++ b/doc/classes/OS.xml
@@ -23,7 +23,7 @@
 			<return type="void" />
 			<description>
 				Shuts down the system MIDI driver. Godot will no longer receive [InputEventMIDI]. See also [method open_midi_inputs] and [method get_connected_midi_inputs].
-				[b]Note:[/b] This method is implemented on Linux, macOS, and Windows.
+				[b]Note:[/b] This method is implemented on Linux, macOS, Windows, and Web.
 			</description>
 		</method>
 		<method name="crash">
@@ -244,7 +244,9 @@
 			<return type="PackedStringArray" />
 			<description>
 				Returns an array of connected MIDI device names, if they exist. Returns an empty array if the system MIDI driver has not previously been initialized with [method open_midi_inputs]. See also [method close_midi_inputs].
-				[b]Note:[/b] This method is implemented on Linux, macOS, and Windows.
+				[b]Note:[/b] This method is implemented on Linux, macOS, Windows, and Web.
+				[b]Note:[/b] On the Web platform, Web MIDI needs to be supported by the browser. [url=https://caniuse.com/midi]For the time being[/url], it is currently supported by all major browsers, except Safari.
+				[b]Note:[/b] On the Web platform, using MIDI input requires a browser permission to be granted first. This permission request is performed when calling [method open_midi_inputs]. The browser will refrain from processing MIDI input until the user accepts the permission request.
 			</description>
 		</method>
 		<method name="get_data_dir" qualifiers="const">
@@ -704,7 +706,9 @@
 			<return type="void" />
 			<description>
 				Initializes the singleton for the system MIDI driver, allowing Godot to receive [InputEventMIDI]. See also [method get_connected_midi_inputs] and [method close_midi_inputs].
-				[b]Note:[/b] This method is implemented on Linux, macOS, and Windows.
+				[b]Note:[/b] This method is implemented on Linux, macOS, Windows, and Web.
+				[b]Note:[/b] On the Web platform, Web MIDI needs to be supported by the browser. [url=https://caniuse.com/midi]For the time being[/url], it is currently supported by all major browsers, except Safari.
+				[b]Note:[/b] On the Web platform, using MIDI input requires a browser permission to be granted first. This permission request is performed when calling [method open_midi_inputs]. The browser will refrain from processing MIDI input until the user accepts the permission request.
 			</description>
 		</method>
 		<method name="read_buffer_from_stdin">
diff --git a/platform/web/SCsub b/platform/web/SCsub
index 9a2eea9e07b3..7ba12eecb96b 100644
--- a/platform/web/SCsub
+++ b/platform/web/SCsub
@@ -22,6 +22,7 @@ if "serve" in COMMAND_LINE_TARGETS or "run" in COMMAND_LINE_TARGETS:
 
 web_files = [
     "audio_driver_web.cpp",
+    "webmidi_driver.cpp",
     "display_server_web.cpp",
     "http_client_web.cpp",
     "javascript_bridge_singleton.cpp",
@@ -38,6 +39,7 @@ sys_env.AddJSLibraries(
         "js/libs/library_godot_audio.js",
         "js/libs/library_godot_display.js",
         "js/libs/library_godot_fetch.js",
+        "js/libs/library_godot_webmidi.js",
         "js/libs/library_godot_os.js",
         "js/libs/library_godot_runtime.js",
         "js/libs/library_godot_input.js",
diff --git a/platform/web/godot_midi.h b/platform/web/godot_midi.h
new file mode 100644
index 000000000000..db8de3ec23c7
--- /dev/null
+++ b/platform/web/godot_midi.h
@@ -0,0 +1,51 @@
+/**************************************************************************/
+/*  godot_midi.h                                                          */
+/**************************************************************************/
+/*                         This file is part of:                          */
+/*                             GODOT ENGINE                               */
+/*                        https://godotengine.org                         */
+/**************************************************************************/
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.                  */
+/*                                                                        */
+/* 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.                 */
+/**************************************************************************/
+
+#ifndef GODOT_MIDI_H
+#define GODOT_MIDI_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#include <stdint.h>
+extern int godot_js_webmidi_open_midi_inputs(
+		void (*p_callback)(int p_size, const char **p_connected_input_names),
+		void (*p_on_midi_message)(int p_device_index, int p_status, const uint8_t *p_data, int p_data_len),
+		const uint8_t *p_data_buffer,
+		const int p_data_buffer_len);
+
+extern void godot_js_webmidi_close_midi_inputs();
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // GODOT_MIDI_H
diff --git a/platform/web/js/libs/library_godot_webmidi.js b/platform/web/js/libs/library_godot_webmidi.js
new file mode 100644
index 000000000000..1c94b1559fcd
--- /dev/null
+++ b/platform/web/js/libs/library_godot_webmidi.js
@@ -0,0 +1,94 @@
+/**************************************************************************/
+/*  library_godot_webmidi.js                                              */
+/**************************************************************************/
+/*                         This file is part of:                          */
+/*                             GODOT ENGINE                               */
+/*                        https://godotengine.org                         */
+/**************************************************************************/
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.                  */
+/*                                                                        */
+/* 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.                 */
+/**************************************************************************/
+
+const GodotWebMidi = {
+
+	$GodotWebMidi__deps: ['$GodotRuntime'],
+	$GodotWebMidi: {
+		abortControllers: [],
+		isListening: false,
+	},
+
+	godot_js_webmidi_open_midi_inputs__deps: ['$GodotWebMidi'],
+	godot_js_webmidi_open_midi_inputs__proxy: 'sync',
+	godot_js_webmidi_open_midi_inputs__sig: 'iiii',
+	godot_js_webmidi_open_midi_inputs: function (pSetInputNamesCb, pOnMidiMessageCb, pDataBuffer, dataBufferLen) {
+		if (GodotWebMidi.is_listening) {
+			return 0; // OK
+		}
+		if (!navigator.requestMIDIAccess) {
+			return 2; // ERR_UNAVAILABLE
+		}
+		const setInputNamesCb = GodotRuntime.get_func(pSetInputNamesCb);
+		const onMidiMessageCb = GodotRuntime.get_func(pOnMidiMessageCb);
+
+		GodotWebMidi.isListening = true;
+		navigator.requestMIDIAccess().then((midi) => {
+			const inputs = [...midi.inputs.values()];
+			const inputNames = inputs.map((input) => input.name);
+
+			const c_ptr = GodotRuntime.allocStringArray(inputNames);
+			setInputNamesCb(inputNames.length, c_ptr);
+			GodotRuntime.freeStringArray(c_ptr, inputNames.length);
+
+			inputs.forEach((input, i) => {
+				const abortController = new AbortController();
+				GodotWebMidi.abortControllers.push(abortController);
+				input.addEventListener('midimessage', (event) => {
+					const status = event.data[0];
+					const data = event.data.slice(1);
+					const size = data.length;
+
+					if (size > dataBufferLen) {
+						throw new Error(`data too big ${size} > ${dataBufferLen}`);
+					}
+					HEAPU8.set(data, pDataBuffer);
+
+					onMidiMessageCb(i, status, pDataBuffer, data.length);
+				}, { signal: abortController.signal });
+			});
+		});
+
+		return 0; // OK
+	},
+
+	godot_js_webmidi_close_midi_inputs__deps: ['$GodotWebMidi'],
+	godot_js_webmidi_close_midi_inputs__proxy: 'sync',
+	godot_js_webmidi_close_midi_inputs__sig: 'v',
+	godot_js_webmidi_close_midi_inputs: function () {
+		for (const abortController of GodotWebMidi.abortControllers) {
+			abortController.abort();
+		}
+		GodotWebMidi.abortControllers = [];
+		GodotWebMidi.isListening = false;
+	},
+};
+
+mergeInto(LibraryManager.library, GodotWebMidi);
diff --git a/platform/web/os_web.h b/platform/web/os_web.h
index 1ddb745965ac..e9040f04d4d3 100644
--- a/platform/web/os_web.h
+++ b/platform/web/os_web.h
@@ -32,6 +32,7 @@
 #define OS_WEB_H
 
 #include "audio_driver_web.h"
+#include "webmidi_driver.h"
 
 #include "godot_js.h"
 
@@ -45,6 +46,8 @@ class OS_Web : public OS_Unix {
 	MainLoop *main_loop = nullptr;
 	List<AudioDriverWeb *> audio_drivers;
 
+	MIDIDriverWebMidi midi_driver;
+
 	bool idb_is_syncing = false;
 	bool idb_available = false;
 	bool idb_needs_sync = false;
diff --git a/platform/web/webmidi_driver.cpp b/platform/web/webmidi_driver.cpp
new file mode 100644
index 000000000000..51c58344419d
--- /dev/null
+++ b/platform/web/webmidi_driver.cpp
@@ -0,0 +1,98 @@
+/**************************************************************************/
+/*  webmidi_driver.cpp                                                    */
+/**************************************************************************/
+/*                         This file is part of:                          */
+/*                             GODOT ENGINE                               */
+/*                        https://godotengine.org                         */
+/**************************************************************************/
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.                  */
+/*                                                                        */
+/* 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.                 */
+/**************************************************************************/
+
+#include "webmidi_driver.h"
+
+#ifdef PROXY_TO_PTHREAD_ENABLED
+#include "core/object/callable_method_pointer.h"
+#endif
+
+MIDIDriverWebMidi *MIDIDriverWebMidi::get_singleton() {
+	return static_cast<MIDIDriverWebMidi *>(MIDIDriver::get_singleton());
+}
+
+Error MIDIDriverWebMidi::open() {
+	Error error = (Error)godot_js_webmidi_open_midi_inputs(&MIDIDriverWebMidi::set_input_names_callback, &MIDIDriverWebMidi::on_midi_message, _event_buffer, MIDIDriverWebMidi::MAX_EVENT_BUFFER_LENGTH);
+	if (error == ERR_UNAVAILABLE) {
+		ERR_PRINT("Web MIDI is not supported on this browser.");
+	}
+	return error;
+}
+
+void MIDIDriverWebMidi::close() {
+	get_singleton()->connected_input_names.clear();
+	godot_js_webmidi_close_midi_inputs();
+}
+
+MIDIDriverWebMidi::~MIDIDriverWebMidi() {
+	close();
+}
+
+void MIDIDriverWebMidi::set_input_names_callback(int p_size, const char **p_input_names) {
+	Vector<String> input_names;
+	for (int i = 0; i < p_size; i++) {
+		input_names.append(String::utf8(p_input_names[i]));
+	}
+#ifdef PROXY_TO_PTHREAD_ENABLED
+	if (!Thread::is_main_thread()) {
+		callable_mp_static(MIDIDriverWebMidi::_set_input_names_callback).call_deferred(input_names);
+		return;
+	}
+#endif
+
+	_set_input_names_callback(input_names);
+}
+
+void MIDIDriverWebMidi::_set_input_names_callback(const Vector<String> &p_input_names) {
+	get_singleton()->connected_input_names.clear();
+	for (const String &input_name : p_input_names) {
+		get_singleton()->connected_input_names.push_back(input_name);
+	}
+}
+
+void MIDIDriverWebMidi::on_midi_message(int p_device_index, int p_status, const uint8_t *p_data, int p_data_len) {
+	PackedByteArray data;
+	data.resize(p_data_len);
+	uint8_t *data_ptr = data.ptrw();
+	for (int i = 0; i < p_data_len; i++) {
+		data_ptr[i] = p_data[i];
+	}
+#ifdef PROXY_TO_PTHREAD_ENABLED
+	if (!Thread::is_main_thread()) {
+		callable_mp_static(MIDIDriverWebMidi::_on_midi_message).call_deferred(p_device_index, p_status, data, p_data_len);
+		return;
+	}
+#endif
+	_on_midi_message(p_device_index, p_status, data, p_data_len);
+}
+
+void MIDIDriverWebMidi::_on_midi_message(int p_device_index, int p_status, const PackedByteArray &p_data, int p_data_len) {
+	MIDIDriver::send_event(p_device_index, p_status, p_data.ptr(), p_data_len);
+}
diff --git a/platform/web/webmidi_driver.h b/platform/web/webmidi_driver.h
new file mode 100644
index 000000000000..00c7df1ae776
--- /dev/null
+++ b/platform/web/webmidi_driver.h
@@ -0,0 +1,61 @@
+/**************************************************************************/
+/*  webmidi_driver.h                                                      */
+/**************************************************************************/
+/*                         This file is part of:                          */
+/*                             GODOT ENGINE                               */
+/*                        https://godotengine.org                         */
+/**************************************************************************/
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.                  */
+/*                                                                        */
+/* 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.                 */
+/**************************************************************************/
+
+#ifndef WEBMIDI_DRIVER_H
+#define WEBMIDI_DRIVER_H
+
+#include "core/os/midi_driver.h"
+
+#include "godot_js.h"
+#include "godot_midi.h"
+
+class MIDIDriverWebMidi : public MIDIDriver {
+private:
+	static const int MAX_EVENT_BUFFER_LENGTH = 2;
+	uint8_t _event_buffer[MAX_EVENT_BUFFER_LENGTH];
+
+public:
+	// Override return type to make writing static callbacks less tedious.
+	static MIDIDriverWebMidi *get_singleton();
+
+	virtual Error open() override;
+	virtual void close() override final;
+
+	MIDIDriverWebMidi() = default;
+	virtual ~MIDIDriverWebMidi();
+
+	WASM_EXPORT static void set_input_names_callback(int p_size, const char **p_input_names);
+	static void _set_input_names_callback(const Vector<String> &p_input_names);
+
+	WASM_EXPORT static void on_midi_message(int p_device_index, int p_status, const uint8_t *p_data, int p_data_len);
+	static void _on_midi_message(int p_device_index, int p_status, const PackedByteArray &p_data, int p_data_len);
+};
+
+#endif // WEBMIDI_DRIVER_H