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
This commit is contained in:
Simon
2025-06-11 20:32:48 +02:00
committed by GitHub
parent 27cb97a17c
commit f5fbef7ccc
14 changed files with 882 additions and 15 deletions

View File

@@ -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;
}
}

View File

@@ -1,3 +1,4 @@
using System.IO;
using System.Linq; using System.Linq;
using Content.Shared.CCVar; using Content.Shared.CCVar;
using Content.Shared.Instruments; using Content.Shared.Instruments;
@@ -12,7 +13,7 @@ using Robust.Shared.Timing;
namespace Content.Client.Instruments; namespace Content.Client.Instruments;
public sealed class InstrumentSystem : SharedInstrumentSystem public sealed partial class InstrumentSystem : SharedInstrumentSystem
{ {
[Dependency] private readonly IClientNetManager _netManager = default!; [Dependency] private readonly IClientNetManager _netManager = default!;
[Dependency] private readonly IMidiManager _midiManager = default!; [Dependency] private readonly IMidiManager _midiManager = default!;
@@ -23,6 +24,8 @@ public sealed class InstrumentSystem : SharedInstrumentSystem
public int MaxMidiEventsPerBatch { get; private set; } public int MaxMidiEventsPerBatch { get; private set; }
public int MaxMidiEventsPerSecond { get; private set; } public int MaxMidiEventsPerSecond { get; private set; }
public event Action? OnChannelsUpdated;
public override void Initialize() public override void Initialize()
{ {
base.Initialize(); base.Initialize();
@@ -38,6 +41,26 @@ public sealed class InstrumentSystem : SharedInstrumentSystem
SubscribeLocalEvent<InstrumentComponent, ComponentShutdown>(OnShutdown); SubscribeLocalEvent<InstrumentComponent, ComponentShutdown>(OnShutdown);
SubscribeLocalEvent<InstrumentComponent, ComponentHandleState>(OnHandleState); 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) 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) 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)) if (!Resolve(uid, ref instrument))
return false; return false;
@@ -263,6 +292,8 @@ public sealed class InstrumentSystem : SharedInstrumentSystem
return false; return false;
SetMaster(uid, null); SetMaster(uid, null);
TrySetChannels(uid, data);
instrument.MidiEventBuffer.Clear(); instrument.MidiEventBuffer.Clear();
instrument.Renderer.OnMidiEvent += instrument.MidiEventBuffer.Add; instrument.Renderer.OnMidiEvent += instrument.MidiEventBuffer.Add;
return true; return true;

View File

@@ -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());
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -7,5 +7,7 @@
<Button Name="AllButton" Text="{Loc 'instruments-component-channels-all-button'}" HorizontalExpand="true" VerticalExpand="true" SizeFlagsStretchRatio="1"/> <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"/> <Button Name="ClearButton" Text="{Loc 'instruments-component-channels-clear-button'}" HorizontalExpand="true" VerticalExpand="true" SizeFlagsStretchRatio="1"/>
</BoxContainer> </BoxContainer>
<CheckButton Name="DisplayTrackNames"
Text="{Loc 'instruments-component-channels-track-names-toggle'}" />
</BoxContainer> </BoxContainer>
</DefaultWindow> </DefaultWindow>

View File

