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 _logger; private WaveOutEvent? _outputDevice; private WaveFileReader? _reader; private readonly object _lock = new(); public CustomAudioPlayer(ILogger 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(); 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(); } }