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:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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>(),
|
||||||
|
|||||||
@@ -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.
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user