Files
RobustToolbox/Robust.Client/Audio/Midi/MidiManager.cs
2025-08-06 01:12:55 +02:00

511 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using NFluidsynth;
using Robust.Shared;
using Robust.Shared.Asynchronous;
using Robust.Shared.Audio.Midi;
using Robust.Shared.Collections;
using Robust.Shared.Configuration;
using Robust.Shared.ContentPack;
using Robust.Shared.Exceptions;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Maths;
using Robust.Shared.Physics.Components;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Threading;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
using Robust.Shared.ViewVariables;
namespace Robust.Client.Audio.Midi;
internal sealed partial class MidiManager : IMidiManager
{
public const string SoundfontEnvironmentVariable = "ROBUST_SOUNDFONT_OVERRIDE";
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IResourceManager _resourceManager = default!;
[Dependency] private readonly IEntityManager _entityManager = default!;
[Dependency] private readonly IConfigurationManager _cfgMan = default!;
[Dependency] private readonly IAudioInternal _audio = default!;
[Dependency] private readonly ITaskManager _taskManager = default!;
[Dependency] private readonly ILogManager _logger = default!;
[Dependency] private readonly IParallelManager _parallel = default!;
[Dependency] private readonly IRuntimeLog _runtime = default!;
private AudioSystem _audioSys = default!;
private SharedPhysicsSystem _physics = default!;
private SharedTransformSystem _xformSystem = default!;
public IReadOnlyList<IMidiRenderer> Renderers
{
get
{
lock (_renderers)
{
// Perform a copy. Sadly, we can't return a reference to the original list due to threading concerns.
return _renderers.ToArray();
}
}
}
[ViewVariables]
public bool IsAvailable
{
get
{
InitializeFluidsynth();
return FluidsynthInitialized;
}
}
[ViewVariables] private readonly List<IMidiRenderer> _renderers = new();
// To avoid lock contention until some kind of MIDI refactor.
private TimeSpan _nextUpdate;
private TimeSpan _updateFrequency = TimeSpan.FromSeconds(0.25f);
private SemaphoreSlim _updateSemaphore = new(1);
private bool _alive = true;
[ViewVariables] private Settings? _settings;
private Thread? _midiThread;
private ISawmill _midiSawmill = default!;
private float _gain = 0f;
private bool _gainDirty = true;
// Not reliable until Fluidsynth is initialized!
[ViewVariables(VVAccess.ReadWrite)]
public float Gain
{
get => _gain;
set
{
var clamped = Math.Clamp(value, 0f, 1f);
if (MathHelper.CloseToPercent(_gain, clamped))
return;
_cfgMan.SetCVar(CVars.MidiVolume, clamped);
_gainDirty = true;
}
}
private static readonly string[] LinuxSoundfonts =
{
"/usr/share/soundfonts/default.sf2",
"/usr/share/soundfonts/default.dls",
"/usr/share/soundfonts/FluidR3_GM.sf2",
"/usr/share/soundfonts/FluidR3_GM2-2.sf2",
"/usr/share/soundfonts/freepats-general-midi.sf2",
"/usr/share/sounds/sf2/default.sf2",
"/usr/share/sounds/sf2/default.dls",
"/usr/share/sounds/sf2/FluidR3_GM.sf2",
"/usr/share/sounds/sf2/FluidR3_GM2-2.sf2",
"/usr/share/sounds/sf2/TimGM6mb.sf2",
};
private static readonly string WindowsSoundfont =
$@"{Environment.GetEnvironmentVariable("SystemRoot")}\system32\drivers\gm.dls";
private const string OsxSoundfont =
"/System/Library/Components/CoreAudio.component/Contents/Resources/gs_instruments.dls";
private static readonly ResPath FallbackSoundfont = new ResPath("/Midi/fallback.sf2");
private const string ContentCustomSoundfontDirectory = "/Audio/MidiCustom/";
private static ResPath CustomSoundfontDirectory = new("/soundfonts/");
private readonly ResourceLoaderCallbacks _soundfontLoaderCallbacks;
private bool FluidsynthInitialized;
private bool _failedInitialize;
private NFluidsynth.Logger.LoggerDelegate _loggerDelegate = default!;
private ISawmill _fluidsynthSawmill = default!;
private MidiUpdateJob _updateJob;
public MidiManager()
{
_soundfontLoaderCallbacks = new ResourceLoaderCallbacks(this);
}
private void InitializeFluidsynth()
{
if (FluidsynthInitialized || _failedInitialize) return;
_cfgMan.OnValueChanged(CVars.MidiVolume,
value =>
{
_gain = value;
_gainDirty = true;
},
true);
_midiSawmill = _logger.GetSawmill("midi");
#if DEBUG
_midiSawmill.Level = LogLevel.Debug;
#else
_midiSawmill.Level = LogLevel.Error;
#endif
_fluidsynthSawmill = _logger.GetSawmill("midi.fluidsynth");
_loggerDelegate = LoggerDelegate;
if (!_resourceManager.UserData.Exists(CustomSoundfontDirectory))
{
_resourceManager.UserData.CreateDir(CustomSoundfontDirectory);
}
// not a directory, preserve the old file and create an actual directory
else if (!_resourceManager.UserData.IsDir(CustomSoundfontDirectory))
{
_resourceManager.UserData.Rename(CustomSoundfontDirectory,
CustomSoundfontDirectory.WithName(CustomSoundfontDirectory.Filename + ".old"));
_resourceManager.UserData.CreateDir(CustomSoundfontDirectory);
}
try
{
NFluidsynth.Logger
.SetLoggerMethod(_loggerDelegate); // Will cause a safe DllNotFoundException if not available.
_settings = new Settings();
_settings["synth.sample-rate"].DoubleValue = 44100;
_settings["player.timing-source"].StringValue = "sample";
_settings["synth.lock-memory"].IntValue = 0;
_settings["synth.threadsafe-api"].IntValue = 1;
_settings["synth.gain"].DoubleValue = 1.0d;
_settings["synth.midi-channels"].IntValue = 16;
_settings["synth.overflow.age"].DoubleValue = 3000;
_settings["audio.driver"].StringValue = "file";
_settings["audio.periods"].IntValue = 8;
_settings["audio.period-size"].IntValue = 4096;
_settings["midi.autoconnect"].IntValue = 1;
_settings["player.reset-synth"].IntValue = 0;
_settings["synth.midi-channels"].IntValue = Math.Clamp(RobustMidiEvent.MaxChannels, 16, 256);
_settings["synth.midi-bank-select"].StringValue = "gm";
//_settings["synth.verbose"].IntValue = 1; // Useful for debugging.
var midiParallel = _cfgMan.GetCVar(CVars.MidiParallelism);
_settings["synth.polyphony"].IntValue = Math.Clamp(1024 + (int) (Math.Log2(midiParallel) * 2048), 1, 65535);
_settings["synth.cpu-cores"].IntValue = Math.Clamp(midiParallel, 1, 256);
_midiSawmill.Debug($"Synth Cores: {_settings["synth.cpu-cores"].IntValue}");
_midiSawmill.Debug($"Synth Polyphony: {_settings["synth.polyphony"].IntValue}");
}
catch (Exception e)
{
_midiSawmill.Error("Failed to initialize fluidsynth due to exception, disabling MIDI support:\n{0}", e);
_failedInitialize = true;
return;
}
_midiThread = new Thread(ThreadUpdate)
{
Name = "RobustToolbox MIDI Thread"
};
_midiThread.Start();
_updateJob = new MidiUpdateJob()
{
Manager = this,
Renderers = _renderers,
};
_audioSys = _entityManager.EntitySysManager.GetEntitySystem<AudioSystem>();
_physics = _entityManager.EntitySysManager.GetEntitySystem<SharedPhysicsSystem>();
_xformSystem = _entityManager.System<SharedTransformSystem>();
_entityManager.GetEntityQuery<PhysicsComponent>();
_entityManager.GetEntityQuery<TransformComponent>();
FluidsynthInitialized = true;
}
private void LoggerDelegate(NFluidsynth.Logger.LogLevel level, string message, IntPtr data)
{
var rLevel = level switch
{
NFluidsynth.Logger.LogLevel.Panic => LogLevel.Error,
NFluidsynth.Logger.LogLevel.Error => LogLevel.Error,
NFluidsynth.Logger.LogLevel.Warning => LogLevel.Warning,
NFluidsynth.Logger.LogLevel.Information => LogLevel.Info,
NFluidsynth.Logger.LogLevel.Debug => LogLevel.Debug,
_ => LogLevel.Debug
};
_fluidsynthSawmill.Log(rLevel, message);
}
public IMidiRenderer? GetNewRenderer(bool mono = true)
{
if (!FluidsynthInitialized)
{
InitializeFluidsynth();
if (!FluidsynthInitialized) // init failed
{
return null;
}
}
var soundfontLoader = SoundFontLoader.NewDefaultSoundFontLoader(_settings);
// Just making double sure these don't get GC'd.
// They shouldn't, MidiRenderer keeps a ref, but making sure...
var handle = GCHandle.Alloc(soundfontLoader);
try
{
soundfontLoader.SetCallbacks(_soundfontLoaderCallbacks);
var renderer =
new MidiRenderer(_settings!, soundfontLoader, mono, this, _audio, _taskManager, _midiSawmill);
LoadSoundFontSetup(renderer);
renderer.Source.Gain = _gain;
lock (_renderers)
{
_renderers.Add(renderer);
}
return renderer;
}
finally
{
handle.Free();
}
}
public void FrameUpdate(float frameTime)
{
if (!FluidsynthInitialized)
{
return;
}
if (_nextUpdate > _timing.RealTime)
return;
_nextUpdate = _timing.RealTime + _updateFrequency;
// Update positions of streams occasionally.
// This has a lot of code duplication with AudioSystem.FrameUpdate(), and they should probably be combined somehow.
// so TRUE
_updateJob.OurPosition = _audioSys.GetListenerCoordinates();
// This semaphore is here to avoid lock contention as much as possible.
_updateSemaphore.Wait();
// The ONLY time this should be contested is with ThreadUpdate.
// If that becomes NOT the case then just lock this, remove the semaphore, and drop the update frequency even harder.
// ReSharper disable once InconsistentlySynchronizedField
_parallel.ProcessNow(_updateJob, _renderers.Count);
_updateSemaphore.Release();
_gainDirty = false;
}
private void UpdateRenderer(IMidiRenderer renderer, MapCoordinates listener)
{
try
{
if (renderer.Disposed)
return;
if (!renderer.Mono)
renderer.Source.Global = true;
if (!renderer.Source.Global)
UpdateLocalRenderer(renderer, listener);
else
UpdateGlobalRenderer(renderer);
}
catch (Exception ex)
{
_runtime.LogException(ex, _midiSawmill.Name);
}
}
private void UpdateLocalRenderer(IMidiRenderer renderer, MapCoordinates listener)
{
if (_entityManager.Deleted(renderer.TrackingEntity) || _entityManager.IsPaused(renderer.TrackingEntity))
{
renderer.Source.Gain = 0f;
return;
}
MapCoordinates mapCoords = _xformSystem.GetMapCoordinates(renderer.TrackingEntity.Value);
renderer.TrackingCoordinates = mapCoords;
if (mapCoords.MapId == MapId.Nullspace || mapCoords.MapId != listener.MapId)
{
renderer.Source.Gain = 0f;
return;
}
Vector2 mapPosition = mapCoords.Position;
Vector2 listenerDelta = mapPosition - listener.Position;
var listenerDeltaLength = listenerDelta.Length();
if (listenerDeltaLength > renderer.Source.MaxDistance)
{
renderer.Source.Gain = 0f;
return;
}
if (listenerDeltaLength is > 0f and < 0.01f)
{
mapPosition = listener.Position;
listenerDelta = Vector2.Zero;
listenerDeltaLength = 0f;
}
if (_gainDirty || renderer.Source.Gain == 0f)
renderer.Source.Gain = Gain;
renderer.Source.Position = mapPosition;
renderer.Source.Velocity = _physics.GetMapLinearVelocity(renderer.TrackingEntity.Value);
renderer.Source.Occlusion =
_audioSys.GetOcclusion(listener, listenerDelta, listenerDeltaLength, renderer.TrackingEntity);
}
private void UpdateGlobalRenderer(IMidiRenderer renderer)
{
if (_gainDirty)
renderer.Source.Gain = Gain;
}
/// <summary>
/// Main method for the thread rendering the midi audio.
/// </summary>
private void ThreadUpdate()
{
while (_alive)
{
lock (_renderers)
{
var toRemove = new ValueList<IMidiRenderer>();
for (var i = 0; i < _renderers.Count; i++)
{
var renderer = _renderers[i];
lock (renderer)
{
if (!renderer.Disposed)
{
if (renderer.Master is {Disposed: true})
renderer.Master = null;
renderer.Render();
}
else
{
toRemove.Add(renderer);
}
}
}
if (toRemove.Count > 0)
{
_updateSemaphore.Wait();
foreach (var renderer in toRemove)
{
renderer.InternalDispose();
_renderers.Remove(renderer);
}
_updateSemaphore.Release();
}
}
Thread.Sleep(1);
}
}
public void Shutdown()
{
_alive = false;
_midiThread?.Join();
_settings?.Dispose();
lock (_renderers)
{
foreach (var renderer in _renderers)
{
renderer?.Dispose();
}
}
if (FluidsynthInitialized && !_failedInitialize)
{
NFluidsynth.Logger.SetLoggerMethod(null);
}
}
/// <summary>
/// Internal method to get a human-readable representation of a <see cref="SequencerEvent"/>.
/// </summary>
internal static string SequencerEventToString(SequencerEvent midiEvent)
{
// ReSharper disable once UseStringInterpolation
return string.Format(
"{0} chan:{1:D2} key:{2:D5} bank:{3:D2} ctrl:{4:D5} dur:{5:D5} pitch:{6:D5} prog:{7:D3} val:{8:D5} vel:{9:D5}",
midiEvent.Type.ToString().PadLeft(22),
midiEvent.Channel,
midiEvent.Key,
midiEvent.Bank,
midiEvent.Control,
midiEvent.Duration,
midiEvent.Pitch,
midiEvent.Program,
midiEvent.Value,
midiEvent.Velocity);
}
#region Jobs
private record struct MidiUpdateJob : IParallelRobustJob
{
public int MinimumBatchParallel => 2;
public int BatchSize => 1;
public MidiManager Manager;
public MapCoordinates OurPosition;
public List<IMidiRenderer> Renderers;
public void Execute(int index)
{
// The indices shouldn't be able to be touched while this job is running, just the renderer itself getting locked.
var renderer = Renderers[index];
lock (renderer)
{
Manager.UpdateRenderer(renderer, OurPosition);
}
}
}
#endregion
}