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 @@
+
diff --git a/Content.Client/Instruments/UI/ChannelsMenu.xaml.cs b/Content.Client/Instruments/UI/ChannelsMenu.xaml.cs
index c175e67842..da164a633c 100644
--- a/Content.Client/Instruments/UI/ChannelsMenu.xaml.cs
+++ b/Content.Client/Instruments/UI/ChannelsMenu.xaml.cs
@@ -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)
+ ///
+ /// Walks up the tree of instrument masters to find the truest master of them all.
+ ///
+ private ActiveInstrumentComponent ResolveActiveInstrument(InstrumentComponent? comp)
+ {
+ comp ??= _entityManager.GetComponent(_owner.Owner);
+
+ var instrument = new Entity(_owner.Owner, comp);
+
+ while (true)
+ {
+ if (instrument.Comp.Master == null)
+ break;
+
+ instrument = new Entity((EntityUid)instrument.Comp.Master,
+ _entityManager.GetComponent((EntityUid)instrument.Comp.Master));
+ }
+
+ return _entityManager.GetComponent(instrument.Owner);
+ }
+
+ public void Populate()
{
ChannelList.Clear();
+ var instrument = _entityManager.GetComponent(_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;
}
diff --git a/Content.Client/Instruments/UI/InstrumentBoundUserInterface.cs b/Content.Client/Instruments/UI/InstrumentBoundUserInterface.cs
index e511cb8654..ffdb67f626 100644
--- a/Content.Client/Instruments/UI/InstrumentBoundUserInterface.cs
+++ b/Content.Client/Instruments/UI/InstrumentBoundUserInterface.cs
@@ -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();
}
diff --git a/Content.Client/Instruments/UI/InstrumentMenu.xaml.cs b/Content.Client/Instruments/UI/InstrumentMenu.xaml.cs
index 9b14e01fb5..4a29478a9c 100644
--- a/Content.Client/Instruments/UI/InstrumentMenu.xaml.cs
+++ b/Content.Client/Instruments/UI/InstrumentMenu.xaml.cs
@@ -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(Entity, out var instrument))
{
return;
@@ -156,7 +153,7 @@ namespace Content.Client.Instruments.UI
if (!_entManager.System()
.OpenMidi(Entity,
- memStream.GetBuffer().AsSpan(0, (int) memStream.Length),
+ file.CopyToArray(),
instrument))
{
return;
diff --git a/Content.Server/Instruments/InstrumentSystem.cs b/Content.Server/Instruments/InstrumentSystem.cs
index 2539db7a6f..a347d7ea41 100644
--- a/Content.Server/Instruments/InstrumentSystem.cs
+++ b/Content.Server/Instruments/InstrumentSystem.cs
@@ -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(OnMidiStop);
SubscribeNetworkEvent(OnMidiSetMaster);
SubscribeNetworkEvent(OnMidiSetFilteredChannel);
+ SubscribeNetworkEvent(OnMidiSetChannels);
Subs.BuiEvents(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);
diff --git a/Content.Shared.Database/LogType.cs b/Content.Shared.Database/LogType.cs
index dc8265bb43..58a41a5f7a 100644
--- a/Content.Shared.Database/LogType.cs
+++ b/Content.Shared.Database/LogType.cs
@@ -472,5 +472,10 @@ public enum LogType
///
/// Damaging grid collision has occurred.
///
- ShuttleImpact = 102
+ ShuttleImpact = 102,
+
+ ///
+ /// Events relating to midi playback.
+ ///
+ Instrument = 103,
}
diff --git a/Content.Shared/CCVar/CCVars.Midi.cs b/Content.Shared/CCVar/CCVars.Midi.cs
index 4ca4bfd6f8..806bcc2b52 100644
--- a/Content.Shared/CCVar/CCVars.Midi.cs
+++ b/Content.Shared/CCVar/CCVars.Midi.cs
@@ -15,4 +15,10 @@ public sealed partial class CCVars
public static readonly CVarDef MaxMidiLaggedBatches =
CVarDef.Create("midi.max_lagged_batches", 8, CVar.SERVERONLY);
+
+ ///
+ /// Defines the max amount of characters to allow in the "Midi channel selector".
+ ///
+ public static readonly CVarDef MidiMaxChannelNameLength =
+ CVarDef.Create("midi.max_channel_name_length", 64, CVar.SERVERONLY);
}
diff --git a/Content.Shared/Instruments/SharedInstrumentComponent.cs b/Content.Shared/Instruments/SharedInstrumentComponent.cs
index da64bf8cd7..97eef752eb 100644
--- a/Content.Shared/Instruments/SharedInstrumentComponent.cs
+++ b/Content.Shared/Instruments/SharedInstrumentComponent.cs
@@ -38,7 +38,13 @@ public abstract partial class SharedInstrumentComponent : Component
/// Component that indicates that musical instrument was activated (ui opened).
///
[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,
}
+
+///
+/// Sets the MIDI channels on an instrument.
+///
+[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;
+ }
+}
+
+///
+/// Represents a single midi track with the track name, instrument name and bank instrument name extracted.
+///
+[Serializable, NetSerializable]
+public sealed class MidiTrack
+{
+ ///
+ /// The first specified Track Name
+ ///
+ public string? TrackName;
+ ///
+ /// The first specified instrument name
+ ///
+ public string? InstrumentName;
+
+ ///
+ /// The first program change resolved to the name.
+ ///
+ public string? ProgramName;
+
+ public override string ToString()
+ {
+ return $"Track Name: {TrackName}; Instrument Name: {InstrumentName}; Program Name: {ProgramName}";
+ }
+
+ ///
+ /// Truncates the fields based on the limit inputted into this method.
+ ///
+ 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;
+ }
+}
diff --git a/Resources/Locale/en-US/instruments/instruments-component.ftl b/Resources/Locale/en-US/instruments/instruments-component.ftl
index f0e0c1b3a9..76cfb28166 100644
--- a/Resources/Locale/en-US/instruments/instruments-component.ftl
+++ b/Resources/Locale/en-US/instruments/instruments-component.ftl
@@ -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