151 lines
5.2 KiB
C#
151 lines
5.2 KiB
C#
using System;
|
|
using System.IO;
|
|
using Microsoft.Extensions.Logging;
|
|
using NAudio.Wave;
|
|
|
|
namespace HellionChat.Integrations;
|
|
|
|
// Plays the three bundled WAV notification sounds via NAudio WaveOutEvent.
|
|
// WaveOutEvent/WinMM is the correct backend for FFXIV on Wine: it works
|
|
// without Media Foundation (which Wine does not support for MP3/AAC).
|
|
//
|
|
// Playback volume comes from Configuration.CustomSoundVolume via the Play
|
|
// parameter, clamped to [0,1]. The 16 game sounds are unaffected — they go
|
|
// through UIGlobals.PlaySoundEffect, which the plugin cannot scale.
|
|
internal sealed class CustomAudioPlayer : IDisposable
|
|
{
|
|
// Sound bytes are read once at construction so each Play() wraps a fresh
|
|
// MemoryStream rather than re-reading the manifest stream (which becomes
|
|
// unreadable after the first read and would require Seek support).
|
|
private readonly byte[][] _soundData;
|
|
private readonly ILogger<CustomAudioPlayer> _logger;
|
|
|
|
private WaveOutEvent? _outputDevice;
|
|
private WaveFileReader? _reader;
|
|
private readonly object _lock = new();
|
|
|
|
public CustomAudioPlayer(ILogger<CustomAudioPlayer> logger)
|
|
{
|
|
_logger = logger;
|
|
_soundData = new byte[3][];
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var resourceName = $"HellionChat.Sounds.notification-{i + 1}.wav";
|
|
using var stream = typeof(CustomAudioPlayer).Assembly.GetManifestResourceStream(
|
|
resourceName
|
|
);
|
|
if (stream is null)
|
|
{
|
|
_logger.LogWarning(
|
|
"Embedded sound resource not found: {Resource}. "
|
|
+ "Custom sound {Index} will be silent.",
|
|
resourceName,
|
|
i + 1
|
|
);
|
|
_soundData[i] = Array.Empty<byte>();
|
|
continue;
|
|
}
|
|
|
|
using var ms = new MemoryStream();
|
|
stream.CopyTo(ms);
|
|
_soundData[i] = ms.ToArray();
|
|
}
|
|
}
|
|
|
|
// customIndex is 1, 2, or 3, matching the sound file suffix.
|
|
// Stops any currently playing sound before starting the new one.
|
|
// NAudio playback runs on its own thread; this method returns immediately.
|
|
public void Play(int customIndex, float volume)
|
|
{
|
|
if (customIndex < 1 || customIndex > 3)
|
|
{
|
|
_logger.LogWarning(
|
|
"CustomAudioPlayer.Play called with out-of-range index {Index}",
|
|
customIndex
|
|
);
|
|
return;
|
|
}
|
|
|
|
var data = _soundData[customIndex - 1];
|
|
if (data.Length == 0)
|
|
{
|
|
_logger.LogWarning(
|
|
"Sound data for index {Index} is empty; skipping playback",
|
|
customIndex
|
|
);
|
|
return;
|
|
}
|
|
|
|
lock (_lock)
|
|
{
|
|
try
|
|
{
|
|
StopCurrent();
|
|
|
|
var ms = new MemoryStream(data, writable: false);
|
|
_reader = new WaveFileReader(ms);
|
|
|
|
_outputDevice = new WaveOutEvent();
|
|
// Init opens the device and creates the WinMM handle. Volume
|
|
// must be set after Init, otherwise waveOutSetVolume fails with
|
|
// InvalidHandle.
|
|
_outputDevice.Init(_reader);
|
|
// AUDIO-1: volume comes from Configuration.CustomSoundVolume.
|
|
// Clamp here too — a hand-edited config could carry an
|
|
// out-of-range value, and WaveOutEvent.Volume rejects those.
|
|
_outputDevice.Volume = Math.Clamp(volume, 0f, 1f);
|
|
_outputDevice.Play();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(
|
|
ex,
|
|
"Failed to play custom notification sound {Index}",
|
|
customIndex
|
|
);
|
|
StopCurrent();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stops and tears down the active WaveOutEvent + WaveFileReader without
|
|
// throwing. Called on Play (to interrupt previous sound) and from Dispose.
|
|
// Guards Stop() with a PlaybackState check because waveOutReset blocks even
|
|
// when playback already finished; under Wine this can stall the WinMM
|
|
// callback thread if many sounds arrive in quick succession.
|
|
private void StopCurrent()
|
|
{
|
|
try
|
|
{
|
|
if (_outputDevice?.PlaybackState == PlaybackState.Playing)
|
|
_outputDevice.Stop();
|
|
_outputDevice?.Dispose();
|
|
_outputDevice = null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Exception while stopping current WaveOutEvent");
|
|
}
|
|
|
|
try
|
|
{
|
|
_reader?.Dispose();
|
|
_reader = null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Exception while disposing WaveFileReader");
|
|
}
|
|
}
|
|
|
|
// At plugin unload the PendingMessageThread is already cancelled and the
|
|
// draw loop is gone, so _lock is uncontended here. Calling StopCurrent
|
|
// outside the lock avoids holding it across the blocking waveOutReset /
|
|
// WaveOutEvent.Dispose, which can freeze on Wine during unload.
|
|
public void Dispose()
|
|
{
|
|
StopCurrent();
|
|
}
|
|
}
|