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: