mirror of
https://github.com/space-wizards/RobustToolbox.git
synced 2026-09-16 07:12:27 +02:00
Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com> Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com>
494 lines
16 KiB
C#
494 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
using System.Threading;
|
|
using NFluidsynth;
|
|
using Robust.Client.Graphics;
|
|
using Robust.Client.ResourceManagement;
|
|
using Robust.Shared;
|
|
using Robust.Shared.Configuration;
|
|
using Robust.Shared.ContentPack;
|
|
using Robust.Shared.GameObjects;
|
|
using Robust.Shared.IoC;
|
|
using Robust.Shared.Log;
|
|
using Robust.Shared.Map;
|
|
using Robust.Shared.Maths;
|
|
using Robust.Shared.Physics;
|
|
using Robust.Shared.Physics.Broadphase;
|
|
using Robust.Shared.Utility;
|
|
using Robust.Shared.ViewVariables;
|
|
using Logger = Robust.Shared.Log.Logger;
|
|
|
|
namespace Robust.Client.Audio.Midi
|
|
{
|
|
public interface IMidiManager
|
|
{
|
|
/// <summary>
|
|
/// This method tries to return a midi renderer ready to be used.
|
|
/// You only need to set the <see cref="IMidiRenderer.MidiProgram"/> afterwards.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This method can fail if MIDI support is not available.
|
|
/// </remarks>
|
|
/// <returns>
|
|
/// <c>null</c> if MIDI support is not available.
|
|
/// </returns>
|
|
IMidiRenderer? GetNewRenderer();
|
|
|
|
/// <summary>
|
|
/// Method called every frame.
|
|
/// Should be used to update positional audio.
|
|
/// </summary>
|
|
/// <param name="frameTime"></param>
|
|
void FrameUpdate(float frameTime);
|
|
|
|
/// <summary>
|
|
/// Volume, in db.
|
|
/// </summary>
|
|
float Volume { get; set; }
|
|
|
|
/// <summary>
|
|
/// If true, MIDI support is available.
|
|
/// </summary>
|
|
bool IsAvailable { get; }
|
|
|
|
public int OcclusionCollisionMask { get; set; }
|
|
|
|
void Shutdown();
|
|
}
|
|
|
|
internal sealed class MidiManager : IMidiManager
|
|
{
|
|
[Dependency] private readonly IEyeManager _eyeManager = default!;
|
|
[Dependency] private readonly IResourceManagerInternal _resourceManager = default!;
|
|
[Dependency] private readonly IEntityManager _entityManager = default!;
|
|
[Dependency] private readonly IConfigurationManager _cfgMan = default!;
|
|
|
|
private SharedPhysicsSystem _broadPhaseSystem = default!;
|
|
|
|
[ViewVariables]
|
|
public bool IsAvailable
|
|
{
|
|
get
|
|
{
|
|
InitializeFluidsynth();
|
|
|
|
return FluidsynthInitialized;
|
|
}
|
|
}
|
|
|
|
[ViewVariables]
|
|
private readonly List<IMidiRenderer> _renderers = new();
|
|
|
|
private bool _alive = true;
|
|
private Settings? _settings;
|
|
private Thread? _midiThread;
|
|
private ISawmill _midiSawmill = default!;
|
|
private float _volume = 0f;
|
|
private bool _volumeDirty = true;
|
|
|
|
// Not reliable until Fluidsynth is initialized!
|
|
[ViewVariables(VVAccess.ReadWrite)]
|
|
public float Volume
|
|
{
|
|
get => _volume;
|
|
set
|
|
{
|
|
if (MathHelper.CloseToPercent(_volume, value))
|
|
return;
|
|
|
|
_cfgMan.SetCVar(CVars.MidiVolume, value);
|
|
_volumeDirty = true;
|
|
}
|
|
}
|
|
|
|
private static readonly string[] LinuxSoundfonts =
|
|
{
|
|
"/usr/share/soundfonts/default.sf2",
|
|
"/usr/share/soundfonts/FluidR3_GM.sf2",
|
|
"/usr/share/soundfonts/freepats-general-midi.sf2",
|
|
"/usr/share/sounds/sf2/default.sf2",
|
|
"/usr/share/sounds/sf2/FluidR3_GM.sf2",
|
|
"/usr/share/sounds/sf2/TimGM6mb.sf2",
|
|
};
|
|
|
|
private const string WindowsSoundfont = @"C:\WINDOWS\system32\drivers\gm.dls";
|
|
|
|
private const string OsxSoundfont =
|
|
"/System/Library/Components/CoreAudio.component/Contents/Resources/gs_instruments.dls";
|
|
|
|
private const string FallbackSoundfont = "/Midi/fallback.sf2";
|
|
|
|
private readonly ResourceLoaderCallbacks _soundfontLoaderCallbacks = new();
|
|
|
|
private bool FluidsynthInitialized;
|
|
private bool _failedInitialize;
|
|
|
|
private NFluidsynth.Logger.LoggerDelegate _loggerDelegate = default!;
|
|
private ISawmill _sawmill = default!;
|
|
|
|
[ViewVariables(VVAccess.ReadWrite)]
|
|
public int OcclusionCollisionMask { get; set; }
|
|
|
|
private void InitializeFluidsynth()
|
|
{
|
|
if (FluidsynthInitialized || _failedInitialize) return;
|
|
|
|
_volume = _cfgMan.GetCVar(CVars.MidiVolume);
|
|
_cfgMan.OnValueChanged(CVars.MidiVolume, value =>
|
|
{
|
|
_volume = value;
|
|
_volumeDirty = true;
|
|
}, true);
|
|
|
|
_midiSawmill = Logger.GetSawmill("midi");
|
|
_sawmill = Logger.GetSawmill("midi.fluidsynth");
|
|
_loggerDelegate = LoggerDelegate;
|
|
|
|
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.polyphony"].IntValue = 1024;
|
|
_settings["synth.cpu-cores"].IntValue = 2;
|
|
_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-bank-select"].StringValue = "gm";
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Logger.WarningS("midi",
|
|
"Failed to initialize fluidsynth due to exception, disabling MIDI support:\n{0}", e);
|
|
_failedInitialize = true;
|
|
return;
|
|
}
|
|
|
|
_midiThread = new Thread(ThreadUpdate);
|
|
_midiThread.Start();
|
|
|
|
_broadPhaseSystem = EntitySystem.Get<SharedPhysicsSystem>();
|
|
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
|
|
};
|
|
_sawmill.Log(rLevel, message);
|
|
}
|
|
|
|
public IMidiRenderer? GetNewRenderer()
|
|
{
|
|
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);
|
|
|
|
foreach (var file in _resourceManager.ContentFindFiles(("/Audio/MidiCustom/")))
|
|
{
|
|
if (file.Extension != "sf2" && file.Extension != "dls") continue;
|
|
renderer.LoadSoundfont(file.ToString());
|
|
}
|
|
|
|
// Since the last loaded soundfont takes priority, we load the fallback soundfont before the soundfont.
|
|
renderer.LoadSoundfont(FallbackSoundfont);
|
|
|
|
if (OperatingSystem.IsLinux())
|
|
{
|
|
foreach (var filepath in LinuxSoundfonts)
|
|
{
|
|
if (!File.Exists(filepath) || !SoundFont.IsSoundFont(filepath)) continue;
|
|
|
|
try
|
|
{
|
|
renderer.LoadSoundfont(filepath, true);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
else if (OperatingSystem.IsMacOS())
|
|
{
|
|
if (File.Exists(OsxSoundfont) && SoundFont.IsSoundFont(OsxSoundfont))
|
|
renderer.LoadSoundfont(OsxSoundfont, true);
|
|
}
|
|
else if (OperatingSystem.IsWindows())
|
|
{
|
|
if (File.Exists(WindowsSoundfont) && SoundFont.IsSoundFont(WindowsSoundfont))
|
|
renderer.LoadSoundfont(WindowsSoundfont, true);
|
|
}
|
|
|
|
renderer.Source.SetVolume(Volume);
|
|
|
|
lock (_renderers)
|
|
{
|
|
_renderers.Add(renderer);
|
|
}
|
|
return renderer;
|
|
}
|
|
finally
|
|
{
|
|
handle.Free();
|
|
}
|
|
}
|
|
|
|
public void FrameUpdate(float frameTime)
|
|
{
|
|
if (!FluidsynthInitialized)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Update positions of streams every frame.
|
|
foreach (var renderer in _renderers)
|
|
{
|
|
if (renderer.Disposed)
|
|
continue;
|
|
|
|
if(_volumeDirty)
|
|
renderer.Source.SetVolume(Volume);
|
|
|
|
if (!renderer.Mono)
|
|
{
|
|
renderer.Source.SetGlobal();
|
|
continue;
|
|
}
|
|
|
|
MapCoordinates? mapPos = null;
|
|
var trackingEntity = renderer.TrackingEntity != null && !_entityManager.Deleted(renderer.TrackingEntity);
|
|
if (trackingEntity)
|
|
{
|
|
renderer.TrackingCoordinates = _entityManager.GetComponent<TransformComponent>(renderer.TrackingEntity!.Value).Coordinates;
|
|
}
|
|
|
|
if (renderer.TrackingCoordinates != null)
|
|
{
|
|
mapPos = renderer.TrackingCoordinates.Value.ToMap(_entityManager);
|
|
}
|
|
|
|
if (mapPos != null && mapPos.Value.MapId == _eyeManager.CurrentMap)
|
|
{
|
|
var pos = mapPos.Value;
|
|
|
|
var sourceRelative = _eyeManager.CurrentEye.Position.Position - pos.Position;
|
|
var occlusion = 0f;
|
|
if (sourceRelative.Length > 0)
|
|
{
|
|
occlusion = _broadPhaseSystem.IntersectRayPenetration(
|
|
pos.MapId,
|
|
new CollisionRay(
|
|
pos.Position,
|
|
sourceRelative.Normalized,
|
|
OcclusionCollisionMask),
|
|
sourceRelative.Length,
|
|
renderer.TrackingEntity);
|
|
}
|
|
|
|
renderer.Source.SetOcclusion(occlusion);
|
|
|
|
if (!renderer.Source.SetPosition(pos.Position))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (trackingEntity)
|
|
{
|
|
renderer.Source.SetVelocity(renderer.TrackingEntity!.Value.GlobalLinearVelocity());
|
|
}
|
|
}
|
|
else
|
|
{
|
|
renderer.Source.SetOcclusion(float.MaxValue);
|
|
}
|
|
}
|
|
|
|
_volumeDirty = false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main method for the thread rendering the midi audio.
|
|
/// </summary>
|
|
private void ThreadUpdate()
|
|
{
|
|
while (_alive)
|
|
{
|
|
lock (_renderers)
|
|
{
|
|
for (var i = 0; i < _renderers.Count; i++)
|
|
{
|
|
var renderer = _renderers[i];
|
|
if (!renderer.Disposed)
|
|
renderer.Render();
|
|
else
|
|
{
|
|
renderer.InternalDispose();
|
|
_renderers.Remove(renderer);
|
|
}
|
|
}
|
|
}
|
|
|
|
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>
|
|
/// This class is used to load soundfonts.
|
|
/// </summary>
|
|
private sealed class ResourceLoaderCallbacks : SoundFontLoaderCallbacks
|
|
{
|
|
private readonly Dictionary<int, Stream> _openStreams = new();
|
|
private int _nextStreamId = 1;
|
|
|
|
public override IntPtr Open(string filename)
|
|
{
|
|
if (string.IsNullOrEmpty(filename))
|
|
{
|
|
return IntPtr.Zero;
|
|
}
|
|
|
|
Stream? stream;
|
|
var resourceCache = IoCManager.Resolve<IResourceCache>();
|
|
var resourcePath = new ResourcePath(filename);
|
|
|
|
if (resourcePath.IsRooted && resourceCache.ContentFileExists(filename))
|
|
{
|
|
if (!resourceCache.TryContentFileRead(filename, out stream))
|
|
return IntPtr.Zero;
|
|
}
|
|
else if (File.Exists(filename))
|
|
{
|
|
stream = File.OpenRead(filename);
|
|
}
|
|
else
|
|
{
|
|
return IntPtr.Zero;
|
|
}
|
|
|
|
var id = _nextStreamId++;
|
|
|
|
_openStreams.Add(id, stream);
|
|
|
|
return (IntPtr) id;
|
|
}
|
|
|
|
public override unsafe int Read(IntPtr buf, long count, IntPtr sfHandle)
|
|
{
|
|
var length = (int) count;
|
|
var span = new Span<byte>(buf.ToPointer(), length);
|
|
var stream = _openStreams[(int) sfHandle];
|
|
|
|
// Fluidsynth's docs state that this method should leave the buffer unmodified if it fails. (returns -1)
|
|
try
|
|
{
|
|
// Fluidsynth does a LOT of tiny allocations (frankly, way too much).
|
|
if (count < 1024)
|
|
{
|
|
// ReSharper disable once SuggestVarOrType_Elsewhere
|
|
Span<byte> buffer = stackalloc byte[(int)count];
|
|
|
|
stream.ReadExact(buffer);
|
|
|
|
buffer.CopyTo(span);
|
|
}
|
|
else
|
|
{
|
|
var buffer = stream.ReadExact(length);
|
|
|
|
buffer.CopyTo(span);
|
|
}
|
|
}
|
|
catch (EndOfStreamException)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
public override int Seek(IntPtr sfHandle, int offset, SeekOrigin origin)
|
|
{
|
|
var stream = _openStreams[(int) sfHandle];
|
|
|
|
stream.Seek(offset, origin);
|
|
|
|
return 0;
|
|
}
|
|
|
|
public override int Tell(IntPtr sfHandle)
|
|
{
|
|
var stream = _openStreams[(int) sfHandle];
|
|
|
|
return (int) stream.Position;
|
|
}
|
|
|
|
public override int Close(IntPtr sfHandle)
|
|
{
|
|
if (!_openStreams.Remove((int) sfHandle, out var stream))
|
|
return -1;
|
|
|
|
stream.Dispose();
|
|
return 0;
|
|
|
|
}
|
|
}
|
|
}
|
|
}
|