From f5fbef7ccc0e3f032a17b0d8bbd40804cd40c617 Mon Sep 17 00:00:00 2001 From: Simon <63975668+Simyon264@users.noreply.github.com> Date: Wed, 11 Jun 2025 20:32:48 +0200 Subject: [PATCH] Add the instrument names to the MIDI channel selector (#38083) * Add the instrument to the MIDI channel selector * Reviews Adds support for chained masters Makes the channel UI update on its own when the midi changes (Works with bands too!) * add to admin logs and limit track count * Limit track names by length too * remove left over comment * Requested changes * Reviews --- .../InstrumentSystem.MidiParsing.cs | 54 +++++ .../Instruments/InstrumentSystem.cs | 33 +++- .../Instruments/MidiParser/MidiInstrument.cs | 147 ++++++++++++++ .../Instruments/MidiParser/MidiParser.cs | 184 ++++++++++++++++++ .../MidiParser/MidiStreamWrapper.cs | 103 ++++++++++ .../Instruments/UI/ChannelsMenu.xaml | 2 + .../Instruments/UI/ChannelsMenu.xaml.cs | 94 ++++++++- .../UI/InstrumentBoundUserInterface.cs | 5 +- .../Instruments/UI/InstrumentMenu.xaml.cs | 7 +- .../Instruments/InstrumentSystem.cs | 45 +++++ Content.Shared.Database/LogType.cs | 7 +- Content.Shared/CCVar/CCVars.Midi.cs | 6 + .../Instruments/SharedInstrumentComponent.cs | 77 +++++++- .../instruments/instruments-component.ftl | 133 +++++++++++++ 14 files changed, 882 insertions(+), 15 deletions(-) create mode 100644 Content.Client/Instruments/InstrumentSystem.MidiParsing.cs create mode 100644 Content.Client/Instruments/MidiParser/MidiInstrument.cs create mode 100644 Content.Client/Instruments/MidiParser/MidiParser.cs create mode 100644 Content.Client/Instruments/MidiParser/MidiStreamWrapper.cs diff --git a/Content.Client/Instruments/InstrumentSystem.MidiParsing.cs b/Content.Client/Instruments/InstrumentSystem.MidiParsing.cs new file mode 100644 index 0000000000..16aed930f6 --- /dev/null +++ b/Content.Client/Instruments/InstrumentSystem.MidiParsing.cs @@ -0,0 +1,54 @@ +using System.Linq; +using Content.Shared.Instruments; +using Robust.Shared.Audio.Midi; + +namespace Content.Client.Instruments; + +public sealed partial class InstrumentSystem +{ + /// + /// Tries to parse the input data as a midi and set the channel names respectively. + /// + /// + /// Thank you to http://www.somascape.org/midi/tech/mfile.html for providing an awesome resource for midi files. + /// + /// + /// This method has exception tolerance and does not throw, even if the midi file is invalid. + /// + private bool TrySetChannels(EntityUid uid, byte[] data) + { + if (!MidiParser.MidiParser.TryGetMidiTracks(data, out var tracks, out var error)) + { + Log.Error(error); + return false; + } + + var resolvedTracks = new List(); + for (var index = 0; index < tracks.Length; index++) + { + var midiTrack = tracks[index]; + if (midiTrack is { TrackName: null, ProgramName: null, InstrumentName: null}) + continue; + + switch (midiTrack) + { + case { TrackName: not null, ProgramName: not null }: + case { TrackName: not null, InstrumentName: not null }: + case { TrackName: not null }: + case { ProgramName: not null }: + resolvedTracks.Add(midiTrack); + break; + default: + resolvedTracks.Add(null); // Used so the channel still displays as MIDI Channel X and doesn't just take the next valid one in the UI + break; + } + + Log.Debug($"Channel name: {resolvedTracks.Last()}"); + } + + RaiseNetworkEvent(new InstrumentSetChannelsEvent(GetNetEntity(uid), resolvedTracks.Take(RobustMidiEvent.MaxChannels).ToArray())); + Log.Debug($"Resolved {resolvedTracks.Count} channels."); + + return true; + } +} diff --git a/Content.Client/Instruments/InstrumentSystem.cs b/Content.Client/Instruments/InstrumentSystem.cs index abc3fa8210..d861f4163b 100644 --- a/Content.Client/Instruments/InstrumentSystem.cs +++ b/Content.Client/Instruments/InstrumentSystem.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Linq; using Content.Shared.CCVar; using Content.Shared.Instruments; @@ -12,7 +13,7 @@ using Robust.Shared.Timing; namespace Content.Client.Instruments; -public sealed class InstrumentSystem : SharedInstrumentSystem +public sealed partial class InstrumentSystem : SharedInstrumentSystem { [Dependency] private readonly IClientNetManager _netManager = default!; [Dependency] private readonly IMidiManager _midiManager = default!; @@ -23,6 +24,8 @@ public sealed class InstrumentSystem : SharedInstrumentSystem public int MaxMidiEventsPerBatch { get; private set; } public int MaxMidiEventsPerSecond { get; private set; } + public event Action? OnChannelsUpdated; + public override void Initialize() { base.Initialize(); @@ -38,6 +41,26 @@ public sealed class InstrumentSystem : SharedInstrumentSystem SubscribeLocalEvent(OnShutdown); SubscribeLocalEvent(OnHandleState); + SubscribeLocalEvent(OnActiveInstrumentAfterHandleState); + } + + private bool _isUpdateQueued = false; + + private void OnActiveInstrumentAfterHandleState(Entity ent, ref AfterAutoHandleStateEvent args) + { + // Called in the update loop so that the components update client side for resolving them in TryComps. + _isUpdateQueued = true; + } + + public override void FrameUpdate(float frameTime) + { + base.FrameUpdate(frameTime); + + if (!_isUpdateQueued) + return; + + _isUpdateQueued = false; + OnChannelsUpdated?.Invoke(); } private void OnHandleState(EntityUid uid, SharedInstrumentComponent component, ref ComponentHandleState args) @@ -252,7 +275,13 @@ public sealed class InstrumentSystem : SharedInstrumentSystem } + [Obsolete("Use overload that takes in byte[] instead.")] public bool OpenMidi(EntityUid uid, ReadOnlySpan data, InstrumentComponent? instrument = null) + { + return OpenMidi(uid, data.ToArray(), instrument); + } + + public bool OpenMidi(EntityUid uid, byte[] data, InstrumentComponent? instrument = null) { if (!Resolve(uid, ref instrument)) return false; @@ -263,6 +292,8 @@ public sealed class InstrumentSystem : SharedInstrumentSystem return false; SetMaster(uid, null); + TrySetChannels(uid, data); + instrument.MidiEventBuffer.Clear(); instrument.Renderer.OnMidiEvent += instrument.MidiEventBuffer.Add; return true; diff --git a/Content.Client/Instruments/MidiParser/MidiInstrument.cs b/Content.Client/Instruments/MidiParser/MidiInstrument.cs new file mode 100644 index 0000000000..93946496eb --- /dev/null +++ b/Content.Client/Instruments/MidiParser/MidiInstrument.cs @@ -0,0 +1,147 @@ +using Robust.Shared.Utility; + +namespace Content.Client.Instruments.MidiParser; + +// This file was autogenerated. Based on https://www.ccarh.org/courses/253/handout/gminstruments/ +public enum MidiInstrument : byte +{ + AcousticGrandPiano = 0, + BrightAcousticPiano = 1, + ElectricGrandPiano = 2, + HonkyTonkPiano = 3, + RhodesPiano = 4, + ChorusedPiano = 5, + Harpsichord = 6, + Clavinet = 7, + Celesta = 8, + Glockenspiel = 9, + MusicBox = 10, + Vibraphone = 11, + Marimba = 12, + Xylophone = 13, + TubularBells = 14, + Dulcimer = 15, + HammondOrgan = 16, + PercussiveOrgan = 17, + RockOrgan = 18, + ChurchOrgan = 19, + ReedOrgan = 20, + Accordion = 21, + Harmonica = 22, + TangoAccordion = 23, + AcousticNylonGuitar = 24, + AcousticSteelGuitar = 25, + ElectricJazzGuitar = 26, + ElectricCleanGuitar = 27, + ElectricMutedGuitar = 28, + OverdrivenGuitar = 29, + DistortionGuitar = 30, + GuitarHarmonics = 31, + AcousticBass = 32, + FingeredElectricBass = 33, + PluckedElectricBass = 34, + FretlessBass = 35, + SlapBass1 = 36, + SlapBass2 = 37, + SynthBass1 = 38, + SynthBass2 = 39, + Violin = 40, + Viola = 41, + Cello = 42, + Contrabass = 43, + TremoloStrings = 44, + PizzicatoStrings = 45, + OrchestralHarp = 46, + Timpani = 47, + StringEnsemble1 = 48, + StringEnsemble2 = 49, + SynthStrings1 = 50, + SynthStrings2 = 51, + ChoirAah = 52, + VoiceOoh = 53, + SynthChoir = 54, + OrchestraHit = 55, + Trumpet = 56, + Trombone = 57, + Tuba = 58, + MutedTrumpet = 59, + FrenchHorn = 60, + BrassSection = 61, + SynthBrass1 = 62, + SynthBrass2 = 63, + SopranoSax = 64, + AltoSax = 65, + TenorSax = 66, + BaritoneSax = 67, + Oboe = 68, + EnglishHorn = 69, + Bassoon = 70, + Clarinet = 71, + Piccolo = 72, + Flute = 73, + Recorder = 74, + PanFlute = 75, + BottleBlow = 76, + Shakuhachi = 77, + Whistle = 78, + Ocarina = 79, + SquareWaveLead = 80, + SawtoothWaveLead = 81, + CalliopeLead = 82, + ChiffLead = 83, + CharangLead = 84, + VoiceLead = 85, + FithsLead = 86, + BassLead = 87, + NewAgePad = 88, + WarmPad = 89, + PolysynthPad = 90, + ChoirPad = 91, + BowedPad = 92, + MetallicPad = 93, + HaloPad = 94, + SweepPad = 95, + RainEffect = 96, + SoundtrackEffect = 97, + CrystalEffect = 98, + AtmosphereEffect = 99, + BrightnessEffect = 100, + GoblinsEffect = 101, + EchoesEffect = 102, + SciFiEffect = 103, + Sitar = 104, + Banjo = 105, + Shamisen = 106, + Koto = 107, + Kalimba = 108, + Bagpipe = 109, + Fiddle = 110, + Shanai = 111, + TinkleBell = 112, + Agogo = 113, + SteelDrums = 114, + Woodblock = 115, + TaikoDrum = 116, + MelodicTom = 117, + SynthDrum = 118, + ReverseCymbal = 119, + GuitarFretNoise = 120, + BreathNoise = 121, + Seashore = 122, + BirdTweet = 123, + TelephoneRing = 124, + Helicopter = 125, + Applause = 126, + Gunshot = 127, +} + +public static class MidiInstrumentExt +{ + /// + /// Turns the given enum value into it's string representation to be used in localization. + /// + public static string GetStringRep(this MidiInstrument instrument) + { + return CaseConversion.PascalToKebab(instrument.ToString()); + } +} diff --git a/Content.Client/Instruments/MidiParser/MidiParser.cs b/Content.Client/Instruments/MidiParser/MidiParser.cs new file mode 100644 index 0000000000..937384e439 --- /dev/null +++ b/Content.Client/Instruments/MidiParser/MidiParser.cs @@ -0,0 +1,184 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using Content.Shared.Instruments; + +namespace Content.Client.Instruments.MidiParser; + +public static class MidiParser +{ + // Thanks again to http://www.somascape.org/midi/tech/mfile.html + public static bool TryGetMidiTracks( + byte[] data, + [NotNullWhen(true)] out MidiTrack[]? tracks, + [NotNullWhen(false)] out string? error) + { + tracks = null; + error = null; + + var stream = new MidiStreamWrapper(data); + + if (stream.ReadString(4) != "MThd") + { + error = "Invalid file header"; + return false; + } + + var headerLength = stream.ReadUInt32(); + // MIDI specs define that the header is 6 bytes, we only look at the 6 bytes, if its more, we skip ahead. + + stream.Skip(2); // format + var trackCount = stream.ReadUInt16(); + stream.Skip(2); // time div + + // We now skip ahead if we still have any header length left + stream.Skip((int)(headerLength - 6)); + + var parsedTracks = new List(); + + for (var i = 0; i < trackCount; i++) + { + if (stream.ReadString(4) != "MTrk") + { + tracks = null; + error = "Track contains invalid header"; + return false; + } + + var track = new MidiTrack(); + + var trackLength = stream.ReadUInt32(); + var trackEnd = stream.StreamPosition + trackLength; + var hasMidiEvent = false; + byte? lastStatusByte = null; + + while (stream.StreamPosition < trackEnd) + { + stream.ReadVariableLengthQuantity(); + + /* + * If the first (status) byte is less than 128 (hex 80), this implies that running status is in effect, + * and that this byte is actually the first data byte (the status carrying over from the previous MIDI event). + * This can only be the case if the immediately previous event was also a MIDI event, + * i.e. SysEx and Meta events interrupt (clear) running status. + * See http://www.somascape.org/midi/tech/mfile.html#events + */ + + var firstByte = stream.ReadByte(); + if (firstByte >= 0x80) + { + lastStatusByte = firstByte; + } + else + { + // Running status: push byte back for reading as data + stream.Skip(-1); + } + + // The first event in each MTrk chunk must specify status. + if (lastStatusByte == null) + { + tracks = null; + error = "Track data not valid, expected status byte, got nothing."; + return false; + } + + var eventType = (byte)(lastStatusByte & 0xF0); + + switch (lastStatusByte) + { + // Meta events + case 0xFF: + { + var metaType = stream.ReadByte(); + var metaLength = stream.ReadVariableLengthQuantity(); + var metaData = stream.ReadBytes((int)metaLength); + if (metaType == 0x00) // SequenceNumber event + continue; + + // Meta event types 01 through 0F are reserved for text and all follow the basic FF 01 len text format + if (metaType is < 0x01 or > 0x0F) + break; + + // 0x03 is TrackName, + // 0x04 is InstrumentName + + var text = Encoding.ASCII.GetString(metaData, 0, (int)metaLength); + switch (metaType) + { + case 0x03 when track.TrackName == null: + track.TrackName = text; + break; + case 0x04 when track.InstrumentName == null: + track.InstrumentName = text; + break; + } + + // still here? then we dont care about the event + break; + } + + // SysEx events + case 0xF0: + case 0xF7: + { + var sysexLength = stream.ReadVariableLengthQuantity(); + stream.Skip((int)sysexLength); + // Sysex events and meta-events cancel any running status which was in effect. + // Running status does not apply to and may not be used for these messages. + lastStatusByte = null; + break; + } + + + default: + switch (eventType) + { + // Program Change + case 0xC0: + { + var programNumber = stream.ReadByte(); + if (track.ProgramName == null) + { + if (programNumber < Enum.GetValues().Length) + track.ProgramName = Loc.GetString($"instruments-component-menu-midi-channel-{((MidiInstrument)programNumber).GetStringRep()}"); + } + break; + } + + case 0x80: // Note Off + case 0x90: // Note On + case 0xA0: // Polyphonic Key Pressure + case 0xB0: // Control Change + case 0xE0: // Pitch Bend + { + hasMidiEvent = true; + stream.Skip(2); + break; + } + + case 0xD0: // Channel Pressure + { + hasMidiEvent = true; + stream.Skip(1); + break; + } + + default: + error = $"Unknown MIDI event type {lastStatusByte:X2}"; + tracks = null; + return false; + } + break; + } + } + + + if (hasMidiEvent) + parsedTracks.Add(track); + } + + tracks = parsedTracks.ToArray(); + + return true; + } +} diff --git a/Content.Client/Instruments/MidiParser/MidiStreamWrapper.cs b/Content.Client/Instruments/MidiParser/MidiStreamWrapper.cs new file mode 100644 index 0000000000..1886417a56 --- /dev/null +++ b/Content.Client/Instruments/MidiParser/MidiStreamWrapper.cs @@ -0,0 +1,103 @@ +using System.IO; +using System.Text; + +namespace Content.Client.Instruments.MidiParser; + +public sealed class MidiStreamWrapper +{ + private readonly MemoryStream _stream; + private byte[] _buffer; + + public long StreamPosition => _stream.Position; + + public MidiStreamWrapper(byte[] data) + { + _stream = new MemoryStream(data, writable: false); + _buffer = new byte[4]; + } + + /// + /// Skips X number of bytes in the stream. + /// + /// The number of bytes to skip. If 0, no operations on the stream are performed. + public void Skip(int count) + { + if (count == 0) + return; + + _stream.Seek(count, SeekOrigin.Current); + } + + public byte ReadByte() + { + var b = _stream.ReadByte(); + if (b == -1) + throw new Exception("Unexpected end of stream"); + + return (byte)b; + } + + /// + /// Reads N bytes using the buffer. + /// + public byte[] ReadBytes(int count) + { + if (_buffer.Length < count) + { + Array.Resize(ref _buffer, count); + } + + var read = _stream.Read(_buffer, 0, count); + if (read != count) + throw new Exception("Unexpected end of stream"); + + return _buffer; + } + + /// + /// Reads a 4 byte big-endian uint. + /// + public uint ReadUInt32() + { + var bytes = ReadBytes(4); + return (uint)((bytes[0] << 24) | + (bytes[1] << 16) | + (bytes[2] << 8) | + (bytes[3])); + } + + /// + /// Reads a 2 byte big-endian ushort. + /// + public ushort ReadUInt16() + { + var bytes = ReadBytes(2); + return (ushort)((bytes[0] << 8) | bytes[1]); + } + + public string ReadString(int count) + { + var bytes = ReadBytes(count); + return Encoding.UTF8.GetString(bytes, 0, count); + } + + public uint ReadVariableLengthQuantity() + { + uint value = 0; + + // variable-length-quantities encode ints using 7 bits per byte + // the highest bit (7) is used for a continuation flag. We read until the high bit is 0 + + while (true) + { + var b = ReadByte(); + value = (value << 7) | (uint)(b & 0x7f); // Shift current value and add 7 bits + // value << 7, make room for the next 7 bits + // b & 0x7F mask out the high bit to just get the 7 bit payload + if ((b & 0x80) == 0) + break; // This was the last bit. + } + + return value; + } +} diff --git a/Content.Client/Instruments/UI/ChannelsMenu.xaml b/Content.Client/Instruments/UI/ChannelsMenu.xaml index 1bf4647609..20e4a3e923 100644 --- a/Content.Client/Instruments/UI/ChannelsMenu.xaml +++ b/Content.Client/Instruments/UI/ChannelsMenu.xaml @@ -7,5 +7,7 @@