diff --git a/Content.Client/Anomaly/Ui/AnomalyScannerBoundUserInterface.cs b/Content.Client/Anomaly/Ui/AnomalyScannerBoundUserInterface.cs index 97bc0276e9..124057a701 100644 --- a/Content.Client/Anomaly/Ui/AnomalyScannerBoundUserInterface.cs +++ b/Content.Client/Anomaly/Ui/AnomalyScannerBoundUserInterface.cs @@ -20,6 +20,7 @@ public sealed class AnomalyScannerBoundUserInterface : BoundUserInterface _menu = new AnomalyScannerMenu(); _menu.OpenCentered(); + _menu.OnClose += Close; } protected override void UpdateState(BoundUserInterfaceState state) diff --git a/Content.Client/Atmos/UI/GasAnalyzerBoundUserInterface.cs b/Content.Client/Atmos/UI/GasAnalyzerBoundUserInterface.cs index f838a69fdf..3a5df3f9a8 100644 --- a/Content.Client/Atmos/UI/GasAnalyzerBoundUserInterface.cs +++ b/Content.Client/Atmos/UI/GasAnalyzerBoundUserInterface.cs @@ -1,4 +1,4 @@ -using Robust.Client.GameObjects; +using Robust.Client.GameObjects; using Robust.Client.UserInterface; using static Content.Shared.Atmos.Components.GasAnalyzerComponent; @@ -18,6 +18,7 @@ namespace Content.Client.Atmos.UI base.Open(); _window = this.CreateWindowCenteredLeft(); + _window.OnClose += Close; } protected override void ReceiveMessage(BoundUserInterfaceMessage message) @@ -29,15 +30,6 @@ namespace Content.Client.Atmos.UI _window.Populate(cast); } - /// - /// Closes UI and tells the server to disable the analyzer - /// - private void OnClose() - { - SendMessage(new GasAnalyzerDisableMessage()); - Close(); - } - protected override void Dispose(bool disposing) { base.Dispose(disposing); diff --git a/Content.Client/Audio/AmbientOverlayCommand.cs b/Content.Client/Audio/AmbientOverlayCommand.cs index 909353cd24..7bbc6c6cbf 100644 --- a/Content.Client/Audio/AmbientOverlayCommand.cs +++ b/Content.Client/Audio/AmbientOverlayCommand.cs @@ -2,16 +2,16 @@ using Robust.Shared.Console; namespace Content.Client.Audio; -public sealed class AmbientOverlayCommand : IConsoleCommand +public sealed class AmbientOverlayCommand : LocalizedEntityCommands { - public string Command => "showambient"; - public string Description => "Shows all AmbientSoundComponents in the viewport"; - public string Help => $"{Command}"; - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - var system = IoCManager.Resolve().GetEntitySystem(); - system.OverlayEnabled ^= true; + [Dependency] private readonly AmbientSoundSystem _ambient = default!; - shell.WriteLine($"Ambient sound overlay set to {system.OverlayEnabled}"); + public override string Command => "showambient"; + + public override void Execute(IConsoleShell shell, string argStr, string[] args) + { + _ambient.OverlayEnabled ^= true; + + shell.WriteLine(Loc.GetString($"cmd-showambient-status", ("status", _ambient.OverlayEnabled))); } } diff --git a/Content.Client/Changelog/ChangelogWindow.xaml.cs b/Content.Client/Changelog/ChangelogWindow.xaml.cs index f46ffa7b91..d82c34254c 100644 --- a/Content.Client/Changelog/ChangelogWindow.xaml.cs +++ b/Content.Client/Changelog/ChangelogWindow.xaml.cs @@ -15,8 +15,8 @@ namespace Content.Client.Changelog [GenerateTypedNameReferences] public sealed partial class ChangelogWindow : FancyWindow { - [Dependency] private readonly ChangelogManager _changelog = default!; [Dependency] private readonly IClientAdminManager _adminManager = default!; + [Dependency] private readonly ChangelogManager _changelog = default!; public ChangelogWindow() { @@ -112,15 +112,15 @@ namespace Content.Client.Changelog } [UsedImplicitly, AnyCommand] - public sealed class ChangelogCommand : IConsoleCommand + public sealed class ChangelogCommand : LocalizedCommands { - public string Command => "changelog"; - public string Description => "Opens the changelog"; - public string Help => "Usage: changelog"; + [Dependency] private readonly IUserInterfaceManager _uiManager = default!; - public void Execute(IConsoleShell shell, string argStr, string[] args) + public override string Command => "changelog"; + + public override void Execute(IConsoleShell shell, string argStr, string[] args) { - IoCManager.Resolve().GetUIController().OpenWindow(); + _uiManager.GetUIController().OpenWindow(); } } } diff --git a/Content.Client/Chat/UI/SpeechBubble.cs b/Content.Client/Chat/UI/SpeechBubble.cs index 442368a3e6..0bfe6dc7c8 100644 --- a/Content.Client/Chat/UI/SpeechBubble.cs +++ b/Content.Client/Chat/UI/SpeechBubble.cs @@ -14,6 +14,7 @@ namespace Content.Client.Chat.UI { public abstract class SpeechBubble : Control { + [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IEyeManager _eyeManager = default!; [Dependency] private readonly IEntityManager _entityManager = default!; [Dependency] protected readonly IConfigurationManager ConfigManager = default!; @@ -30,12 +31,12 @@ namespace Content.Client.Chat.UI /// /// The total time a speech bubble stays on screen. /// - private const float TotalTime = 4; + private static readonly TimeSpan TotalTime = TimeSpan.FromSeconds(4); /// /// The amount of time at the end of the bubble's life at which it starts fading. /// - private const float FadeTime = 0.25f; + private static readonly TimeSpan FadeTime = TimeSpan.FromSeconds(0.25f); /// /// The distance in world space to offset the speech bubble from the center of the entity. @@ -50,7 +51,10 @@ namespace Content.Client.Chat.UI private readonly EntityUid _senderEntity; - private float _timeLeft = TotalTime; + /// + /// The time at which this bubble will die. + /// + private TimeSpan _deathTime; public float VerticalOffset { get; set; } private float _verticalOffsetAchieved; @@ -99,6 +103,7 @@ namespace Content.Client.Chat.UI bubble.Measure(Vector2Helpers.Infinity); ContentSize = bubble.DesiredSize; _verticalOffsetAchieved = -ContentSize.Y; + _deathTime = _timing.RealTime + TotalTime; } protected abstract Control BuildBubble(ChatMessage message, string speechStyleClass, Color? fontColor = null); @@ -107,8 +112,8 @@ namespace Content.Client.Chat.UI { base.FrameUpdate(args); - _timeLeft -= args.DeltaSeconds; - if (_entityManager.Deleted(_senderEntity) || _timeLeft <= 0) + var timeLeft = (float)(_deathTime - _timing.RealTime).TotalSeconds; + if (_entityManager.Deleted(_senderEntity) || timeLeft <= 0) { // Timer spawn to prevent concurrent modification exception. Timer.Spawn(0, Die); @@ -131,10 +136,10 @@ namespace Content.Client.Chat.UI return; } - if (_timeLeft <= FadeTime) + if (timeLeft <= FadeTime.TotalSeconds) { // Update alpha if we're fading. - Modulate = Color.White.WithAlpha(_timeLeft / FadeTime); + Modulate = Color.White.WithAlpha(timeLeft / (float)FadeTime.TotalSeconds); } else { @@ -144,7 +149,7 @@ namespace Content.Client.Chat.UI var baseOffset = 0f; - if (_entityManager.TryGetComponent(_senderEntity, out var speech)) + if (_entityManager.TryGetComponent(_senderEntity, out var speech)) baseOffset = speech.SpeechBubbleOffset; var offset = (-_eyeManager.CurrentEye.Rotation).ToWorldVec() * -(EntityVerticalOffset + baseOffset); @@ -175,9 +180,9 @@ namespace Content.Client.Chat.UI /// public void FadeNow() { - if (_timeLeft > FadeTime) + if (_deathTime > _timing.RealTime) { - _timeLeft = FadeTime; + _deathTime = _timing.RealTime + FadeTime; } } diff --git a/Content.Client/Chemistry/EntitySystems/HypospraySystem.cs b/Content.Client/Chemistry/EntitySystems/HyposprayStatusControlSystem.cs similarity index 70% rename from Content.Client/Chemistry/EntitySystems/HypospraySystem.cs rename to Content.Client/Chemistry/EntitySystems/HyposprayStatusControlSystem.cs index ee7aa3aafe..4dfc8506d2 100644 --- a/Content.Client/Chemistry/EntitySystems/HypospraySystem.cs +++ b/Content.Client/Chemistry/EntitySystems/HyposprayStatusControlSystem.cs @@ -5,8 +5,9 @@ using Content.Shared.Chemistry.EntitySystems; namespace Content.Client.Chemistry.EntitySystems; -public sealed class HypospraySystem : SharedHypospraySystem +public sealed class HyposprayStatusControlSystem : EntitySystem { + [Dependency] private readonly SharedSolutionContainerSystem _solutionContainers = default!; public override void Initialize() { base.Initialize(); diff --git a/Content.Client/Construction/UI/ConstructionMenuPresenter.cs b/Content.Client/Construction/UI/ConstructionMenuPresenter.cs index b1cebab33a..119e92fc6f 100644 --- a/Content.Client/Construction/UI/ConstructionMenuPresenter.cs +++ b/Content.Client/Construction/UI/ConstructionMenuPresenter.cs @@ -38,7 +38,7 @@ namespace Content.Client.Construction.UI private ConstructionSystem? _constructionSystem; private ConstructionPrototype? _selected; private List _favoritedRecipes = []; - private Dictionary _recipeButtons = new(); + private readonly Dictionary _recipeButtons = new(); private string _selectedCategory = string.Empty; private const string FavoriteCatName = "construction-category-favorites"; @@ -217,8 +217,8 @@ namespace Content.Client.Construction.UI var itemButton = new ContainerButton() { VerticalAlignment = Control.VAlignment.Center, - Name = recipe.TargetPrototype.Name, - ToolTip = recipe.TargetPrototype.Name, + Name = recipe.Prototype.Name, + ToolTip = recipe.Prototype.Name, ToggleMode = true, Children = { protoView }, }; @@ -235,7 +235,7 @@ namespace Content.Client.Construction.UI if (buttonToggledEventArgs.Pressed && _selected != null && - _recipeButtons.TryGetValue(_selected.Name!, out var oldButton)) + _recipeButtons.TryGetValue(_selected.ID, out var oldButton)) { oldButton.Pressed = false; SelectGridButton(oldButton, false); @@ -245,7 +245,7 @@ namespace Content.Client.Construction.UI }; recipesGrid.AddChild(itemButtonPanelContainer); - _recipeButtons[recipe.Prototype.Name!] = itemButton; + _recipeButtons[recipe.Prototype.ID] = itemButton; var isCurrentButtonSelected = _selected == recipe.Prototype; itemButton.Pressed = isCurrentButtonSelected; SelectGridButton(itemButton, isCurrentButtonSelected); @@ -307,7 +307,7 @@ namespace Content.Client.Construction.UI if (button.Parent is not PanelContainer buttonPanel) return; - button.Modulate = select ? Color.Green : Color.Transparent; + button.Children.Single().Modulate = select ? Color.Green : Color.White; var buttonColor = select ? StyleNano.ButtonColorDefault : Color.Transparent; buttonPanel.PanelOverride = new StyleBoxFlat { BackgroundColor = buttonColor }; } diff --git a/Content.Client/Decals/ToggleDecalCommand.cs b/Content.Client/Decals/ToggleDecalCommand.cs index 025ed1299d..cf7113cb50 100644 --- a/Content.Client/Decals/ToggleDecalCommand.cs +++ b/Content.Client/Decals/ToggleDecalCommand.cs @@ -1,17 +1,15 @@ using Robust.Shared.Console; -using Robust.Shared.GameObjects; namespace Content.Client.Decals; -public sealed class ToggleDecalCommand : IConsoleCommand +public sealed class ToggleDecalCommand : LocalizedEntityCommands { - [Dependency] private readonly IEntityManager _e = default!; + [Dependency] private readonly DecalSystem _decal = default!; - public string Command => "toggledecals"; - public string Description => "Toggles decaloverlay"; - public string Help => $"{Command}"; - public void Execute(IConsoleShell shell, string argStr, string[] args) + public override string Command => "toggledecals"; + + public override void Execute(IConsoleShell shell, string argStr, string[] args) { - _e.System().ToggleOverlay(); + _decal.ToggleOverlay(); } } diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs index ede0b0bcea..18ce1fd156 100644 --- a/Content.Client/Entry/EntryPoint.cs +++ b/Content.Client/Entry/EntryPoint.cs @@ -16,6 +16,7 @@ using Content.Client.Lobby; using Content.Client.MainMenu; using Content.Client.Parallax.Managers; using Content.Client.Players.PlayTimeTracking; +using Content.Client.Playtime; using Content.Client.Radiation.Overlays; using Content.Client.Replay; using Content.Client.Screenshot; @@ -76,6 +77,7 @@ namespace Content.Client.Entry [Dependency] private readonly DebugMonitorManager _debugMonitorManager = default!; [Dependency] private readonly TitleWindowManager _titleWindowManager = default!; [Dependency] private readonly IEntitySystemManager _entitySystemManager = default!; + [Dependency] private readonly ClientsidePlaytimeTrackingManager _clientsidePlaytimeManager = default!; public override void Init() { @@ -139,6 +141,7 @@ namespace Content.Client.Entry _extendedDisconnectInformation.Initialize(); _jobRequirements.Initialize(); _playbackMan.Initialize(); + _clientsidePlaytimeManager.Initialize(); //AUTOSCALING default Setup! _configManager.SetCVar("interface.resolutionAutoScaleUpperCutoffX", 1080); diff --git a/Content.Client/Ghost/Commands/ToggleGhostVisibilityCommand.cs b/Content.Client/Ghost/Commands/ToggleGhostVisibilityCommand.cs index 480da6ad8d..3dacc81f21 100644 --- a/Content.Client/Ghost/Commands/ToggleGhostVisibilityCommand.cs +++ b/Content.Client/Ghost/Commands/ToggleGhostVisibilityCommand.cs @@ -2,25 +2,17 @@ namespace Content.Client.Ghost.Commands; -public sealed class ToggleGhostVisibilityCommand : IConsoleCommand +public sealed class ToggleGhostVisibilityCommand : LocalizedEntityCommands { - [Dependency] private readonly IEntitySystemManager _entSysMan = default!; + [Dependency] private readonly GhostSystem _ghost = default!; - public string Command => "toggleghostvisibility"; - public string Description => "Toggles ghost visibility on the client."; - public string Help => "toggleghostvisibility [bool]"; + public override string Command => "toggleghostvisibility"; - public void Execute(IConsoleShell shell, string argStr, string[] args) + public override void Execute(IConsoleShell shell, string argStr, string[] args) { - var ghostSystem = _entSysMan.GetEntitySystem(); - if (args.Length != 0 && bool.TryParse(args[0], out var visibility)) - { - ghostSystem.ToggleGhostVisibility(visibility); - } + _ghost.ToggleGhostVisibility(visibility); else - { - ghostSystem.ToggleGhostVisibility(); - } + _ghost.ToggleGhostVisibility(); } } diff --git a/Content.Client/Ghost/GhostToggleSelfVisibility.cs b/Content.Client/Ghost/GhostToggleSelfVisibility.cs index bc4531ce92..fea9a2fc0d 100644 --- a/Content.Client/Ghost/GhostToggleSelfVisibility.cs +++ b/Content.Client/Ghost/GhostToggleSelfVisibility.cs @@ -4,28 +4,27 @@ using Robust.Shared.Console; namespace Content.Client.Ghost; -public sealed class GhostToggleSelfVisibility : IConsoleCommand +public sealed class GhostToggleSelfVisibility : LocalizedEntityCommands { - public string Command => "toggleselfghost"; - public string Description => "Toggles seeing your own ghost."; - public string Help => "toggleselfghost"; - public void Execute(IConsoleShell shell, string argStr, string[] args) + [Dependency] private readonly SpriteSystem _sprite = default!; + + public override string Command => "toggleselfghost"; + + public override void Execute(IConsoleShell shell, string argStr, string[] args) { var attachedEntity = shell.Player?.AttachedEntity; if (!attachedEntity.HasValue) return; - var entityManager = IoCManager.Resolve(); - if (!entityManager.HasComponent(attachedEntity)) + if (!EntityManager.HasComponent(attachedEntity)) { - shell.WriteError("Entity must be a ghost."); + shell.WriteError(Loc.GetString($"cmd-toggleselfghost-must-be-ghost")); return; } - if (!entityManager.TryGetComponent(attachedEntity, out SpriteComponent? spriteComponent)) + if (!EntityManager.TryGetComponent(attachedEntity, out SpriteComponent? spriteComponent)) return; - var spriteSys = entityManager.System(); - spriteSys.SetVisible((attachedEntity.Value, spriteComponent), !spriteComponent.Visible); + _sprite.SetVisible((attachedEntity.Value, spriteComponent), !spriteComponent.Visible); } } 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 @@