feat(ui): add bundled custom notification sounds

Adds three embedded WAV files as additional notification sound choices
(ids 17-19) alongside the existing 16 game sounds. Playback via NAudio
WaveOutEvent/WinMM, which works correctly on Wine/Linux.
This commit is contained in:
2026-05-21 19:49:30 +02:00
parent 969d5e6aa6
commit cc1c05add0
13 changed files with 253 additions and 14 deletions
+15
View File
@@ -26,6 +26,11 @@
<!-- SQLitePCLRaw override for CVE-2025-6965, CVE-2025-7709 (SQLite >= 3.50.3) --> <!-- SQLitePCLRaw override for CVE-2025-6965, CVE-2025-7709 (SQLite >= 3.50.3) -->
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" /> <PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
<PackageReference Include="morelinq" Version="4.4.0" /> <PackageReference Include="morelinq" Version="4.4.0" />
<!-- NAudio.WinMM 2.2.1 MIT - WaveOutEvent/WinMM path is Wine-safe (WaveOut works under Wine,
Media-Foundation-based codecs do not). Using the sub-package avoids pulling in
NAudio.WinForms (which requires WindowsDesktop and does not build on Linux hosts).
WaveOutEvent and WaveFileReader both live in NAudio.WinMM + NAudio.Core. -->
<PackageReference Include="NAudio.WinMM" Version="2.2.1" />
<PackageReference Include="Pidgin" Version="[3.5.1, 4.0.0)" /> <PackageReference Include="Pidgin" Version="[3.5.1, 4.0.0)" />
<PackageReference Include="SixLabors.ImageSharp" Version="[3.1.12, 4.0.0)" /> <PackageReference Include="SixLabors.ImageSharp" Version="[3.1.12, 4.0.0)" />
</ItemGroup> </ItemGroup>
@@ -61,6 +66,16 @@
<EmbeddedResource Include="Resources\Branding\fox-banner.png"> <EmbeddedResource Include="Resources\Branding\fox-banner.png">
<LogicalName>HellionChat.Branding.fox-banner.png</LogicalName> <LogicalName>HellionChat.Branding.fox-banner.png</LogicalName>
</EmbeddedResource> </EmbeddedResource>
<!-- Bundled custom notification sounds, Mono 44.1 kHz 16-bit PCM WAV (Wine-safe) -->
<EmbeddedResource Include="Resources\Sounds\notification-1.wav">
<LogicalName>HellionChat.Sounds.notification-1.wav</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Resources\Sounds\notification-2.wav">
<LogicalName>HellionChat.Sounds.notification-2.wav</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Resources\Sounds\notification-3.wav">
<LogicalName>HellionChat.Sounds.notification-3.wav</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Resources\Branding\fox-mini.txt"> <EmbeddedResource Include="Resources\Branding\fox-mini.txt">
<LogicalName>HellionChat.Branding.fox-mini.txt</LogicalName> <LogicalName>HellionChat.Branding.fox-mini.txt</LogicalName>
</EmbeddedResource> </EmbeddedResource>
@@ -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<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)
{
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();
}
}
+18 -8
View File
@@ -363,16 +363,26 @@ internal class MessageManager : IAsyncDisposable
if (notificationSound is { } soundId) if (notificationSound is { } soundId)
{ {
// ProcessMessage runs on the PendingMessageThread worker; the native if (soundId is >= 1 and <= 16)
// UIGlobals.PlaySoundEffect must be marshalled onto the framework
// thread (reference_dalamud_framework_thread).
Plugin.Framework.RunOnFrameworkThread(() =>
{ {
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); MessageProcessed?.Invoke(message);
+2
View File
@@ -115,6 +115,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
internal Themes.ThemeRegistry ThemeRegistry { get; private set; } = null!; internal Themes.ThemeRegistry ThemeRegistry { get; private set; } = null!;
internal Ui.StatusBar StatusBar { get; private set; } = null!; internal Ui.StatusBar StatusBar { get; private set; } = null!;
internal Integrations.HonorificService HonorificService { 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 // Platform indirection over Dalamud.Utility.Util. Wired in Phase-1 ctor so
// any service allocated in LoadAsync can read Plugin.PlatformUtil. // any service allocated in LoadAsync can read Plugin.PlatformUtil.
@@ -286,6 +287,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
TypingIpc = _host.Services.GetRequiredService<TypingIpc>(); TypingIpc = _host.Services.GetRequiredService<TypingIpc>();
ExtraChat = _host.Services.GetRequiredService<ExtraChat>(); ExtraChat = _host.Services.GetRequiredService<ExtraChat>();
HonorificService = _host.Services.GetRequiredService<Integrations.HonorificService>(); HonorificService = _host.Services.GetRequiredService<Integrations.HonorificService>();
CustomAudioPlayer = _host.Services.GetRequiredService<Integrations.CustomAudioPlayer>();
StatusBar = _host.Services.GetRequiredService<Ui.StatusBar>(); StatusBar = _host.Services.GetRequiredService<Ui.StatusBar>();
MessageManager = _host.Services.GetRequiredService<MessageManager>(); MessageManager = _host.Services.GetRequiredService<MessageManager>();
AutoTellTabsService = _host.Services.GetRequiredService<AutoTellTabsService>(); AutoTellTabsService = _host.Services.GetRequiredService<AutoTellTabsService>();
+3
View File
@@ -110,6 +110,9 @@ internal static class PluginHostFactory
services.AddSingleton(sp => new Integrations.FailedTellNotifier( services.AddSingleton(sp => new Integrations.FailedTellNotifier(
sp.GetRequiredService<ILogger<Integrations.FailedTellNotifier>>() sp.GetRequiredService<ILogger<Integrations.FailedTellNotifier>>()
)); ));
services.AddSingleton(sp => new Integrations.CustomAudioPlayer(
sp.GetRequiredService<ILogger<Integrations.CustomAudioPlayer>>()
));
services.AddSingleton(sp => new MessageManager( services.AddSingleton(sp => new MessageManager(
sp.GetRequiredService<Plugin>(), sp.GetRequiredService<Plugin>(),
+1
View File
@@ -463,6 +463,7 @@ internal class HellionStrings
internal static string Tabs_NotificationSound_Description => Get(nameof(Tabs_NotificationSound_Description)); 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_Option => Get(nameof(Tabs_NotificationSound_Option));
internal static string Tabs_NotificationSound_Preview => Get(nameof(Tabs_NotificationSound_Preview)); 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 // Scroll-to-bottom and item/flag linking
internal static string ChatLog_ScrollToBottom_Tooltip => Get(nameof(ChatLog_ScrollToBottom_Tooltip)); internal static string ChatLog_ScrollToBottom_Tooltip => Get(nameof(ChatLog_ScrollToBottom_Tooltip));
@@ -1076,6 +1076,9 @@
<data name="Tabs_NotificationSound_Preview" xml:space="preserve"> <data name="Tabs_NotificationSound_Preview" xml:space="preserve">
<value>Preview the selected sound</value> <value>Preview the selected sound</value>
</data> </data>
<data name="Tabs_NotificationSound_CustomOption" xml:space="preserve">
<value>Hellion sound</value>
</data>
<!-- Scroll-to-bottom and item/flag linking --> <!-- Scroll-to-bottom and item/flag linking -->
<data name="ChatLog_ScrollToBottom_Tooltip" xml:space="preserve"> <data name="ChatLog_ScrollToBottom_Tooltip" xml:space="preserve">
Binary file not shown.
Binary file not shown.
Binary file not shown.
+31 -6
View File
@@ -174,8 +174,11 @@ internal sealed class Tabs : ISettingsTab
if (tab.EnableNotificationSound) if (tab.EnableNotificationSound)
{ {
using var indent = ImRaii.PushIndent(10.0f); using var indent = ImRaii.PushIndent(10.0f);
// Build a readable preview label for the currently selected sound.
var soundPreview = 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)) using (var combo = ImRaii.Combo($"##notif-sound-{i}", soundPreview))
{ {
if (combo.Success) if (combo.Success)
@@ -190,6 +193,21 @@ internal sealed class Tabs : ISettingsTab
) )
tab.NotificationSoundId = s; 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; 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); ImGui.Checkbox(Language.Options_Tabs_PopOut, ref tab.PopOut);
+14
View File
@@ -102,6 +102,15 @@
"resolved": "4.4.0", "resolved": "4.4.0",
"contentHash": "QX3bsK9oFeUXk8tFsc9NkI6NnCr8Ar/ex027p+ZZ/jdLCdX2RlryDtxUqZW5j45NVwn4E4Z4hzupsoMQd6Yxtg==" "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": { "Pidgin": {
"type": "Direct", "type": "Direct",
"requested": "[3.5.1, 4.0.0)", "requested": "[3.5.1, 4.0.0)",
@@ -366,6 +375,11 @@
"resolved": "17.11.4", "resolved": "17.11.4",
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA==" "contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
}, },
"NAudio.Core": {
"type": "Transitive",
"resolved": "2.2.1",
"contentHash": "GgkdP6K/7FqXFo7uHvoqGZTJvW4z8g2IffhOO4JHaLzKCdDOUEzVKtveoZkCuUX8eV2HAINqi7VFqlFndrnz/g=="
},
"SQLitePCLRaw.bundle_e_sqlite3": { "SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive", "type": "Transitive",
"resolved": "2.1.11", "resolved": "2.1.11",
+20
View File
@@ -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: <https://github.com/naudio/NAudio>