Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion MigrationInstaller/MigrationInstaller.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
Expand Down
40 changes: 40 additions & 0 deletions ShockOsc/Config/AvatarParameterAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using OpenShock.Desktop.ModuleBase.Models;

namespace OpenShock.ShockOSC.Config;

public sealed class AvatarParameterAction
{
public required string ParameterName { get; set; }
public ControlType Action { get; set; } = ControlType.Shock;
public Guid GroupId { get; set; }
public ParameterTriggerKind TriggerKind { get; set; } = ParameterTriggerKind.OnTrue;
public float Threshold { get; set; } = 0.5f;
public byte? OverrideIntensity { get; set; }
public ushort? OverrideDuration { get; set; }
public bool IsLiveControl { get; set; }
public float LiveControlMin { get; set; }
public float LiveControlMax { get; set; } = 1f;
}

public enum ParameterTriggerKind
{
/// <summary>
/// Triggers when a bool parameter becomes true
/// </summary>
OnTrue,

/// <summary>
/// Triggers when a bool parameter becomes false
/// </summary>
OnFalse,

/// <summary>
/// Triggers when a float parameter exceeds the threshold
/// </summary>
Threshold,

/// <summary>
/// Triggers on any value change (non-default)
/// </summary>
OnChange
}
1 change: 1 addition & 0 deletions ShockOsc/Config/BehaviourConf.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ public sealed class BehaviourConf : SharedBehaviourConfig
public uint HoldTime { get; set; } = 250;
public bool DisableWhileAfk { get; set; } = true;
public bool ForceUnmute { get; set; }
public uint CheckLoopIntervalMs { get; set; } = 50;
}
6 changes: 6 additions & 0 deletions ShockOsc/Config/ShockOscConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ public sealed class ShockOscConfig
public ChatboxConf Chatbox { get; set; } = new();
public IDictionary<Guid, Group> Groups { get; set; } = new Dictionary<Guid, Group>();

/// <summary>
/// Custom parameter-to-action mappings, keyed by avatar ID
/// </summary>
public IDictionary<string, IList<AvatarParameterAction>> AvatarParameterActions { get; set; } =
new Dictionary<string, IList<AvatarParameterAction>>();

public T GetGroupOrGlobal<T>(ProgramGroup group, Func<SharedBehaviourConfig, T> selector,
Func<Group, bool> groupOverrideSelector)
{
Expand Down
8 changes: 6 additions & 2 deletions ShockOsc/Services/OscHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,13 @@ public async Task SendParams()

foreach (var shocker in _shockOscData.ProgramGroups.Values)
{
var cooldownTime = _moduleConfig.Config.Behaviour.CooldownTime;
if (shocker.ConfigGroup is { OverrideCooldownTime: true })
cooldownTime = shocker.ConfigGroup.CooldownTime;

var isActive = shocker.LastExecuted.AddMilliseconds(shocker.LastDuration) > DateTime.UtcNow;
var isActiveOrOnCooldown =
shocker.LastExecuted.AddMilliseconds(_moduleConfig.Config.Behaviour.CooldownTime)
shocker.LastExecuted.AddMilliseconds(cooldownTime)
.AddMilliseconds(shocker.LastDuration) > DateTime.UtcNow;
if (!isActiveOrOnCooldown && shocker.LastIntensity > 0)
shocker.LastIntensity = 0;
Expand All @@ -100,7 +104,7 @@ public async Task SendParams()
shocker.LastExecuted.AddMilliseconds(
shocker.LastDuration))
.TotalMilliseconds /
_moduleConfig.Config.Behaviour.CooldownTime);
cooldownTime);

await shocker.ParamActive.SetValue(isActive);
await shocker.ParamCooldown.SetValue(onCoolDown);
Expand Down
161 changes: 130 additions & 31 deletions ShockOsc/Services/ShockOsc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,13 @@ public sealed class ShockOsc
private readonly OscHandler _oscHandler;
private readonly ChatboxService _chatboxService;

private bool _oscServerActive;
private CancellationTokenSource _loopCts = new();
private Task[] _loopTasks = [];
private bool _isAfk;
public bool IsGameConnected { get; private set; }
public bool IsConnectedViaOscQuery { get; private set; }
public event Action? OnGameConnectionChanged;
public event Action<AvatarParameterAction>? OnAvatarActionTriggered;
public string AvatarId = string.Empty;
private readonly Random Random = new();

Expand Down Expand Up @@ -61,6 +66,11 @@ public sealed class ShockOsc
public readonly Dictionary<string, object?> ShockOscParams = new();
public readonly Dictionary<string, object?> AllAvatarParams = new();

/// <summary>
/// Tracks previous values of avatar parameters for custom action trigger detection
/// </summary>
private readonly Dictionary<string, object?> _previousParamValues = new();

public IObservable<bool> OnParamsChangeObservable => _onParamsChange;
private readonly Subject<bool> _onParamsChange = new();

Expand Down Expand Up @@ -113,9 +123,16 @@ private void SetupGroups()

private async Task SetupVrcClient((OscQueryServer, IPEndPoint)? client)
{
// stop tasks
_oscServerActive = false;
await Task.Delay(1000); // wait for tasks to stop TODO: REWORK THIS
// Stop existing loops
await _loopCts.CancelAsync();
try { await Task.WhenAll(_loopTasks); }
catch (OperationCanceledException) { }
_loopCts.Dispose();
_loopCts = new CancellationTokenSource();

IsGameConnected = false;
IsConnectedViaOscQuery = false;
OnGameConnectionChanged?.Invoke();

if (client != null)
{
Expand All @@ -133,10 +150,17 @@ private async Task SetupVrcClient((OscQueryServer, IPEndPoint)? client)
_logger.LogInformation("Connecting UDP Clients...");

// Start tasks
_oscServerActive = true;
OsTask.Run(ReceiverLoopAsync);
OsTask.Run(SenderLoopAsync);
OsTask.Run(CheckLoop);
var ct = _loopCts.Token;
_loopTasks =
[
Task.Run(() => ReceiverLoopAsync(ct), ct),
Task.Run(() => SenderLoopAsync(ct), ct),
Task.Run(() => CheckLoop(ct), ct)
];

IsGameConnected = true;
IsConnectedViaOscQuery = client != null;
OnGameConnectionChanged?.Invoke();

_logger.LogInformation("Ready");
OsTask.Run(_underscoreConfig.SendUpdateForAll);
Expand All @@ -159,6 +183,7 @@ private Task OnAvatarChange(OscQueryServer.ParameterUpdateArgs parameterUpdateAr

ShockOscParams.Clear();
AllAvatarParams.Clear();
_previousParamValues.Clear();

foreach (var param in parameters.Keys)
{
Expand Down Expand Up @@ -201,46 +226,120 @@ private Task OnAvatarChange(OscQueryServer.ParameterUpdateArgs parameterUpdateAr
return Task.CompletedTask;
}

private async Task ReceiverLoopAsync()
private void CheckCustomParameterAction(string paramName, object? oldValue, object? newValue)
{
while (_oscServerActive)
if (string.IsNullOrEmpty(AvatarId)) return;
if (!_moduleConfig.Config.AvatarParameterActions.TryGetValue(AvatarId, out var actions)) return;

foreach (var action in actions)
{
try
if (action.ParameterName != paramName) continue;

if (!_dataLayer.ProgramGroups.TryGetValue(action.GroupId, out var programGroup))
{
await ReceiveLogic();
_logger.LogWarning("Custom parameter action references unknown group {GroupId}", action.GroupId);
continue;
}
catch (Exception e)

if (action.IsLiveControl)
{
_logger.LogError(e, "Error in receiver loop");
var rawValue = newValue switch
{
float f => f,
int i => i,
true => action.LiveControlMax,
_ => action.LiveControlMin
};

var range = action.LiveControlMax - action.LiveControlMin;
var liveIntensity = range == 0f
? 0f
: MathUtils.Saturate((rawValue - action.LiveControlMin) / range);

var scaledIntensity = Convert.ToByte(liveIntensity * 100f);
if (action.OverrideIntensity.HasValue && scaledIntensity > 0)
scaledIntensity = GetScaledIntensity(programGroup, scaledIntensity);

programGroup.ConcurrentIntensity = scaledIntensity;
programGroup.ConcurrentType = scaledIntensity > 0 ? action.Action : ControlType.Stop;

if (scaledIntensity > 0)
OnAvatarActionTriggered?.Invoke(action);

continue;
}

var shouldTrigger = action.TriggerKind switch
{
ParameterTriggerKind.OnTrue => newValue is true && oldValue is not true,
ParameterTriggerKind.OnFalse => newValue is false && oldValue is not false,
ParameterTriggerKind.Threshold => newValue is float f && f >= action.Threshold &&
(oldValue is not float oldF || oldF < action.Threshold),
ParameterTriggerKind.OnChange => !Equals(newValue, oldValue) && newValue is not null &&
newValue is not false && newValue is not 0 && newValue is not 0f,
_ => false
};

if (!shouldTrigger) continue;

if (!CheckAndSetAllPreconditions(programGroup).IsT0)
{
_logger.LogDebug("Custom parameter action skipped due to preconditions for group {Group}",
programGroup.Name);
continue;
}

var intensity = action.OverrideIntensity ?? GetIntensity(programGroup);
var duration = action.OverrideDuration ?? GetDuration(programGroup);

_logger.LogInformation(
"Custom parameter action triggered: {Param} -> {Action} on group {Group} (intensity: {Intensity}, duration: {Duration}ms)",
paramName, action.Action, programGroup.Name, intensity, duration);

OnAvatarActionTriggered?.Invoke(action);
OsTask.Run(() => SendCommand(programGroup, duration, intensity, action.Action));
}
// ReSharper disable once FunctionNeverReturns
}

private async Task ReceiveLogic()
private async Task ReceiverLoopAsync(CancellationToken ct)
{
OscMessage received;
try
{
received = await _oscClient.ReceiveGameMessage()!;
}
catch (Exception e)
while (!ct.IsCancellationRequested)
{
_logger.LogTrace(e, "Error receiving message");
return;
try
{
var receiveTask = _oscClient.ReceiveGameMessage();
if (receiveTask == null) break;
await receiveTask.WaitAsync(ct);
await ReceiveLogic(receiveTask.Result);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception e)
{
_logger.LogError(e, "Error in receiver loop");
}
}
}

private async Task ReceiveLogic(OscMessage received)
{
var addr = received.Address;

if (addr.StartsWith("/avatar/parameters/"))
{
// FIXME: less alloc pls
var fullName = addr[19..];
var oldValue = AllAvatarParams.GetValueOrDefault(fullName);
if (AllAvatarParams.ContainsKey(fullName))
AllAvatarParams[fullName] = received.Arguments[0];
else
AllAvatarParams.TryAdd(fullName, received.Arguments[0]);
_onParamsChange.OnNext(false);

// Check custom avatar parameter actions
CheckCustomParameterAction(fullName, oldValue, received.Arguments[0]);
}

switch (addr)
Expand Down Expand Up @@ -512,12 +611,12 @@ private ValueTask LogIgnoredAfk()
return _chatboxService.SendGenericMessage(_moduleConfig.Config.Chatbox.IgnoredAfk);
}

private async Task SenderLoopAsync()
private async Task SenderLoopAsync(CancellationToken ct)
{
while (_oscServerActive)
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(300));
while (await timer.WaitForNextTickAsync(ct))
{
await _oscHandler.SendParams();
await Task.Delay(300);
}
}

Expand Down Expand Up @@ -584,9 +683,11 @@ private async Task SendCommand(ProgramGroup programGroup, ushort duration, byte
await _chatboxService.SendLocalControlMessage(programGroup.Name, actualIntensity, actualDuration, type);
}

private async Task CheckLoop()
private async Task CheckLoop(CancellationToken ct)
{
while (_oscServerActive)
var intervalMs = Math.Max(10, _moduleConfig.Config.Behaviour.CheckLoopIntervalMs);
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(intervalMs));
while (await timer.WaitForNextTickAsync(ct))
{
try
{
Expand All @@ -596,8 +697,6 @@ private async Task CheckLoop()
{
_logger.LogError(e, "Error in check loop");
}

await Task.Delay(20);
}
}

Expand Down
6 changes: 6 additions & 0 deletions ShockOsc/ShockOSCModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ public sealed class ShockOSCModule : DesktopModuleBase, IAsyncDisposable
Icon = IconOneOf.FromSvg(Icons.Material.Filled.Group)
},
new()
{
Name = "Avatar Actions",
ComponentType = typeof(AvatarActionsTab),
Icon = IconOneOf.FromSvg(Icons.Material.Filled.Tune)
},
new()
{
Name = "Chatbox",
ComponentType = typeof(ChatboxTab),
Expand Down
Loading