@@ -1,26 +1,56 @@
using Content.Shared.Instruments;
using Robust.Client.AutoGenerated; using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls; using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML; using Robust.Client.UserInterface.XAML;
using Robust.Shared.Audio.Midi; using Robust.Shared.Audio.Midi;
using Robust.Shared.Timing; using Robust.Shared.Utility;
namespace Content.Client.Instruments.UI; namespace Content.Client.Instruments.UI;
[GenerateTypedNameReferences] [GenerateTypedNameReferences]
public sealed partial class ChannelsMenu : DefaultWindow public sealed partial class ChannelsMenu : DefaultWindow
{ {
[Dependency] private readonly IEntityManager _entityManager = null!;
private readonly InstrumentBoundUserInterface _owner; private readonly InstrumentBoundUserInterface _owner;
public ChannelsMenu(InstrumentBoundUserInterface owner) : base() public ChannelsMenu(InstrumentBoundUserInterface owner) : base()
{ {
RobustXamlLoader.Load(this); RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
_owner = owner; _owner = owner;
ChannelList.OnItemSelected += OnItemSelected; ChannelList.OnItemSelected += OnItemSelected;
ChannelList.OnItemDeselected += OnItemDeselected; ChannelList.OnItemDeselected += OnItemDeselected;
AllButton.OnPressed += OnAllPressed; AllButton.OnPressed += OnAllPressed;
ClearButton.OnPressed += OnClearPressed; 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) 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(); ChannelList.Clear();
var instrument = _entityManager.GetComponent<InstrumentComponent>(_owner.Owner);
var activeInstrument = ResolveActiveInstrument(instrument);
for (int i = 0; i < RobustMidiEvent.MaxChannels; i++) for (int i = 0; i < RobustMidiEvent.MaxChannels; i++)
{ {
var item = ChannelList.AddItem(_owner.Loc.GetString("instrument-component-channel-name", var label = _owner.Loc.GetString("instrument-component-channel-name",
("number", i)), null, true, i); ("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; item.Selected = !instrument?.FilteredChannels[i] ?? false;
} }

View File

@@ -1,4 +1,5 @@
using Content.Shared.ActionBlocker; using Content.Shared.ActionBlocker;
using Content.Shared.Instruments;
using Content.Shared.Instruments.UI; using Content.Shared.Instruments.UI;
using Content.Shared.Interaction; using Content.Shared.Interaction;
using Robust.Client.Audio.Midi; using Robust.Client.Audio.Midi;
@@ -101,9 +102,7 @@ namespace Content.Client.Instruments.UI
public void OpenChannelsMenu() public void OpenChannelsMenu()
{ {
_channelsMenu ??= new ChannelsMenu(this); _channelsMenu ??= new ChannelsMenu(this);
EntMan.TryGetComponent(Owner, out InstrumentComponent? instrument); _channelsMenu.Populate();
_channelsMenu.Populate(instrument);
_channelsMenu.OpenCenteredRight(); _channelsMenu.OpenCenteredRight();
} }

View File

@@ -11,6 +11,7 @@ using Robust.Client.UserInterface.XAML;
using Robust.Shared.Containers; using Robust.Shared.Containers;
using Robust.Shared.Input; using Robust.Shared.Input;
using Robust.Shared.Timing; using Robust.Shared.Timing;
using Robust.Shared.Utility;
using static Robust.Client.UserInterface.Controls.BaseButton; using static Robust.Client.UserInterface.Controls.BaseButton;
using Range = Robust.Client.UserInterface.Controls.Range; using Range = Robust.Client.UserInterface.Controls.Range;
@@ -145,10 +146,6 @@ namespace Content.Client.Instruments.UI
if (!PlayCheck()) if (!PlayCheck())
return; return;
await using var memStream = new MemoryStream((int) file.Length);
await file.CopyToAsync(memStream);
if (!_entManager.TryGetComponent<InstrumentComponent>(Entity, out var instrument)) if (!_entManager.TryGetComponent<InstrumentComponent>(Entity, out var instrument))
{ {
return; return;
@@ -156,7 +153,7 @@ namespace Content.Client.Instruments.UI
if (!_entManager.System<InstrumentSystem>() if (!_entManager.System<InstrumentSystem>()
.OpenMidi(Entity, .OpenMidi(Entity,
memStream.GetBuffer().AsSpan(0, (int) memStream.Length), file.CopyToArray(),
instrument)) instrument))
{ {
return; return;

View File

@@ -1,8 +1,12 @@
using System.Linq;
using Content.Server.Administration; using Content.Server.Administration;
using Content.Server.Administration.Logs;
using Content.Server.Interaction; using Content.Server.Interaction;
using Content.Server.Popups; using Content.Server.Popups;
using Content.Server.Stunnable; using Content.Server.Stunnable;
using Content.Shared.Administration; using Content.Shared.Administration;
using Content.Shared.CCVar;
using Content.Shared.Database;
using Content.Shared.Examine; using Content.Shared.Examine;
using Content.Shared.Instruments; using Content.Shared.Instruments;
using Content.Shared.Instruments.UI; using Content.Shared.Instruments.UI;
@@ -17,6 +21,7 @@ using Robust.Shared.Console;
using Robust.Shared.GameStates; using Robust.Shared.GameStates;
using Robust.Shared.Player; using Robust.Shared.Player;
using Robust.Shared.Timing; using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Server.Instruments; namespace Content.Server.Instruments;
@@ -31,6 +36,7 @@ public sealed partial class InstrumentSystem : SharedInstrumentSystem
[Dependency] private readonly PopupSystem _popup = default!; [Dependency] private readonly PopupSystem _popup = default!;
[Dependency] private readonly TransformSystem _transform = default!; [Dependency] private readonly TransformSystem _transform = default!;
[Dependency] private readonly ExamineSystemShared _examineSystem = default!; [Dependency] private readonly ExamineSystemShared _examineSystem = default!;
[Dependency] private readonly IAdminLogManager _admingLogSystem = default!;
private const float MaxInstrumentBandRange = 10f; private const float MaxInstrumentBandRange = 10f;
@@ -50,6 +56,7 @@ public sealed partial class InstrumentSystem : SharedInstrumentSystem
SubscribeNetworkEvent<InstrumentStopMidiEvent>(OnMidiStop); SubscribeNetworkEvent<InstrumentStopMidiEvent>(OnMidiStop);
SubscribeNetworkEvent<InstrumentSetMasterEvent>(OnMidiSetMaster); SubscribeNetworkEvent<InstrumentSetMasterEvent>(OnMidiSetMaster);
SubscribeNetworkEvent<InstrumentSetFilteredChannelEvent>(OnMidiSetFilteredChannel); SubscribeNetworkEvent<InstrumentSetFilteredChannelEvent>(OnMidiSetFilteredChannel);
SubscribeNetworkEvent<InstrumentSetChannelsEvent>(OnMidiSetChannels);
Subs.BuiEvents<InstrumentComponent>(InstrumentUiKey.Key, subs => Subs.BuiEvents<InstrumentComponent>(InstrumentUiKey.Key, subs =>
{ {
@@ -132,6 +139,44 @@ public sealed partial class InstrumentSystem : SharedInstrumentSystem
Clean(uid, instrument); 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) private void OnMidiSetMaster(InstrumentSetMasterEvent msg, EntitySessionEventArgs args)
{ {
var uid = GetEntity(msg.Uid); var uid = GetEntity(msg.Uid);

View File

@@ -472,5 +472,10 @@ public enum LogType
/// <summary> /// <summary>
/// Damaging grid collision has occurred. /// Damaging grid collision has occurred.
/// </summary> /// </summary>
ShuttleImpact = 102 ShuttleImpact = 102,
/// <summary>
/// Events relating to midi playback.
/// </summary>
Instrument = 103,
} }

View File

@@ -15,4 +15,10 @@ public sealed partial class CCVars
public static readonly CVarDef<int> MaxMidiLaggedBatches = public static readonly CVarDef<int> MaxMidiLaggedBatches =
CVarDef.Create("midi.max_lagged_batches", 8, CVar.SERVERONLY); 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);
} }

View File

@@ -38,7 +38,13 @@ public abstract partial class SharedInstrumentComponent : Component
/// Component that indicates that musical instrument was activated (ui opened). /// Component that indicates that musical instrument was activated (ui opened).
/// </summary> /// </summary>
[RegisterComponent, NetworkedComponent] [RegisterComponent, NetworkedComponent]
public sealed partial class ActiveInstrumentComponent : Component; [AutoGenerateComponentState(true)]
public sealed partial class ActiveInstrumentComponent : Component
{
[DataField]
[AutoNetworkedField]
public MidiTrack?[] Tracks = [];
}
[Serializable, NetSerializable] [Serializable, NetSerializable]
public sealed class InstrumentComponentState : ComponentState public sealed class InstrumentComponentState : ComponentState
@@ -144,3 +150,72 @@ public enum InstrumentUiKey
{ {
Key, 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;
}
}

View File

@@ -19,6 +19,139 @@ instruments-component-channels-menu = MIDI Channel Selection
instrument-component-channel-name = MIDI Channel {$number} instrument-component-channel-name = MIDI Channel {$number}
instruments-component-channels-all-button = All instruments-component-channels-all-button = All
instruments-component-channels-clear-button = Clear 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 # SwappableInstrumentComponent
swappable-instrument-component-style-set = Style set to "{$style}" 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