diff --git a/QuakeSounds.csproj b/QuakeSounds.csproj
index fab313b..e7e4054 100644
--- a/QuakeSounds.csproj
+++ b/QuakeSounds.csproj
@@ -18,7 +18,6 @@
-
diff --git a/README.md b/README.md
index 934fcf9..5b4289d 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,12 @@
## Overview
-**SwiftlyS2-QuakeSounds** is a SwiftlyS2 plugin that plays Quake-style announcer audio for kill streaks, multi-kills, first blood, and special weapon events. It uses the shared Audio interface to play the sounds.
+**SwiftlyS2-QuakeSounds** is a SwiftlyS2 plugin that plays Quake-style announcer audio for kill streaks, multi-kills, first blood, and special weapon events.
+
+It supports two playback modes:
+
+- **Audio plugin mode**: Uses the Swiftly Audio plugin (MP3/WAV playback).
+- **Workshop Addons**: Relies on addon sound events (.vsndevts).
## Support
@@ -42,6 +47,11 @@ Need help or have questions? Join our Discord server:
📦
Download Latest Plugin Version →
Click Here
+
+
+ ⚙️
+ Download Latest Addons Manager →
+ Click Here
⚙️
@@ -55,7 +65,7 @@ Need help or have questions? Join our Discord server:
-
⚠️
- Requires Swiftly Audio Plugin in order to work →
+ Swiftly Audio Plugin is optional →
Click Here
@@ -91,9 +101,13 @@ The plugin uses SwiftlyS2's JSON config system.
On first run the config is created automatically. The resolved path is logged on startup.
+If you are using **Workshop Addons / sound events mode** (no Audio plugin), the default `Sounds` entries that reference `.mp3` files are only examples. You must replace them with the **actual sound event names** that exist in your `.vsndevts` and make sure `SoundEventFile` points to that `.vsndevts` so it gets precached.
+
### Key Configuration Options
- `Enabled`: Master on/off switch (default: true)
+- `UseAudioPlugin`: Use the Swiftly Audio plugin if available; otherwise fall back to addon sounds mode (default: true)
+- `SoundEventFile`: (Addon sounds mode) `.vsndevts` file to precache (default: `your_sound_events/quakesounds.vsndevts`)
- `PlayToAll`: Play sounds to all enabled players instead of only the killer (default: false)
- `Volume`: Base volume (0-1) used when no per-player override exists (default: 1.0)
- `CountSelfKills` / `CountTeamKills`: Whether to include suicides/team-kills in streaks (default: false / false)
@@ -109,6 +123,17 @@ On first run the config is created automatically. The resolved path is logged on
- `!volume <0-10>`: Set your personal QuakeSounds volume (falls back to `Volume` when unset).
- `!quake`: Toggle QuakeSounds on or off for yourself.
+### CVars
+
+- `qs_enabled <0|1>`: Enable/disable QuakeSounds globally at runtime.
+
+Examples:
+
+```text
+qs_enabled 0
+qs_enabled 1
+```
+
## Building
```bash
diff --git a/src/Configuration/QuakeSoundsConfig.cs b/src/Configuration/QuakeSoundsConfig.cs
index 2820ebb..affceb1 100644
--- a/src/Configuration/QuakeSoundsConfig.cs
+++ b/src/Configuration/QuakeSoundsConfig.cs
@@ -10,6 +10,8 @@ public sealed class QuakeSoundsConfig
{
public bool Enabled { get; set; } = true;
public bool Debug { get; set; } = false;
+ public bool UseAudioPlugin { get; set; } = true;
+ public string SoundEventFile { get; set; } = "your_sound_events/quakesounds.vsndevts";
public bool PlayToAll { get; set; } = false;
public float Volume { get; set; } = 1.0f;
public bool CountSelfKills { get; set; } = false;
diff --git a/src/EventHandlers/PlayerDeathHandler.cs b/src/EventHandlers/PlayerDeathHandler.cs
index 9626a3f..7b4cc8a 100644
--- a/src/EventHandlers/PlayerDeathHandler.cs
+++ b/src/EventHandlers/PlayerDeathHandler.cs
@@ -37,6 +37,11 @@ private bool ShouldSkipKillByConfig(IPlayer attacker, IPlayer victim)
[GameEventHandler(HookMode.Post)]
public HookResult OnPlayerDeath(EventPlayerDeath @event)
{
+ if (!IsPluginEnabled())
+ {
+ return HookResult.Continue;
+ }
+
var victim = @event.Accessor.GetPlayer("userid");
var attacker = @event.Accessor.GetPlayer("attacker");
@@ -137,8 +142,13 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event)
private bool TryPlay(IPlayer attacker, string soundKey)
{
+ if (!IsPluginEnabled())
+ {
+ return false;
+ }
+
// Try to play sound
- var played = _audioService?.TryPlay(
+ var played = _soundService?.TryPlay(
attacker,
soundKey,
_config,
diff --git a/src/EventHandlers/RoundStartHandler.cs b/src/EventHandlers/RoundStartHandler.cs
index eb4017c..126166b 100644
--- a/src/EventHandlers/RoundStartHandler.cs
+++ b/src/EventHandlers/RoundStartHandler.cs
@@ -9,6 +9,11 @@ public partial class QuakeSounds
{
private void TryPlayRoundSoundToAll(string soundKey)
{
+ if (!IsPluginEnabled())
+ {
+ return;
+ }
+
var anyPlayer = Core.PlayerManager.GetAllPlayers()
.FirstOrDefault(p => p is { IsValid: true } && !p.IsFakeClient);
@@ -27,7 +32,7 @@ private void TryPlayRoundSoundToAll(string soundKey)
_config.Messages.EnableCenterMessage = false;
_config.Messages.EnableChatMessage = false;
- _audioService?.TryPlay(
+ _soundService?.TryPlay(
anyPlayer,
soundKey,
_config,
diff --git a/src/QuakeSounds.cs b/src/QuakeSounds.cs
index d462ae5..69be5d2 100644
--- a/src/QuakeSounds.cs
+++ b/src/QuakeSounds.cs
@@ -1,9 +1,9 @@
-using AudioApi;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using QuakeSounds.Services;
using SwiftlyS2.Shared;
+using SwiftlyS2.Shared.Convars;
using SwiftlyS2.Shared.Misc;
using SwiftlyS2.Shared.Plugins;
using System;
@@ -11,12 +11,13 @@
namespace QuakeSounds;
-[PluginMetadata(Id = "QuakeSounds", Version = "1.0.2", Name = "QuakeSounds", Author = "aga", Description = "No description.")]
+[PluginMetadata(Id = "QuakeSounds", Version = "1.1.0", Name = "QuakeSounds", Author = "aga", Description = "No description.")]
public partial class QuakeSounds : BasePlugin {
- private AudioService? _audioService;
+ private ISoundService? _soundService;
private readonly GameStateService _gameStateService;
private readonly MessageService _messageService;
private QuakeSoundsConfig _config = new();
+ private IConVar? _qsEnabled;
private IDisposable? _configReloadRegistration;
private readonly List _registeredCommands = new();
@@ -32,21 +33,54 @@ public override void ConfigureSharedInterface(IInterfaceManager interfaceManager
public override void UseSharedInterface(IInterfaceManager interfaceManager)
{
- if (!interfaceManager.HasSharedInterface("audio"))
+ if (_config.UseAudioPlugin)
{
- Core.Logger.LogWarning("[QuakeSounds] Audio shared interface not found. Install/enable the 'Audio' plugin.");
- _audioService = null;
- return;
- }
+ if (!interfaceManager.HasSharedInterface("audio"))
+ {
+ Core.Logger.LogWarning("[QuakeSounds] Audio plugin not found, falling back to addon sounds mode.");
+ _soundService = new AddonSoundService(Core);
+ return;
+ }
- var audioApi = interfaceManager.GetSharedInterface("audio");
- _audioService = new AudioService(Core, audioApi);
+ try
+ {
+ var audioApiType = Type.GetType("AudioApi.IAudioApi, AudioApi");
+ if (audioApiType == null)
+ {
+ Core.Logger.LogWarning("[QuakeSounds] AudioApi assembly not found, falling back to addon sounds mode.");
+ _soundService = new AddonSoundService(Core);
+ return;
+ }
+
+ var getSharedInterfaceMethod = typeof(IInterfaceManager).GetMethod("GetSharedInterface");
+ var genericMethod = getSharedInterfaceMethod?.MakeGenericMethod(audioApiType);
+ var audioApi = genericMethod?.Invoke(interfaceManager, new object[] { "audio" });
+
+ if (audioApi == null)
+ {
+ Core.Logger.LogWarning("[QuakeSounds] Failed to get Audio API interface, falling back to addon sounds mode.");
+ _soundService = new AddonSoundService(Core);
+ return;
+ }
+
+ _soundService = AudioService.Create(Core, audioApi);
+ Core.Logger.LogInformation("[QuakeSounds] Using Audio API mode.");
+ }
+ catch (Exception ex)
+ {
+ Core.Logger.LogError(ex, "[QuakeSounds] Failed to initialize Audio API, falling back to addon sounds mode.");
+ _soundService = new AddonSoundService(Core);
+ }
+ }
+ else
+ {
+ _soundService = new AddonSoundService(Core);
+ Core.Logger.LogInformation("[QuakeSounds] Using addon sounds mode.");
+ }
}
public override void Load(bool hotReload)
{
- Core.Logger.LogInformation("[QuakeSounds] Load called - hotReload: {HotReload}, Instance: {InstanceHash}", hotReload, GetHashCode());
-
Core.Configuration
.InitializeJsonWithModel("config.jsonc", "Main")
.Configure(builder =>
@@ -56,36 +90,50 @@ public override void Load(bool hotReload)
ReloadConfig();
+ _qsEnabled = Core.ConVar.CreateOrFind(
+ "qs_enabled",
+ "Enable/disable QuakeSounds globally. 1 = enabled, 0 = disabled.",
+ _config.Enabled ? 1 : 0,
+ 0,
+ 1
+ );
+
+ if (!_config.UseAudioPlugin && !string.IsNullOrEmpty(_config.SoundEventFile))
+ {
+ Core.Event.OnPrecacheResource += Event_OnPrecacheResource;
+ }
+
_configReloadRegistration?.Dispose();
_configReloadRegistration = ChangeToken.OnChange(
() => Core.Configuration.Manager.GetReloadToken(),
() =>
{
ReloadConfig();
- _audioService?.ClearCache();
+ _soundService?.ClearCache();
}
);
_registeredCommands.Add("volume");
_registeredCommands.Add("quake");
- Core.Logger.LogInformation("[QuakeSounds] Load completed - Commands registered: {Count}", _registeredCommands.Count);
+ Core.Logger.LogInformation("[QuakeSounds] Plugin loaded successfully.");
}
public override void Unload()
{
- Core.Logger.LogInformation("[QuakeSounds] Unload called - Instance: {InstanceHash}", GetHashCode());
-
+ if (!_config.UseAudioPlugin && !string.IsNullOrEmpty(_config.SoundEventFile))
+ {
+ Core.Event.OnPrecacheResource -= Event_OnPrecacheResource;
+ }
+
foreach (var commandName in _registeredCommands)
{
try
{
- Core.Logger.LogInformation("[QuakeSounds] Unregistering command: {Command}", commandName);
-
Core.Command.UnregisterCommand(commandName);
}
catch (Exception ex)
{
- Core.Logger.LogWarning(ex, "[QuakeSounds] Failed to unregister command: {Command}", commandName);
+ Core.Logger.LogError(ex, "[QuakeSounds] Failed to unregister command: {Command}", commandName);
}
}
_registeredCommands.Clear();
@@ -93,9 +141,16 @@ public override void Unload()
_configReloadRegistration?.Dispose();
_configReloadRegistration = null;
- _audioService?.ClearCache();
+ _soundService?.ClearCache();
_gameStateService.ResetAll();
- Core.Logger.LogInformation("[QuakeSounds] Unload completed");
+ }
+
+ private void Event_OnPrecacheResource(SwiftlyS2.Shared.Events.IOnPrecacheResourceEvent @event)
+ {
+ if (!string.IsNullOrEmpty(_config.SoundEventFile))
+ {
+ @event.AddItem(_config.SoundEventFile);
+ }
}
private void ReloadConfig()
@@ -122,4 +177,9 @@ private void ReloadConfig()
Core.Logger.LogInformation("[QuakeSounds] Config reloaded. Enabled: {Enabled}. KillStreakAnnounces count: {Count}", _config.Enabled, _config.KillStreakAnnounces.Count);
}
+
+ private bool IsPluginEnabled()
+ {
+ return (_qsEnabled?.Value ?? (_config.Enabled ? 1 : 0)) != 0;
+ }
}
\ No newline at end of file
diff --git a/src/Services/AddonSoundService.cs b/src/Services/AddonSoundService.cs
new file mode 100644
index 0000000..a4621b0
--- /dev/null
+++ b/src/Services/AddonSoundService.cs
@@ -0,0 +1,91 @@
+using Microsoft.Extensions.Logging;
+using SwiftlyS2.Shared;
+using SwiftlyS2.Shared.Players;
+using SwiftlyS2.Shared.Sounds;
+using System;
+using System.Linq;
+
+namespace QuakeSounds.Services;
+
+public class AddonSoundService : ISoundService
+{
+ private readonly ISwiftlyCore _core;
+
+ public AddonSoundService(ISwiftlyCore core)
+ {
+ _core = core;
+ }
+
+ public void ClearCache()
+ {
+ }
+
+ public bool TryPlay(IPlayer attacker, string soundKey, QuakeSounds.QuakeSoundsConfig config, Func isPlayerEnabled, Func getPlayerVolume)
+ {
+ if (!config.Sounds.TryGetValue(soundKey, out var soundPath) || string.IsNullOrWhiteSpace(soundPath))
+ {
+ _core.Logger.LogWarning("[QuakeSounds] Sound key '{Key}' is not mapped in config.", soundKey);
+ return false;
+ }
+
+ if (config.PlayToAll)
+ {
+ var anyPlayed = false;
+ foreach (var player in _core.PlayerManager.GetAllPlayers().Where(p => p is { IsValid: true } && !p.IsFakeClient))
+ {
+ if (!isPlayerEnabled(player.SteamID))
+ {
+ continue;
+ }
+
+ var volume = config.Volume;
+ var overrideVolume = getPlayerVolume(player.SteamID);
+ if (overrideVolume >= 0) volume = overrideVolume;
+
+ PlaySoundToPlayer(player, soundPath, Math.Clamp(volume, 0f, 1f));
+ anyPlayed = true;
+ }
+ return anyPlayed;
+ }
+
+ if (!isPlayerEnabled(attacker.SteamID))
+ {
+ return false;
+ }
+
+ var attackerVolume = config.Volume;
+ var attackerOverrideVolume = getPlayerVolume(attacker.SteamID);
+ if (attackerOverrideVolume >= 0) attackerVolume = attackerOverrideVolume;
+
+ PlaySoundToPlayer(attacker, soundPath, Math.Clamp(attackerVolume, 0f, 1f));
+ return true;
+ }
+
+ private void PlaySoundToPlayer(IPlayer player, string soundPath, float volume)
+ {
+ if (player.Pawn == null)
+ {
+ _core.Logger.LogWarning("[QuakeSounds] Player pawn is null, cannot play sound.");
+ return;
+ }
+
+ var soundName = soundPath.Replace(".vsnd_c", "").Replace(".vsnd", "");
+
+ try
+ {
+ using var soundEvent = new SoundEvent
+ {
+ Name = soundName,
+ Volume = volume,
+ SourceEntityIndex = (int)player.Pawn.Index
+ };
+
+ soundEvent.Recipients.AddRecipient(player.PlayerID);
+ soundEvent.Emit();
+ }
+ catch (Exception ex)
+ {
+ _core.Logger.LogError(ex, "[QuakeSounds] Failed to emit sound '{Sound}'", soundName);
+ }
+ }
+}
diff --git a/src/Services/AudioService.cs b/src/Services/AudioService.cs
index 4fec877..0eb17bb 100644
--- a/src/Services/AudioService.cs
+++ b/src/Services/AudioService.cs
@@ -1,4 +1,3 @@
-using AudioApi;
using Microsoft.Extensions.Logging;
using SwiftlyS2.Shared;
using SwiftlyS2.Shared.Players;
@@ -10,19 +9,24 @@
namespace QuakeSounds.Services;
-public class AudioService
+public class AudioService : ISoundService
{
private readonly ISwiftlyCore _core;
- private readonly IAudioApi? _audioApi;
- private readonly ConcurrentDictionary _decodedSources = new();
+ private readonly dynamic _audioApi;
+ private readonly ConcurrentDictionary _decodedSources = new();
private int _channelCounter = 0;
- public AudioService(ISwiftlyCore core, IAudioApi? audioApi)
+ private AudioService(ISwiftlyCore core, dynamic audioApi)
{
_core = core;
_audioApi = audioApi;
}
+ public static ISoundService Create(ISwiftlyCore core, object audioApi)
+ {
+ return new AudioService(core, audioApi);
+ }
+
public void ClearCache()
{
_decodedSources.Clear();
@@ -63,7 +67,7 @@ float GetEffectiveVolume(ulong steamId)
return false;
}
- IAudioSource source;
+ object source;
try
{
source = _decodedSources.GetOrAdd(resolvedPath, path => _audioApi.DecodeFromFile(path));
@@ -75,8 +79,32 @@ float GetEffectiveVolume(ulong steamId)
}
var channelId = $"quakesounds.{System.Threading.Interlocked.Increment(ref _channelCounter)}";
- var channel = _audioApi.UseChannel(channelId);
- channel.SetSource(source);
+ dynamic channel = _audioApi.UseChannel(channelId);
+
+ try
+ {
+ // Avoid RuntimeBinderException by invoking SetSource with the actual runtime type.
+ // This also makes AudioApi type mismatches (multiple assemblies) easier to diagnose.
+ var setSource = ((object)channel).GetType().GetMethod("SetSource");
+ if (setSource == null)
+ {
+ _core.Logger.LogWarning("[QuakeSounds] Audio channel does not have SetSource method.");
+ return false;
+ }
+
+ setSource.Invoke(channel, new[] { source });
+ }
+ catch (Exception ex)
+ {
+ var channelType = ((object)channel).GetType();
+ var sourceType = source?.GetType();
+ _core.Logger.LogError(ex,
+ "[QuakeSounds] Failed to SetSource on audio channel. ChannelType={ChannelType} SourceType={SourceType} SourceAssembly={SourceAssembly}",
+ channelType.FullName,
+ sourceType?.FullName ?? "NULL",
+ sourceType?.Assembly.FullName ?? "NULL");
+ return false;
+ }
if (config.PlayToAll)
{
diff --git a/src/Services/ISoundService.cs b/src/Services/ISoundService.cs
new file mode 100644
index 0000000..c59d9bd
--- /dev/null
+++ b/src/Services/ISoundService.cs
@@ -0,0 +1,10 @@
+using SwiftlyS2.Shared.Players;
+using System;
+
+namespace QuakeSounds.Services;
+
+public interface ISoundService
+{
+ bool TryPlay(IPlayer attacker, string soundKey, QuakeSounds.QuakeSoundsConfig config, Func isPlayerEnabled, Func getPlayerVolume);
+ void ClearCache();
+}