]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Add the instrument names to the MIDI channel selector (#38083)
authorSimon <63975668+Simyon264@users.noreply.github.com>
Wed, 11 Jun 2025 18:32:48 +0000 (20:32 +0200)
committerGitHub <noreply@github.com>
Wed, 11 Jun 2025 18:32:48 +0000 (20:32 +0200)
* 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

14 files changed:
Content.Client/Instruments/InstrumentSystem.MidiParsing.cs [new file with mode: 0644]
Content.Client/Instruments/InstrumentSystem.cs
Content.Client/Instruments/MidiParser/MidiInstrument.cs [new file with mode: 0644]
Content.Client/Instruments/MidiParser/MidiParser.cs [new file with mode: 0644]
Content.Client/Instruments/MidiParser/MidiStreamWrapper.cs [new file with mode: 0644]
Content.Client/Instruments/UI/ChannelsMenu.xaml
Content.Client/Instruments/UI/ChannelsMenu.xaml.cs
Content.Client/Instruments/UI/InstrumentBoundUserInterface.cs
Content.Client/Instruments/UI/InstrumentMenu.xaml.cs
Content.Server/Instruments/InstrumentSystem.cs
Content.Shared.Database/LogType.cs
Content.Shared/CCVar/CCVars.Midi.cs
Content.Shared/Instruments/SharedInstrumentComponent.cs
Resources/Locale/en-US/instruments/instruments-component.ftl

diff --git a/Content.Client/Instruments/InstrumentSystem.MidiParsing.cs b/Content.Client/Instruments/InstrumentSystem.MidiParsing.cs
new file mode 100644 (file)
index 0000000..16aed93
--- /dev/null
@@ -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
+{
+    /// <summary>
+    /// Tries to parse the input data as a midi and set the channel names respectively.
+    /// </summary>
+    /// <remarks>
+    /// Thank you to http://www.somascape.org/midi/tech/mfile.html for providing an awesome resource for midi files.
+    /// </remarks>
+    /// <remarks>
+    /// This method has exception tolerance and does not throw, even if the midi file is invalid.
+    /// </remarks>
+    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<MidiTrack?>();
+        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;
+    }
+}
index abc3fa8210e94de0ccac426897689869110f8ba6..d861f4163b0c17a81a4367d2d9d46f3a98707564 100644 (file)
@@ -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<InstrumentComponent, ComponentShutdown>(OnShutdown);
         SubscribeLocalEvent<InstrumentComponent, ComponentHandleState>(OnHandleState);
+        SubscribeLocalEvent<ActiveInstrumentComponent, AfterAutoHandleStateEvent>(OnActiveInstrumentAfterHandleState);
+    }
+
+    private bool _isUpdateQueued = false;
+
+    private void OnActiveInstrumentAfterHandleState(Entity<ActiveInstrumentComponent> 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<byte> 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 (file)
index 0000000..9394649
--- /dev/null
@@ -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
+{
+    /// <summary>
+    /// Turns the given enum value into it's string representation to be used in localization.
+    /// </summary>
+    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 (file)
index 0000000..937384e
--- /dev/null
@@ -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<MidiTrack>();
+
+        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<MidiInstrument>().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 (file)
index 0000000..1886417
--- /dev/null
@@ -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];
+    }
+
+    /// <summary>
+    /// Skips X number of bytes in the stream.
+    /// </summary>
+    /// <param name="count">The number of bytes to skip. If 0, no operations on the stream are performed.</param>
+    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;
+    }
+
+    /// <summary>
+    /// Reads N bytes using the buffer.
+    /// </summary>
+    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;
+    }
+
+    /// <summary>
+    /// Reads a 4 byte big-endian uint.
+    /// </summary>
+    public uint ReadUInt32()
+    {
+        var bytes = ReadBytes(4);
+        return (uint)((bytes[0] << 24) |
+                      (bytes[1] << 16) |
+                      (bytes[2] << 8)  |
+                      (bytes[3]));
+    }
+
+    /// <summary>
+    /// Reads a 2 byte big-endian ushort.
+    /// </summary>
+    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;
+    }
+}
index 1bf464760922348b745e4d1db0fb42345db626e5..20e4a3e9232d783c4878a83053e1bbf72666827b 100644 (file)
@@ -7,5 +7,7 @@
             <Button Name="AllButton" Text="{Loc 'instruments-component-channels-all-button'}" HorizontalExpand="true" VerticalExpand="true" SizeFlagsStretchRatio="1"/>
             <Button Name="ClearButton" Text="{Loc 'instruments-component-channels-clear-button'}" HorizontalExpand="true" VerticalExpand="true" SizeFlagsStretchRatio="1"/>
         </BoxContainer>
+        <CheckButton Name="DisplayTrackNames"
+                     Text="{Loc 'instruments-component-channels-track-names-toggle'}" />
     </BoxContainer>
 </DefaultWindow>
index c175e67842fbb7457c7feaa34e2abb5c2e245520..da164a633cde0d8593e35de4283b1f20ad46fd9a 100644 (file)
@@ -1,26 +1,56 @@
+using Content.Shared.Instruments;
 using Robust.Client.AutoGenerated;
 using Robust.Client.UserInterface.Controls;
 using Robust.Client.UserInterface.CustomControls;
 using Robust.Client.UserInterface.XAML;
 using Robust.Shared.Audio.Midi;
-using Robust.Shared.Timing;
+using Robust.Shared.Utility;
 
 namespace Content.Client.Instruments.UI;
 
 [GenerateTypedNameReferences]
 public sealed partial class ChannelsMenu : DefaultWindow
 {
+    [Dependency] private readonly IEntityManager _entityManager = null!;
+
     private readonly InstrumentBoundUserInterface _owner;
 
     public ChannelsMenu(InstrumentBoundUserInterface owner) : base()
     {
         RobustXamlLoader.Load(this);
+        IoCManager.InjectDependencies(this);
         _owner = owner;
 
         ChannelList.OnItemSelected += OnItemSelected;
         ChannelList.OnItemDeselected += OnItemDeselected;
         AllButton.OnPressed += OnAllPressed;
         ClearButton.OnPressed += OnClearPressed;
+        DisplayTrackNames.OnPressed += OnDisplayTrackNamesPressed;
+    }
+
+    protected override void EnteredTree()
+    {
+        base.EnteredTree();
+
+        _owner.Instruments.OnChannelsUpdated += UpdateChannelList;
+    }
+
+    private void OnDisplayTrackNamesPressed(BaseButton.ButtonEventArgs obj)
+    {
+        DisplayTrackNames.SetClickPressed(!DisplayTrackNames.Pressed);
+        Populate();
+    }
+
+    private void UpdateChannelList()
+    {
+        Populate(); // This is kind of in-efficent because we don't filter for which instrument updated its channels, but idc
+    }
+
+    protected override void ExitedTree()
+    {
+        base.ExitedTree();
+
+        _owner.Instruments.OnChannelsUpdated -= UpdateChannelList;
     }
 
     private void OnItemSelected(ItemList.ItemListSelectedEventArgs args)
@@ -51,15 +81,71 @@ public sealed partial class ChannelsMenu : DefaultWindow
         }
     }
 
-    public void Populate(InstrumentComponent? instrument)
+    /// <summary>
+    /// Walks up the tree of instrument masters to find the truest master of them all.
+    /// </summary>
+    private ActiveInstrumentComponent ResolveActiveInstrument(InstrumentComponent? comp)
+    {
+        comp ??= _entityManager.GetComponent<InstrumentComponent>(_owner.Owner);
+
+        var instrument = new Entity<InstrumentComponent>(_owner.Owner, comp);
+
+        while (true)
+        {
+            if (instrument.Comp.Master == null)
+                break;
+
+            instrument = new Entity<InstrumentComponent>((EntityUid)instrument.Comp.Master,
+                _entityManager.GetComponent<InstrumentComponent>((EntityUid)instrument.Comp.Master));
+        }
+
+        return _entityManager.GetComponent<ActiveInstrumentComponent>(instrument.Owner);
+    }
+
+    public void Populate()
     {
         ChannelList.Clear();
+        var instrument = _entityManager.GetComponent<InstrumentComponent>(_owner.Owner);
+        var activeInstrument = ResolveActiveInstrument(instrument);
 
         for (int i = 0; i < RobustMidiEvent.MaxChannels; i++)
         {
-            var item = ChannelList.AddItem(_owner.Loc.GetString("instrument-component-channel-name",
-                ("number", i)), null, true, i);
+            var label = _owner.Loc.GetString("instrument-component-channel-name",
+                ("number", i));
+            if (activeInstrument != null
+                && activeInstrument.Tracks.TryGetValue(i, out var resolvedMidiChannel)
+                && resolvedMidiChannel != null)
+            {
+                if (DisplayTrackNames.Pressed)
+                {
+                    label = resolvedMidiChannel switch
+                    {
+                        { TrackName: not null, InstrumentName: not null } =>
+                            Loc.GetString("instruments-component-channels-multi",
+                                ("channel", i),
+                                ("name", resolvedMidiChannel.TrackName),
+                                ("other", resolvedMidiChannel.InstrumentName)),
+                        { TrackName: not null } =>
+                            Loc.GetString("instruments-component-channels-single",
+                            ("channel", i),
+                            ("name", resolvedMidiChannel.TrackName)),
+                        _ => label,
+                    };
+                }
+                else
+                {
+                    label = resolvedMidiChannel switch
+                    {
+                        { ProgramName: not null } =>
+                            Loc.GetString("instruments-component-channels-single",
+                                ("channel", i),
+                                ("name", resolvedMidiChannel.ProgramName)),
+                        _ => label,
+                    };
+                }
+            }
 
+            var item = ChannelList.AddItem(label, null, true, i);
 
             item.Selected = !instrument?.FilteredChannels[i] ?? false;
         }
index e511cb8654e7afca372476a9470366ae8e38922c..ffdb67f6264d855dbc8d25f20cf8f208ae7a92c9 100644 (file)
@@ -1,4 +1,5 @@
 using Content.Shared.ActionBlocker;
+using Content.Shared.Instruments;
 using Content.Shared.Instruments.UI;
 using Content.Shared.Interaction;
 using Robust.Client.Audio.Midi;
@@ -101,9 +102,7 @@ namespace Content.Client.Instruments.UI
         public void OpenChannelsMenu()
         {
             _channelsMenu ??= new ChannelsMenu(this);
-            EntMan.TryGetComponent(Owner, out InstrumentComponent? instrument);
-
-            _channelsMenu.Populate(instrument);
+            _channelsMenu.Populate();
             _channelsMenu.OpenCenteredRight();
         }
 
index 9b14e01fb57ffa730c8c0d68eafc1c356d781c9c..4a29478a9c4ac50284f8beec83bf5914387693b3 100644 (file)
@@ -11,6 +11,7 @@ using Robust.Client.UserInterface.XAML;
 using Robust.Shared.Containers;
 using Robust.Shared.Input;
 using Robust.Shared.Timing;
+using Robust.Shared.Utility;
 using static Robust.Client.UserInterface.Controls.BaseButton;
 using Range = Robust.Client.UserInterface.Controls.Range;
 
@@ -145,10 +146,6 @@ namespace Content.Client.Instruments.UI
             if (!PlayCheck())
                 return;
 
-            await using var memStream = new MemoryStream((int) file.Length);
-
-            await file.CopyToAsync(memStream);
-
             if (!_entManager.TryGetComponent<InstrumentComponent>(Entity, out var instrument))
             {
                 return;
@@ -156,7 +153,7 @@ namespace Content.Client.Instruments.UI
 
             if (!_entManager.System<InstrumentSystem>()
                     .OpenMidi(Entity,
-                    memStream.GetBuffer().AsSpan(0, (int) memStream.Length),
+                        file.CopyToArray(),
                     instrument))
             {
                 return;
index 2539db7a6f9761f8c8860b84ec0ddbfb7b9831a0..a347d7ea41e6f44403893cc16b8a5f543931c9c2 100644 (file)
@@ -1,8 +1,12 @@
+using System.Linq;
 using Content.Server.Administration;
+using Content.Server.Administration.Logs;
 using Content.Server.Interaction;
 using Content.Server.Popups;
 using Content.Server.Stunnable;
 using Content.Shared.Administration;
+using Content.Shared.CCVar;
+using Content.Shared.Database;
 using Content.Shared.Examine;
 using Content.Shared.Instruments;
 using Content.Shared.Instruments.UI;
@@ -17,6 +21,7 @@ using Robust.Shared.Console;
 using Robust.Shared.GameStates;
 using Robust.Shared.Player;
 using Robust.Shared.Timing;
+using Robust.Shared.Utility;
 
 namespace Content.Server.Instruments;
 
@@ -31,6 +36,7 @@ public sealed partial class InstrumentSystem : SharedInstrumentSystem
     [Dependency] private readonly PopupSystem _popup = default!;
     [Dependency] private readonly TransformSystem _transform = default!;
     [Dependency] private readonly ExamineSystemShared _examineSystem = default!;
+    [Dependency] private readonly IAdminLogManager _admingLogSystem = default!;
 
     private const float MaxInstrumentBandRange = 10f;
 
@@ -50,6 +56,7 @@ public sealed partial class InstrumentSystem : SharedInstrumentSystem
         SubscribeNetworkEvent<InstrumentStopMidiEvent>(OnMidiStop);
         SubscribeNetworkEvent<InstrumentSetMasterEvent>(OnMidiSetMaster);
         SubscribeNetworkEvent<InstrumentSetFilteredChannelEvent>(OnMidiSetFilteredChannel);
+        SubscribeNetworkEvent<InstrumentSetChannelsEvent>(OnMidiSetChannels);
 
         Subs.BuiEvents<InstrumentComponent>(InstrumentUiKey.Key, subs =>
         {
@@ -132,6 +139,44 @@ public sealed partial class InstrumentSystem : SharedInstrumentSystem
         Clean(uid, instrument);
     }
 
+
+    private void OnMidiSetChannels(InstrumentSetChannelsEvent msg, EntitySessionEventArgs args)
+    {
+        var uid = GetEntity(msg.Uid);
+
+        if (!TryComp(uid, out InstrumentComponent? instrument) || !TryComp(uid, out ActiveInstrumentComponent? activeInstrument))
+            return;
+
+        if (args.SenderSession.AttachedEntity != instrument.InstrumentPlayer)
+            return;
+
+        if (msg.Tracks.Length > RobustMidiEvent.MaxChannels)
+        {
+            Log.Warning($"{args.SenderSession.UserId.ToString()} - Tried to send tracks over the limit! Received: {msg.Tracks.Length}; Limit: {RobustMidiEvent.MaxChannels}");
+            return;
+        }
+
+        var tracksString = string.Join("\n",
+            msg.Tracks
+            .Where(t => t != null)
+            .Select(t => t!.ToString()));
+
+        _admingLogSystem.Add(
+            LogType.Instrument,
+            LogImpact.Low,
+            $"{ToPrettyString(args.SenderSession.AttachedEntity)} set the midi channels for {ToPrettyString(uid)} to {tracksString}");
+
+        // Truncate any track names too long.
+        foreach (var t in msg.Tracks)
+        {
+            t?.TruncateFields(_cfg.GetCVar(CCVars.MidiMaxChannelNameLength));
+        }
+
+        activeInstrument.Tracks = msg.Tracks;
+
+        Dirty(uid, activeInstrument);
+    }
+
     private void OnMidiSetMaster(InstrumentSetMasterEvent msg, EntitySessionEventArgs args)
     {
         var uid = GetEntity(msg.Uid);
index dc8265bb434f0b5c20ebca1a591e55ced0b00751..58a41a5f7a47c11a373c6fe1c66974cd357d2c65 100644 (file)
@@ -472,5 +472,10 @@ public enum LogType
     /// <summary>
     /// Damaging grid collision has occurred.
     /// </summary>
-    ShuttleImpact = 102
+    ShuttleImpact = 102,
+
+    /// <summary>
+    /// Events relating to midi playback.
+    /// </summary>
+    Instrument = 103,
 }
index 4ca4bfd6f8647be9579b3deb55175c22f3c3d191..806bcc2b52062ecb759da0f20222fca056a939ca 100644 (file)
@@ -15,4 +15,10 @@ public sealed partial class CCVars
 
     public static readonly CVarDef<int> MaxMidiLaggedBatches =
         CVarDef.Create("midi.max_lagged_batches", 8, CVar.SERVERONLY);
+
+    /// <summary>
+    /// Defines the max amount of characters to allow in the "Midi channel selector".
+    /// </summary>
+    public static readonly CVarDef<int> MidiMaxChannelNameLength =
+        CVarDef.Create("midi.max_channel_name_length", 64, CVar.SERVERONLY);
 }
index da64bf8cd7d6cefa55cfb78d4ac233a16cac30c3..97eef752ebb9e859967f173c14a17bc542da1e6f 100644 (file)
@@ -38,7 +38,13 @@ public abstract partial class SharedInstrumentComponent : Component
 /// Component that indicates that musical instrument was activated (ui opened).
 /// </summary>
 [RegisterComponent, NetworkedComponent]
-public sealed partial class ActiveInstrumentComponent : Component;
+[AutoGenerateComponentState(true)]
+public sealed partial class ActiveInstrumentComponent : Component
+{
+    [DataField]
+    [AutoNetworkedField]
+    public MidiTrack?[] Tracks = [];
+}
 
 [Serializable, NetSerializable]
 public sealed class InstrumentComponentState : ComponentState
@@ -144,3 +150,72 @@ public enum InstrumentUiKey
 {
     Key,
 }
+
+/// <summary>
+/// Sets the MIDI channels on an instrument.
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class InstrumentSetChannelsEvent : EntityEventArgs
+{
+    public NetEntity Uid { get; }
+    public MidiTrack?[] Tracks { get; set; }
+
+    public InstrumentSetChannelsEvent(NetEntity uid, MidiTrack?[] tracks)
+    {
+        Uid = uid;
+        Tracks = tracks;
+    }
+}
+
+/// <summary>
+/// Represents a single midi track with the track name, instrument name and bank instrument name extracted.
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class MidiTrack
+{
+    /// <summary>
+    /// The first specified Track Name
+    /// </summary>
+    public string? TrackName;
+    /// <summary>
+    /// The first specified instrument name
+    /// </summary>
+    public string? InstrumentName;
+
+    /// <summary>
+    /// The first program change resolved to the name.
+    /// </summary>
+    public string? ProgramName;
+
+    public override string ToString()
+    {
+        return $"Track Name: {TrackName}; Instrument Name: {InstrumentName}; Program Name: {ProgramName}";
+    }
+
+    /// <summary>
+    /// Truncates the fields based on the limit inputted into this method.
+    /// </summary>
+    public void TruncateFields(int limit)
+    {
+        if (InstrumentName != null)
+            InstrumentName = Truncate(InstrumentName, limit);
+
+        if (TrackName != null)
+            TrackName = Truncate(TrackName, limit);
+
+        if (ProgramName != null)
+            ProgramName = Truncate(ProgramName, limit);
+    }
+
+    private const string Postfix = "…";
+    // TODO: Make a general method to use in RT? idk if we have that.
+    private string Truncate(string input, int limit)
+    {
+        if (string.IsNullOrEmpty(input) || limit <= 0 || input.Length <= limit)
+            return input;
+
+        var truncatedLength = limit - Postfix.Length;
+
+        return input.Substring(0, truncatedLength) + Postfix;
+    }
+}
index f0e0c1b3a9a083d724da3fbf88c57318ae79f09f..76cfb28166141634639d95c3ee99409c076833fe 100644 (file)
@@ -19,6 +19,139 @@ instruments-component-channels-menu = MIDI Channel Selection
 instrument-component-channel-name = MIDI Channel {$number}
 instruments-component-channels-all-button = All
 instruments-component-channels-clear-button = Clear
+instruments-component-channels-track-names-toggle = Show Track Names
+instruments-component-channels-single = {$channel} {$name}
+instruments-component-channels-multi = {$channel} {$name} ({$other})
+
 
 # SwappableInstrumentComponent
 swappable-instrument-component-style-set = Style set to "{$style}"
+
+instruments-component-menu-midi-channel-acoustic-grand-piano = Acoustic Grand Piano
+instruments-component-menu-midi-channel-bright-acoustic-piano = Bright Acoustic Piano
+instruments-component-menu-midi-channel-electric-grand-piano = Electric Grand Piano
+instruments-component-menu-midi-channel-honky-tonk-piano = Honky-tonk Piano
+instruments-component-menu-midi-channel-rhodes-piano = Rhodes Piano
+instruments-component-menu-midi-channel-chorused-piano = Chorused Piano
+instruments-component-menu-midi-channel-harpsichord = Harpsichord
+instruments-component-menu-midi-channel-clavinet = Clavinet
+instruments-component-menu-midi-channel-celesta = Celesta
+instruments-component-menu-midi-channel-glockenspiel = Glockenspiel
+instruments-component-menu-midi-channel-music-box = Music Box
+instruments-component-menu-midi-channel-vibraphone = Vibraphone
+instruments-component-menu-midi-channel-marimba = Marimba
+instruments-component-menu-midi-channel-xylophone = Xylophone
+instruments-component-menu-midi-channel-tubular-bells = Tubular Bells
+instruments-component-menu-midi-channel-dulcimer = Dulcimer
+instruments-component-menu-midi-channel-hammond-organ = Hammond Organ
+instruments-component-menu-midi-channel-percussive-organ = Percussive Organ
+instruments-component-menu-midi-channel-rock-organ = Rock Organ
+instruments-component-menu-midi-channel-church-organ = Church Organ
+instruments-component-menu-midi-channel-reed-organ = Reed Organ
+instruments-component-menu-midi-channel-accordion = Accordion
+instruments-component-menu-midi-channel-harmonica = Harmonica
+instruments-component-menu-midi-channel-tango-accordion = Tango Accordion
+instruments-component-menu-midi-channel-acoustic-nylon-guitar = Acoustic Nylon Guitar
+instruments-component-menu-midi-channel-acoustic-steel-guitar = Acoustic Steel Guitar
+instruments-component-menu-midi-channel-electric-jazz-guitar = Electric Jazz Guitar
+instruments-component-menu-midi-channel-electric-clean-guitar = Electric Clean Guitar
+instruments-component-menu-midi-channel-electric-muted-guitar = Electric Muted Guitar
+instruments-component-menu-midi-channel-overdriven-guitar = Overdriven Guitar
+instruments-component-menu-midi-channel-distortion-guitar = Distortion Guitar
+instruments-component-menu-midi-channel-guitar-harmonics = Guitar Harmonics
+instruments-component-menu-midi-channel-acoustic-bass = Acoustic Bass
+instruments-component-menu-midi-channel-fingered-electric-bass = Fingered Electric Bass
+instruments-component-menu-midi-channel-plucked-electric-bass = Plucked Electric Bass
+instruments-component-menu-midi-channel-fretless-bass = Fretless Bass
+instruments-component-menu-midi-channel-slap-bass1 = Slap Bass 1
+instruments-component-menu-midi-channel-slap-bass2 = Slap Bass 2
+instruments-component-menu-midi-channel-synth-bass1 = Synth Bass 1
+instruments-component-menu-midi-channel-synth-bass2 = Synth Bass 2
+instruments-component-menu-midi-channel-violin = Violin
+instruments-component-menu-midi-channel-viola = Viola
+instruments-component-menu-midi-channel-cello = Cello
+instruments-component-menu-midi-channel-contrabass = Contrabass
+instruments-component-menu-midi-channel-tremolo-strings = Tremolo Strings
+instruments-component-menu-midi-channel-pizzicato-strings = Pizzicato Strings
+instruments-component-menu-midi-channel-orchestral-harp = Orchestral Harp
+instruments-component-menu-midi-channel-timpani = Timpani
+instruments-component-menu-midi-channel-string-ensemble1 = String Ensemble 1
+instruments-component-menu-midi-channel-string-ensemble2 = String Ensemble 2
+instruments-component-menu-midi-channel-synth-strings1 = Synth Strings 1
+instruments-component-menu-midi-channel-synth-strings2 = Synth Strings 2
+instruments-component-menu-midi-channel-choir-aah = Choir "Aah"
+instruments-component-menu-midi-channel-voice-ooh = Voice "Ooh"
+instruments-component-menu-midi-channel-synth-choir = Synth Choir
+instruments-component-menu-midi-channel-orchestra-hit = Orchestra Hit
+instruments-component-menu-midi-channel-trumpet = Trumpet
+instruments-component-menu-midi-channel-trombone = Trombone
+instruments-component-menu-midi-channel-tuba = Tuba
+instruments-component-menu-midi-channel-muted-trumpet = Muted Trumpet
+instruments-component-menu-midi-channel-french-horn = French Horn
+instruments-component-menu-midi-channel-brass-section = Brass Section
+instruments-component-menu-midi-channel-synth-brass1 = Synth Brass 1
+instruments-component-menu-midi-channel-synth-brass2 = Synth Brass 2
+instruments-component-menu-midi-channel-soprano-sax = Soprano Sax
+instruments-component-menu-midi-channel-alto-sax = Alto Sax
+instruments-component-menu-midi-channel-tenor-sax = Tenor Sax
+instruments-component-menu-midi-channel-baritone-sax = Baritone Sax
+instruments-component-menu-midi-channel-oboe = Oboe
+instruments-component-menu-midi-channel-english-horn = English Horn
+instruments-component-menu-midi-channel-bassoon = Bassoon
+instruments-component-menu-midi-channel-clarinet = Clarinet
+instruments-component-menu-midi-channel-piccolo = Piccolo
+instruments-component-menu-midi-channel-flute = Flute
+instruments-component-menu-midi-channel-recorder = Recorder
+instruments-component-menu-midi-channel-pan-flute = Pan Flute
+instruments-component-menu-midi-channel-bottle-blow = Bottle Blow
+instruments-component-menu-midi-channel-shakuhachi = Shakuhachi
+instruments-component-menu-midi-channel-whistle = Whistle
+instruments-component-menu-midi-channel-ocarina = Ocarina
+instruments-component-menu-midi-channel-square-wave-lead = Square Wave Lead
+instruments-component-menu-midi-channel-sawtooth-wave-lead = Sawtooth Wave Lead
+instruments-component-menu-midi-channel-calliope-lead = Calliope Lead
+instruments-component-menu-midi-channel-chiff-lead = Chiff Lead
+instruments-component-menu-midi-channel-charang-lead = Charang Lead
+instruments-component-menu-midi-channel-voice-lead = Voice Lead
+instruments-component-menu-midi-channel-fiths-lead = Fiths Lead
+instruments-component-menu-midi-channel-bass-lead = Bass Lead
+instruments-component-menu-midi-channel-new-age-pad = New Age Pad
+instruments-component-menu-midi-channel-warm-pad = Warm Pad
+instruments-component-menu-midi-channel-polysynth-pad = Polysynth Pad
+instruments-component-menu-midi-channel-choir-pad = Choir Pad
+instruments-component-menu-midi-channel-bowed-pad = Bowed Pad
+instruments-component-menu-midi-channel-metallic-pad = Metallic Pad
+instruments-component-menu-midi-channel-halo-pad = Halo Pad
+instruments-component-menu-midi-channel-sweep-pad = Sweep Pad
+instruments-component-menu-midi-channel-rain-effect = Rain Effect
+instruments-component-menu-midi-channel-soundtrack-effect = Soundtrack Effect
+instruments-component-menu-midi-channel-crystal-effect = Crystal Effect
+instruments-component-menu-midi-channel-atmosphere-effect = Atmosphere Effect
+instruments-component-menu-midi-channel-brightness-effect = Brightness Effect
+instruments-component-menu-midi-channel-goblins-effect = Goblins Effect
+instruments-component-menu-midi-channel-echoes-effect = Echoes Effect
+instruments-component-menu-midi-channel-sci-fi-effect = Sci-Fi Effect
+instruments-component-menu-midi-channel-sitar = Sitar
+instruments-component-menu-midi-channel-banjo = Banjo
+instruments-component-menu-midi-channel-shamisen = Shamisen
+instruments-component-menu-midi-channel-koto = Koto
+instruments-component-menu-midi-channel-kalimba = Kalimba
+instruments-component-menu-midi-channel-bagpipe = Bagpipe
+instruments-component-menu-midi-channel-fiddle = Fiddle
+instruments-component-menu-midi-channel-shanai = Shanai
+instruments-component-menu-midi-channel-tinkle-bell = Tinkle Bell
+instruments-component-menu-midi-channel-agogo = Agogo
+instruments-component-menu-midi-channel-steel-drums = Steel Drums
+instruments-component-menu-midi-channel-woodblock = Woodblock
+instruments-component-menu-midi-channel-taiko-drum = Taiko Drum
+instruments-component-menu-midi-channel-melodic-tom = Melodic Tom
+instruments-component-menu-midi-channel-synth-drum = Synth Drum
+instruments-component-menu-midi-channel-reverse-cymbal = Reverse Cymbal
+instruments-component-menu-midi-channel-guitar-fret-noise = Guitar Fret Noise
+instruments-component-menu-midi-channel-breath-noise = Breath Noise
+instruments-component-menu-midi-channel-seashore = Seashore
+instruments-component-menu-midi-channel-bird-tweet = Bird Tweet
+instruments-component-menu-midi-channel-telephone-ring = Telephone Ring
+instruments-component-menu-midi-channel-helicopter = Helicopter
+instruments-component-menu-midi-channel-applause = Applause
+instruments-component-menu-midi-channel-gunshot = Gunshot