diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index 97467fd..4eca9ea 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -26,6 +26,11 @@ + + @@ -61,6 +66,16 @@ HellionChat.Branding.fox-banner.png + + + HellionChat.Sounds.notification-1.wav + + + HellionChat.Sounds.notification-2.wav + + + HellionChat.Sounds.notification-3.wav + HellionChat.Branding.fox-mini.txt diff --git a/HellionChat/Integrations/CustomAudioPlayer.cs b/HellionChat/Integrations/CustomAudioPlayer.cs new file mode 100644 index 0000000..320b5fd --- /dev/null +++ b/HellionChat/Integrations/CustomAudioPlayer.cs @@ -0,0 +1,146 @@ +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). +// +// Volume is fixed at 0.8. No per-user slider in this iteration so we can +// ship quickly and gather feedback before adding UX complexity. +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) + { + 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); + _outputDevice.Volume = 0.8f; + _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(); + } +} diff --git a/HellionChat/MessageManager.cs b/HellionChat/MessageManager.cs index b277f40..df91241 100644 --- a/HellionChat/MessageManager.cs +++ b/HellionChat/MessageManager.cs @@ -363,16 +363,26 @@ internal class MessageManager : IAsyncDisposable if (notificationSound is { } soundId) { - // ProcessMessage runs on the PendingMessageThread worker; the native - // UIGlobals.PlaySoundEffect must be marshalled onto the framework - // thread (reference_dalamud_framework_thread). - Plugin.Framework.RunOnFrameworkThread(() => + if (soundId is >= 1 and <= 16) { - unsafe + // ProcessMessage runs on the PendingMessageThread worker; the native + // UIGlobals.PlaySoundEffect must be marshalled onto the framework + // thread (reference_dalamud_framework_thread). + Plugin.Framework.RunOnFrameworkThread(() => { - UIGlobals.PlaySoundEffect(soundId); - } - }); + unsafe + { + UIGlobals.PlaySoundEffect(soundId); + } + }); + } + else if (soundId >= 17) + { + // Custom bundled sounds (ids 17-19) go through NAudio WaveOutEvent. + // NAudio manages its own playback thread, so no framework marshalling needed. + Plugin.CustomAudioPlayer.Play((int)soundId - 16); + } + // soundId == 0 (hand-edited config) falls through: plays nothing. } MessageProcessed?.Invoke(message); diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 67e2399..cbb1679 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -115,6 +115,7 @@ public sealed class Plugin : IAsyncDalamudPlugin internal Themes.ThemeRegistry ThemeRegistry { get; private set; } = null!; internal Ui.StatusBar StatusBar { get; private set; } = null!; internal Integrations.HonorificService HonorificService { get; private set; } = null!; + internal Integrations.CustomAudioPlayer CustomAudioPlayer { get; private set; } = null!; // Platform indirection over Dalamud.Utility.Util. Wired in Phase-1 ctor so // any service allocated in LoadAsync can read Plugin.PlatformUtil. @@ -286,6 +287,7 @@ public sealed class Plugin : IAsyncDalamudPlugin TypingIpc = _host.Services.GetRequiredService(); ExtraChat = _host.Services.GetRequiredService(); HonorificService = _host.Services.GetRequiredService(); + CustomAudioPlayer = _host.Services.GetRequiredService(); StatusBar = _host.Services.GetRequiredService(); MessageManager = _host.Services.GetRequiredService(); AutoTellTabsService = _host.Services.GetRequiredService(); diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 1347b75..62ed716 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -110,6 +110,9 @@ internal static class PluginHostFactory services.AddSingleton(sp => new Integrations.FailedTellNotifier( sp.GetRequiredService>() )); + services.AddSingleton(sp => new Integrations.CustomAudioPlayer( + sp.GetRequiredService>() + )); services.AddSingleton(sp => new MessageManager( sp.GetRequiredService(), diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index 96d38cf..2a5f0a2 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -463,6 +463,7 @@ internal class HellionStrings internal static string Tabs_NotificationSound_Description => Get(nameof(Tabs_NotificationSound_Description)); internal static string Tabs_NotificationSound_Option => Get(nameof(Tabs_NotificationSound_Option)); internal static string Tabs_NotificationSound_Preview => Get(nameof(Tabs_NotificationSound_Preview)); + internal static string Tabs_NotificationSound_CustomOption => Get(nameof(Tabs_NotificationSound_CustomOption)); // Scroll-to-bottom and item/flag linking internal static string ChatLog_ScrollToBottom_Tooltip => Get(nameof(ChatLog_ScrollToBottom_Tooltip)); diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index acadbc7..79a6d45 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1076,6 +1076,9 @@ Preview the selected sound + + Hellion sound + diff --git a/HellionChat/Resources/Sounds/notification-1.wav b/HellionChat/Resources/Sounds/notification-1.wav new file mode 100644 index 0000000..7e1c913 Binary files /dev/null and b/HellionChat/Resources/Sounds/notification-1.wav differ diff --git a/HellionChat/Resources/Sounds/notification-2.wav b/HellionChat/Resources/Sounds/notification-2.wav new file mode 100644 index 0000000..4cb6e4c Binary files /dev/null and b/HellionChat/Resources/Sounds/notification-2.wav differ diff --git a/HellionChat/Resources/Sounds/notification-3.wav b/HellionChat/Resources/Sounds/notification-3.wav new file mode 100644 index 0000000..a60fbb5 Binary files /dev/null and b/HellionChat/Resources/Sounds/notification-3.wav differ diff --git a/HellionChat/Ui/SettingsTabs/Tabs.cs b/HellionChat/Ui/SettingsTabs/Tabs.cs index 8ad1e91..2d97523 100755 --- a/HellionChat/Ui/SettingsTabs/Tabs.cs +++ b/HellionChat/Ui/SettingsTabs/Tabs.cs @@ -174,8 +174,11 @@ internal sealed class Tabs : ISettingsTab if (tab.EnableNotificationSound) { using var indent = ImRaii.PushIndent(10.0f); + // Build a readable preview label for the currently selected sound. var soundPreview = - $"{HellionStrings.Tabs_NotificationSound_Option} {tab.NotificationSoundId}"; + tab.NotificationSoundId <= 16 + ? $"{HellionStrings.Tabs_NotificationSound_Option} {tab.NotificationSoundId}" + : $"{HellionStrings.Tabs_NotificationSound_CustomOption} {tab.NotificationSoundId - 16}"; using (var combo = ImRaii.Combo($"##notif-sound-{i}", soundPreview)) { if (combo.Success) @@ -190,6 +193,21 @@ internal sealed class Tabs : ISettingsTab ) tab.NotificationSoundId = s; } + + ImGui.Separator(); + + // Bundled custom sounds (ids 17-19). + for (uint n = 1; n <= 3; n++) + { + var customId = 16 + n; + if ( + ImGui.Selectable( + $"{HellionStrings.Tabs_NotificationSound_CustomOption} {n}", + tab.NotificationSoundId == customId + ) + ) + tab.NotificationSoundId = customId; + } } } @@ -204,13 +222,20 @@ internal sealed class Tabs : ISettingsTab ) { var previewId = tab.NotificationSoundId; - Plugin.Framework.RunOnFrameworkThread(() => + if (previewId <= 16) { - unsafe + Plugin.Framework.RunOnFrameworkThread(() => { - UIGlobals.PlaySoundEffect(previewId); - } - }); + unsafe + { + UIGlobals.PlaySoundEffect(previewId); + } + }); + } + else + { + Plugin.CustomAudioPlayer.Play((int)previewId - 16); + } } } ImGui.Checkbox(Language.Options_Tabs_PopOut, ref tab.PopOut); diff --git a/HellionChat/packages.lock.json b/HellionChat/packages.lock.json index b50ef46..acf75ef 100644 --- a/HellionChat/packages.lock.json +++ b/HellionChat/packages.lock.json @@ -102,6 +102,15 @@ "resolved": "4.4.0", "contentHash": "QX3bsK9oFeUXk8tFsc9NkI6NnCr8Ar/ex027p+ZZ/jdLCdX2RlryDtxUqZW5j45NVwn4E4Z4hzupsoMQd6Yxtg==" }, + "NAudio.WinMM": { + "type": "Direct", + "requested": "[2.2.1, )", + "resolved": "2.2.1", + "contentHash": "xFHRFwH4x6aq3IxRbewvO33ugJRvZFEOfO62i7uQJRUNW2cnu6BeBTHUS0JD5KBucZbHZaYqxQG8dwZ47ezQuQ==", + "dependencies": { + "NAudio.Core": "2.2.1" + } + }, "Pidgin": { "type": "Direct", "requested": "[3.5.1, 4.0.0)", @@ -366,6 +375,11 @@ "resolved": "17.11.4", "contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA==" }, + "NAudio.Core": { + "type": "Transitive", + "resolved": "2.2.1", + "contentHash": "GgkdP6K/7FqXFo7uHvoqGZTJvW4z8g2IffhOO4JHaLzKCdDOUEzVKtveoZkCuUX8eV2HAINqi7VFqlFndrnz/g==" + }, "SQLitePCLRaw.bundle_e_sqlite3": { "type": "Transitive", "resolved": "2.1.11", diff --git a/docs/CREDITS.md b/docs/CREDITS.md new file mode 100644 index 0000000..5fa9f4e --- /dev/null +++ b/docs/CREDITS.md @@ -0,0 +1,20 @@ +# Third-Party Asset Credits + +## Bundled Notification Sounds + +- **notification-1.wav, notification-2.wav, notification-3.wav** + Creator: Universfield + Source: [Pixabay](https://pixabay.com/users/universfield-28281460/) + License: Pixabay Content License (royalty-free, commercial use permitted) + +## Branding + +- **Fox banner image** (`fox-banner.png`) + Creator: InklyTattooDesigns (Etsy) + License: Royalty-free for commercial and logo use + +## Libraries + +- **NAudio** - Copyright (c) Mark Heath + License: MIT + Source: