// Bootleg MIDI bridge for the Vajcsi soundboard: plays MIDI pads on the Discord bot without a browser. // Build with desktop\bootleg\build.cmd, which uses the .NET Framework compiler that ships with Windows. // C# 5 only: that compiler predates string interpolation and other newer syntax. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Web.Script.Serialization; [assembly: AssemblyTitle("bootleg-do-not-click")] [assembly: AssemblyProduct("Vajcsi soundboard MIDI bridge")] [assembly: AssemblyVersion("1.0.0.0")] class Binding { public string Type; public int Channel; public int Number; public string ClipId; public string Name; public bool Matches(Binding other) { return other != null && Type == other.Type && Channel == other.Channel && Number == other.Number; } public string Label() { string[] notes = { "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" }; string channel = " ch " + (Channel + 1); if (Type == "note") return "Note " + notes[Number % 12] + (Number / 12 - 1) + " (" + Number + ")" + channel; if (Type == "cc") return "CC " + Number + channel; return "Program " + (Number + 1) + channel; } } static class Bootleg { delegate void MidiInProc(IntPtr handle, uint message, IntPtr instance, IntPtr param1, IntPtr param2); [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] struct MidiInCaps { public ushort Manufacturer; public ushort Product; public uint DriverVersion; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string Name; public uint Support; } [DllImport("winmm.dll")] static extern uint midiInGetNumDevs(); [DllImport("winmm.dll", CharSet = CharSet.Unicode, EntryPoint = "midiInGetDevCapsW")] static extern uint midiInGetDevCaps(UIntPtr device, out MidiInCaps caps, uint size); [DllImport("winmm.dll")] static extern uint midiInOpen(out IntPtr handle, uint device, MidiInProc callback, IntPtr instance, uint flags); [DllImport("winmm.dll")] static extern uint midiInStart(IntPtr handle); [DllImport("winmm.dll")] static extern uint midiInStop(IntPtr handle); [DllImport("winmm.dll")] static extern uint midiInClose(IntPtr handle); const uint CallbackFunction = 0x30000; const uint MimData = 0x3C3; static readonly MidiInProc Callback = OnMidi; // Kept in a field so the GC never collects it. static readonly BlockingCollection Messages = new BlockingCollection(); static readonly List Inputs = new List(); static readonly Dictionary ControlValues = new Dictionary(); static readonly object ConsoleLock = new object(); static readonly string SetupDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VajcsiBootleg"); static readonly string SavedSetup = Path.Combine(SetupDirectory, "setup.json"); static string apiBase; static string guildId; static List bindings = new List(); static Binding stopBinding; static int Main(string[] args) { Console.Title = "bootleg-do-not-click"; Console.OutputEncoding = Encoding.UTF8; ServicePointManager.SecurityProtocol = (SecurityProtocolType)3072; // TLS 1.2 Say(ConsoleColor.Yellow, " bootleg-do-not-click.exe -- you clicked it anyway."); Say(ConsoleColor.DarkGray, " Vajcsi soundboard MIDI bridge. Keep this window open; close it to stop.\n"); try { List simulated = new List(); string setupArgument = null; for (int index = 0; index < args.Length; index++) { if (args[index] == "--simulate" && index + 1 < args.Length) simulated.Add(args[++index]); else setupArgument = args[index]; } LoadSetup(setupArgument); RefreshGuild(); if (simulated.Count > 0) { foreach (string message in simulated) Handle(ParseHex(message), true); return 0; } OpenInputs(); new Thread(WatchDevices) { IsBackground = true }.Start(); foreach (int message in Messages.GetConsumingEnumerable()) Handle(message, false); return 0; } catch (Exception error) { Say(ConsoleColor.Red, "\n " + error.Message); Console.WriteLine("\n Press Enter to close."); Console.ReadLine(); return 1; } } static void Say(ConsoleColor color, string text) { lock (ConsoleLock) { Console.ForegroundColor = color; Console.WriteLine(text); Console.ResetColor(); } } // Setup comes from the MIDI panel's "Download setup file": the soundboard link plus pad bindings. static void LoadSetup(string argument) { string path = null; if (argument != null) { if (!File.Exists(argument)) throw new Exception("Setup file not found: " + argument); path = argument; } else { string downloads = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); FileInfo newest = Directory.Exists(downloads) ? new DirectoryInfo(downloads).GetFiles("bootleg-setup*.json").OrderByDescending(file => file.LastWriteTimeUtc).FirstOrDefault() : null; if (newest != null && (!File.Exists(SavedSetup) || newest.LastWriteTimeUtc > File.GetLastWriteTimeUtc(SavedSetup))) path = newest.FullName; else if (File.Exists(SavedSetup)) path = SavedSetup; } if (path == null) throw new Exception("No setup yet. Open your soundboard link in Chrome or Edge, click MIDI, then \"Download setup file\". Run me again afterwards (or drag the file onto me)."); var setup = new JavaScriptSerializer().DeserializeObject(File.ReadAllText(path, Encoding.UTF8)) as Dictionary; if (setup == null || !setup.ContainsKey("link")) throw new Exception("That setup file is not from the soundboard."); Match link = Regex.Match(Convert.ToString(setup["link"]), @"^(https?://[^/\s]+)/soundboard/bot/([0-9]{1,10}\.[A-Za-z0-9_-]{43})/?$"); if (!link.Success) throw new Exception("The setup file has no valid soundboard bot link. Download it again from the MIDI panel."); apiBase = link.Groups[1].Value + "/api/soundboard/bot/" + link.Groups[2].Value; Say(ConsoleColor.Gray, " Setup file: " + path); Say(ConsoleColor.Gray, " I only talk to " + link.Groups[1].Value + " and print a line for every request."); Say(ConsoleColor.Gray, " I read MIDI input and this setup file, nothing else: no keyboard, microphone or other files.\n"); bindings = new List(); object[] list = setup.ContainsKey("bindings") ? setup["bindings"] as object[] : null; if (list != null) foreach (object item in list) { Binding binding = ReadBinding(item as Dictionary); if (binding != null && binding.ClipId != null && Regex.IsMatch(binding.ClipId, "^[a-f0-9]{32}$")) bindings.Add(binding); } stopBinding = setup.ContainsKey("stop") ? ReadBinding(setup["stop"] as Dictionary) : null; if (path != SavedSetup) { Directory.CreateDirectory(SetupDirectory); File.Copy(path, SavedSetup, true); File.SetLastWriteTimeUtc(SavedSetup, DateTime.UtcNow); } Say(ConsoleColor.Gray, " Loaded " + bindings.Count + " pad" + (bindings.Count == 1 ? "" : "s") + (stopBinding != null ? " and a Stop all pad" : "") + "."); foreach (Binding binding in bindings) Say(ConsoleColor.DarkGray, " " + binding.Label() + " -> " + binding.Name); if (bindings.Count == 0 && stopBinding == null) Say(ConsoleColor.Yellow, " No pads assigned yet. Use Learn in a sound's Edit panel, then download the setup file again."); } static Binding ReadBinding(Dictionary item) { if (item == null || !item.ContainsKey("type") || !item.ContainsKey("channel") || !item.ContainsKey("number")) return null; var binding = new Binding { Type = Convert.ToString(item["type"]), Channel = Convert.ToInt32(item["channel"]), Number = Convert.ToInt32(item["number"]), ClipId = item.ContainsKey("clipId") ? Convert.ToString(item["clipId"]) : null, Name = item.ContainsKey("name") ? Convert.ToString(item["name"]) : "", }; bool valid = (binding.Type == "note" || binding.Type == "cc" || binding.Type == "program") && binding.Channel >= 0 && binding.Channel <= 15 && binding.Number >= 0 && binding.Number <= 127; return valid ? binding : null; } static void RefreshGuild() { var data = Request("GET", apiBase, null); var discord = data.ContainsKey("discord") ? data["discord"] as Dictionary : null; var guilds = discord != null && discord.ContainsKey("guilds") ? discord["guilds"] as object[] : null; var guild = guilds != null && guilds.Length > 0 ? guilds[0] as Dictionary : null; if (guild == null) throw new Exception("The soundboard link works, but the Discord bot is offline."); guildId = Convert.ToString(guild["id"]); bool connected = guild.ContainsKey("channelId") && Convert.ToString(guild["channelId"]) != ""; Say(ConsoleColor.Green, " Soundboard reached: " + guild["name"] + (connected ? "" : " (bot is not in a voice channel yet: press Join on the page)")); } static Dictionary Request(string method, string url, Dictionary body) { var request = (HttpWebRequest)WebRequest.Create(url); request.Method = method; request.UserAgent = "bootleg-do-not-click/1.0"; request.Timeout = 12000; if (body != null) { byte[] bytes = Encoding.UTF8.GetBytes(new JavaScriptSerializer().Serialize(body)); request.ContentType = "application/json"; request.ContentLength = bytes.Length; using (Stream stream = request.GetRequestStream()) stream.Write(bytes, 0, bytes.Length); } try { using (var response = (HttpWebResponse)request.GetResponse()) return ReadJson(response); } catch (WebException error) { var response = error.Response as HttpWebResponse; if (response == null) throw new Exception("Could not reach the soundboard: " + error.Message); using (response) { if (response.StatusCode == HttpStatusCode.NotFound) throw new Exception("This soundboard link no longer works. Get a fresh setup file from the MIDI panel."); var data = ReadJson(response); throw new Exception(data.ContainsKey("error") ? Convert.ToString(data["error"]) : "The soundboard answered " + (int)response.StatusCode); } } } static Dictionary ReadJson(HttpWebResponse response) { using (var reader = new StreamReader(response.GetResponseStream(), Encoding.UTF8)) return new JavaScriptSerializer { MaxJsonLength = 8 * 1024 * 1024 }.DeserializeObject(reader.ReadToEnd()) as Dictionary ?? new Dictionary(); } static void OnMidi(IntPtr handle, uint message, IntPtr instance, IntPtr param1, IntPtr param2) { if (message == MimData) Messages.Add((int)(param1.ToInt64() & 0xFFFFFF)); } static void OpenInputs() { uint count = midiInGetNumDevs(); if (count == 0) Say(ConsoleColor.Yellow, " No MIDI devices found. Plug one in; I'll pick it up automatically."); for (uint device = 0; device < count; device++) { MidiInCaps caps; string name = midiInGetDevCaps(new UIntPtr(device), out caps, (uint)Marshal.SizeOf(typeof(MidiInCaps))) == 0 ? caps.Name : "MIDI device " + device; IntPtr handle; if (midiInOpen(out handle, device, Callback, IntPtr.Zero, CallbackFunction) != 0) { Say(ConsoleColor.Yellow, " " + name + " is busy. Close the soundboard tab (Windows lets one app use a MIDI device)."); continue; } midiInStart(handle); lock (Inputs) Inputs.Add(handle); Say(ConsoleColor.Green, " Listening to " + name); } } static void CloseInputs() { lock (Inputs) { foreach (IntPtr handle in Inputs) { midiInStop(handle); midiInClose(handle); } Inputs.Clear(); } } // Reopens everything when a device is plugged in or removed. static void WatchDevices() { uint known = midiInGetNumDevs(); while (true) { Thread.Sleep(3000); uint count = midiInGetNumDevs(); if (count == known) continue; known = count; Say(ConsoleColor.Gray, " MIDI devices changed; reconnecting."); CloseInputs(); OpenInputs(); } } // Same rules as the web page: note-ons with velocity, program changes, and CCs crossing 64 fire. static Binding Trigger(int message) { int status = message & 0xFF, kind = status & 0xF0, channel = status & 0x0F; int number = (message >> 8) & 0x7F, value = (message >> 16) & 0x7F; if (kind == 0x90 && value > 0) return new Binding { Type = "note", Channel = channel, Number = number }; if (kind == 0xC0) return new Binding { Type = "program", Channel = channel, Number = number }; if (kind != 0xB0) return null; int key = channel * 128 + number, previous; if (!ControlValues.TryGetValue(key, out previous)) previous = 0; ControlValues[key] = value; return value >= 64 && previous < 64 ? new Binding { Type = "cc", Channel = channel, Number = number } : null; } static void Handle(int message, bool wait) { Binding pressed = Trigger(message); if (pressed == null) return; Binding target = pressed.Matches(stopBinding) ? stopBinding : bindings.FirstOrDefault(binding => binding.Matches(pressed)); if (target == null) { Say(ConsoleColor.DarkGray, " " + pressed.Label() + " is not assigned"); return; } bool stop = target == stopBinding; var command = new Dictionary { { "action", stop ? "stop" : "play" } }; if (!stop) command["clipId"] = target.ClipId; Thread sender = new Thread(() => Send(command, stop ? "Stop all" : target.Name)) { IsBackground = !wait }; sender.Start(); if (wait) sender.Join(); } static void Send(Dictionary command, string label) { for (int attempt = 0; attempt < 2; attempt++) { try { command["guildId"] = guildId; Request("POST", apiBase + "/discord", command); Say(ConsoleColor.Cyan, " > " + label); return; } catch (Exception error) { if (attempt == 0 && error.Message.Contains("cannot control that server")) { try { RefreshGuild(); continue; } catch (Exception) { } } Say(ConsoleColor.Red, " " + label + ": " + error.Message); return; } } } static int ParseHex(string text) { string[] parts = text.Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries); int message = 0; for (int index = 0; index < parts.Length && index < 3; index++) message |= Convert.ToInt32(parts[index], 16) << (8 * index); return message; } }