|
@@ -0,0 +1,653 @@
|
|
|
|
|
+using System;
|
|
|
|
|
+using System.Collections.Generic;
|
|
|
|
|
+using System.Collections.ObjectModel;
|
|
|
|
|
+using System.IO;
|
|
|
|
|
+using System.Linq;
|
|
|
|
|
+using System.Runtime.InteropServices;
|
|
|
|
|
+using System.Text.Json;
|
|
|
|
|
+using System.Diagnostics;
|
|
|
|
|
+using System.Text.RegularExpressions;
|
|
|
|
|
+using System.Threading;
|
|
|
|
|
+using System.Threading.Tasks;
|
|
|
|
|
+using CommunityToolkit.Mvvm.ComponentModel;
|
|
|
|
|
+using CommunityToolkit.Mvvm.Input;
|
|
|
|
|
+using SlamCross.Models;
|
|
|
|
|
+using SlamCross.Services;
|
|
|
|
|
+
|
|
|
|
|
+namespace SlamCross.ViewModels;
|
|
|
|
|
+
|
|
|
|
|
+public partial class TrackItem : ObservableObject
|
|
|
|
|
+{
|
|
|
|
|
+ [ObservableProperty] private bool _isPlaying;
|
|
|
|
|
+ public string Name { get; set; } = "";
|
|
|
|
|
+ public string Hotkey { get; set; } = "";
|
|
|
|
|
+ public string Volume { get; set; } = "";
|
|
|
|
|
+ public string Trimmed { get; set; } = "";
|
|
|
|
|
+ public string Tags { get; set; } = "";
|
|
|
|
|
+ public int Index { get; set; }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+public partial class MainWindowViewModel : ViewModelBase
|
|
|
|
|
+{
|
|
|
|
|
+ private readonly SettingsService _settings = new();
|
|
|
|
|
+ private readonly IAudioService _audio = AudioServiceFactory.Create();
|
|
|
|
|
+
|
|
|
|
|
+ private SourceGame _game = null!;
|
|
|
|
|
+ private string? _steamAppsPath;
|
|
|
|
|
+ private CancellationTokenSource? _pollCts;
|
|
|
|
|
+
|
|
|
|
|
+ [ObservableProperty] private ObservableCollection<TrackItem> _tracks = new();
|
|
|
|
|
+ [ObservableProperty] private ObservableCollection<AudioDevice> _outputDevices = new();
|
|
|
|
|
+ [ObservableProperty] private ObservableCollection<AudioDevice> _inputDevices = new();
|
|
|
|
|
+
|
|
|
|
|
+ [ObservableProperty] private AudioDevice? _selectedVirtualInCable;
|
|
|
|
|
+ [ObservableProperty] private AudioDevice? _selectedVirtualOutCable;
|
|
|
|
|
+ [ObservableProperty] private AudioDevice? _selectedRegularMic;
|
|
|
|
|
+
|
|
|
|
|
+ [ObservableProperty] private string _statusText = "Status: Idle.";
|
|
|
|
|
+ [ObservableProperty] private bool _isRunning;
|
|
|
|
|
+ [ObservableProperty] private bool _interfaceEnabled = true;
|
|
|
|
|
+ [ObservableProperty] private double _progress;
|
|
|
|
|
+ [ObservableProperty] private double _progressMax = 1;
|
|
|
|
|
+ [ObservableProperty] private string _startButtonText = "Start";
|
|
|
|
|
+ [ObservableProperty] private string _playKeyButtonText = "Play key: (change)";
|
|
|
|
|
+ [ObservableProperty] private bool _isStreaming;
|
|
|
|
|
+ [ObservableProperty] private string _streamButtonText = "Stream to Mic";
|
|
|
|
|
+ [ObservableProperty] private string _streamStatusText = "";
|
|
|
|
|
+ [ObservableProperty] private int _globalVolume = 50;
|
|
|
|
|
+
|
|
|
|
|
+ public AppSettings Settings => _settings.Settings;
|
|
|
|
|
+
|
|
|
|
|
+ public MainWindowViewModel()
|
|
|
|
|
+ {
|
|
|
|
|
+ _settings.Load();
|
|
|
|
|
+ _globalVolume = Settings.GlobalVolume;
|
|
|
|
|
+ InitGame();
|
|
|
|
|
+ LoadAudioDevices();
|
|
|
|
|
+ RefreshTracks();
|
|
|
|
|
+ RefreshPlayKey();
|
|
|
|
|
+
|
|
|
|
|
+ if (Settings.StartEnabled)
|
|
|
|
|
+ _ = StartPollAsync();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ partial void OnGlobalVolumeChanged(int value)
|
|
|
|
|
+ {
|
|
|
|
|
+ Settings.GlobalVolume = value;
|
|
|
|
|
+ _settings.Save();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void InitGame()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sep = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '\\' : '/';
|
|
|
|
|
+
|
|
|
|
|
+ _game = new SourceGame
|
|
|
|
|
+ {
|
|
|
|
|
+ Name = "Counter-Strike 2",
|
|
|
|
|
+ Id = 730,
|
|
|
|
|
+ Directory = $"common{sep}Counter-Strike Global Offensive{sep}",
|
|
|
|
|
+ ToCfg = $"game{sep}csgo{sep}cfg{sep}",
|
|
|
|
|
+ LibraryName = $"game{sep}csgo{sep}",
|
|
|
|
|
+ ExeName = "cs2",
|
|
|
|
|
+ SampleRate = 22050,
|
|
|
|
|
+ VoiceFadeOut = false
|
|
|
|
|
+ };
|
|
|
|
|
+ _game.Blacklist.AddRange(new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ "attack","attack2","autobuy","back","buy","buyammo1","buyammo2","buymenu","callvote",
|
|
|
|
|
+ "cancelselect","cheer","compliment","coverme","drop","duck","enemydown","enemyspot",
|
|
|
|
|
+ "fallback","followme","forward","getout","go","holdpos","inposition","invnext","invprev",
|
|
|
|
|
+ "jump","lastinv","messagemode","messagemode2","moveleft","moveright","mute","negative",
|
|
|
|
|
+ "quit","radio1","radio2","radio3","rebuy","regroup","reload","reporting_in",
|
|
|
|
|
+ "reporting_in_team_only","roger","sectorclear","showscores","slot1","slot10","slot2",
|
|
|
|
|
+ "slot3","slot4","slot5","slot6","slot7","slot8","slot9","speed","sticktog","takepoint",
|
|
|
|
|
+ "takingfire","teammenu","thanks","toggleconsole","use","voicerecord",
|
|
|
|
|
+ "say","say_team","exec","echo","alias","bind","unbind","unbindall","kill","explode",
|
|
|
|
|
+ "disconnect","retry","status","map","volume","stop","play","record","screenshot",
|
|
|
|
|
+ "host_writeconfig","voice_loopback","con_enable","clear","help","find"
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void LoadAudioDevices()
|
|
|
|
|
+ {
|
|
|
|
|
+ OutputDevices.Clear();
|
|
|
|
|
+ InputDevices.Clear();
|
|
|
|
|
+
|
|
|
|
|
+ foreach (var d in _audio.GetOutputDevices())
|
|
|
|
|
+ OutputDevices.Add(d);
|
|
|
|
|
+ foreach (var d in _audio.GetInputDevices())
|
|
|
|
|
+ InputDevices.Add(d);
|
|
|
|
|
+
|
|
|
|
|
+ if (Settings.VirtualMicInDeviceIndex >= 0 && Settings.VirtualMicInDeviceIndex < OutputDevices.Count)
|
|
|
|
|
+ SelectedVirtualInCable = OutputDevices[Settings.VirtualMicInDeviceIndex];
|
|
|
|
|
+
|
|
|
|
|
+ if (Settings.VirtualMicOutDeviceIndex >= 0 && Settings.VirtualMicOutDeviceIndex < InputDevices.Count)
|
|
|
|
|
+ SelectedVirtualOutCable = InputDevices[Settings.VirtualMicOutDeviceIndex];
|
|
|
|
|
+
|
|
|
|
|
+ if (Settings.RegularMicDeviceIndex >= 0 && Settings.RegularMicDeviceIndex < InputDevices.Count)
|
|
|
|
|
+ SelectedRegularMic = InputDevices[Settings.RegularMicDeviceIndex];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ partial void OnSelectedVirtualInCableChanged(AudioDevice? value)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (value == null) return;
|
|
|
|
|
+ Settings.VirtualMicInDeviceIndex = value.Index;
|
|
|
|
|
+ Settings.VirtualMicInDeviceName = value.Name;
|
|
|
|
|
+ Settings.VirtualMicInPactlId = value.PactlId;
|
|
|
|
|
+ _settings.Save();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ partial void OnSelectedVirtualOutCableChanged(AudioDevice? value)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (value == null) return;
|
|
|
|
|
+ Settings.VirtualMicOutDeviceIndex = value.Index;
|
|
|
|
|
+ Settings.VirtualMicOutDeviceName = value.Name;
|
|
|
|
|
+ Settings.VirtualMicOutPactlId = value.PactlId;
|
|
|
|
|
+ _settings.Save();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ partial void OnSelectedRegularMicChanged(AudioDevice? value)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (value == null) return;
|
|
|
|
|
+ Settings.RegularMicDeviceIndex = value.Index;
|
|
|
|
|
+ Settings.RegularMicDeviceName = value.Name;
|
|
|
|
|
+ Settings.RegularMicPactlId = value.PactlId;
|
|
|
|
|
+ _settings.Save();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void RefreshTracks()
|
|
|
|
|
+ {
|
|
|
|
|
+ if (!Directory.Exists(_game.LibraryName))
|
|
|
|
|
+ Directory.CreateDirectory(_game.LibraryName);
|
|
|
|
|
+
|
|
|
|
|
+ _game.Tracks.Clear();
|
|
|
|
|
+ foreach (var f in Directory.GetFiles(_game.LibraryName))
|
|
|
|
|
+ {
|
|
|
|
|
+ if (Path.GetExtension(f) == _game.FileExtension)
|
|
|
|
|
+ _game.Tracks.Add(new Track { Name = Path.GetFileNameWithoutExtension(f) });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ CreateTags(_game);
|
|
|
|
|
+ TrackSettingsService.Load(_game);
|
|
|
|
|
+ TrackSettingsService.Save(_game);
|
|
|
|
|
+ RebuildTrackList();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void RebuildTrackList()
|
|
|
|
|
+ {
|
|
|
|
|
+ Tracks.Clear();
|
|
|
|
|
+ for (int i = 0; i < _game.Tracks.Count; i++)
|
|
|
|
|
+ {
|
|
|
|
|
+ var t = _game.Tracks[i];
|
|
|
|
|
+ Tracks.Add(new TrackItem
|
|
|
|
|
+ {
|
|
|
|
|
+ Index = i,
|
|
|
|
|
+ Name = t.Name,
|
|
|
|
|
+ Hotkey = t.Hotkey,
|
|
|
|
|
+ Volume = t.Volume + "%",
|
|
|
|
|
+ Trimmed = t.EndPos > 0 ? "Yes" : "",
|
|
|
|
|
+ Tags = t.Tags.Count > 0 ? "\"" + string.Join("\", \"", t.Tags) + "\"" : ""
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public async Task StartPollAsync()
|
|
|
|
|
+ {
|
|
|
|
|
+ IsRunning = true;
|
|
|
|
|
+ StartButtonText = "Stop";
|
|
|
|
|
+ InterfaceEnabled = false;
|
|
|
|
|
+
|
|
|
|
|
+ _pollCts = new CancellationTokenSource();
|
|
|
|
|
+ try { await Task.Run(() => PollLoop(_pollCts.Token)); }
|
|
|
|
|
+ catch (OperationCanceledException) { }
|
|
|
|
|
+ catch (Exception ex) { ShowError(ex.Message); }
|
|
|
|
|
+ finally
|
|
|
|
|
+ {
|
|
|
|
|
+ IsRunning = false;
|
|
|
|
|
+ StartButtonText = "Start";
|
|
|
|
|
+ InterfaceEnabled = true;
|
|
|
|
|
+ StatusText = "Status: Idle.";
|
|
|
|
|
+ RebuildTrackList();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void StopPoll()
|
|
|
|
|
+ {
|
|
|
|
|
+ _pollCts?.Cancel();
|
|
|
|
|
+ _audio.Stop();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void PollLoop(CancellationToken ct)
|
|
|
|
|
+ {
|
|
|
|
|
+ StatusText = "Status: Searching...";
|
|
|
|
|
+
|
|
|
|
|
+ if (!Settings.OverrideFolders)
|
|
|
|
|
+ {
|
|
|
|
|
+ while (!ct.IsCancellationRequested)
|
|
|
|
|
+ {
|
|
|
|
|
+ _steamAppsPath = GameLocatorService.FindSteamAppsPath(_game.ExeName);
|
|
|
|
|
+ if (!string.IsNullOrEmpty(_steamAppsPath) && Directory.Exists(_steamAppsPath))
|
|
|
|
|
+ break;
|
|
|
|
|
+ Thread.Sleep(_game.PollInterval);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ else
|
|
|
|
|
+ {
|
|
|
|
|
+ _steamAppsPath = Settings.SteamAppsOverride;
|
|
|
|
|
+ if (!Directory.Exists(_steamAppsPath))
|
|
|
|
|
+ throw new Exception("Steam apps folder does not exist. Check Settings.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ct.ThrowIfCancellationRequested();
|
|
|
|
|
+ CfgService.WriteCfgFiles(_game, _steamAppsPath!, _settings.Settings);
|
|
|
|
|
+ StatusText = "Status: Working.";
|
|
|
|
|
+
|
|
|
|
|
+ var virtualMicOutPactl = string.IsNullOrEmpty(Settings.VirtualMicOutPactlId) ? "VirtualMic.monitor" : Settings.VirtualMicOutPactlId;
|
|
|
|
|
+
|
|
|
|
|
+ var cs2SrcOutputIds = FindCs2SourceOutputIds();
|
|
|
|
|
+ DebugLog($"CS2 pactl source-outputs: [{string.Join(",", cs2SrcOutputIds)}] moving to {virtualMicOutPactl}");
|
|
|
|
|
+ MoveCs2SourceOutputs(cs2SrcOutputIds, virtualMicOutPactl);
|
|
|
|
|
+
|
|
|
|
|
+ var userDataDir = Settings.OverrideFolders && !string.IsNullOrEmpty(Settings.UserDataOverride)
|
|
|
|
|
+ ? Settings.UserDataOverride
|
|
|
|
|
+ : FindUserDataDir(_steamAppsPath!);
|
|
|
|
|
+ var startTime = DateTime.UtcNow;
|
|
|
|
|
+ var seenMtimes = new Dictionary<string, DateTime>();
|
|
|
|
|
+
|
|
|
|
|
+ // seed from the newest existing vcfg so a stale bind from a previous session doesn't fire
|
|
|
|
|
+ string? lastRelayValue = null;
|
|
|
|
|
+ if (userDataDir != null && Directory.Exists(userDataDir))
|
|
|
|
|
+ {
|
|
|
|
|
+ var newest = FindKeyVcfgs(userDataDir).OrderByDescending(File.GetLastWriteTimeUtc).FirstOrDefault();
|
|
|
|
|
+ if (newest != null)
|
|
|
|
|
+ try { lastRelayValue = ParseRelayCommand(File.ReadAllText(newest), Settings.RelayKey); } catch { }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ DebugLog($"SLAM started. userDataDir={userDataDir ?? "null"} startTime={startTime:O} lastRelayValue={lastRelayValue}");
|
|
|
|
|
+
|
|
|
|
|
+ while (!ct.IsCancellationRequested)
|
|
|
|
|
+ {
|
|
|
|
|
+ try
|
|
|
|
|
+ {
|
|
|
|
|
+ if (userDataDir != null && Directory.Exists(userDataDir))
|
|
|
|
|
+ {
|
|
|
|
|
+ foreach (var vcfg in FindKeyVcfgs(userDataDir))
|
|
|
|
|
+ {
|
|
|
|
|
+ var mtime = File.GetLastWriteTimeUtc(vcfg);
|
|
|
|
|
+ if (mtime <= startTime) continue;
|
|
|
|
|
+ if (seenMtimes.TryGetValue(vcfg, out var lastSeen) && mtime <= lastSeen) continue;
|
|
|
|
|
+ seenMtimes[vcfg] = mtime;
|
|
|
|
|
+
|
|
|
|
|
+ DebugLog($"New vcfg: {vcfg} mtime={mtime:O}");
|
|
|
|
|
+ var content = File.ReadAllText(vcfg);
|
|
|
|
|
+ var command = ParseRelayCommand(content, Settings.RelayKey);
|
|
|
|
|
+ DebugLog($" relayKey={Settings.RelayKey} command={command}");
|
|
|
|
|
+
|
|
|
|
|
+ // unchanged bind value means CS2 rewrote the file on its own (round start, buy menu)
|
|
|
|
|
+ if (command == lastRelayValue) continue;
|
|
|
|
|
+ lastRelayValue = command;
|
|
|
|
|
+
|
|
|
|
|
+ int trackNum = ParseTrackNumber(command);
|
|
|
|
|
+ if (trackNum > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ var idx = trackNum - 1;
|
|
|
|
|
+ if (idx >= 0 && idx < _game.Tracks.Count)
|
|
|
|
|
+ {
|
|
|
|
|
+ // CS2 voice stream (SDL Application, media.role=game) only exists while +voicerecord is active.
|
|
|
|
|
+ // host_writeconfig runs before +voicerecord, so the stream may not exist yet - poll briefly.
|
|
|
|
|
+ var freshSrcIds = FindCs2SourceOutputIds();
|
|
|
|
|
+ if (freshSrcIds.Count == 0) { Thread.Sleep(150); freshSrcIds = FindCs2SourceOutputIds(); }
|
|
|
|
|
+ if (freshSrcIds.Count == 0) { Thread.Sleep(150); freshSrcIds = FindCs2SourceOutputIds(); }
|
|
|
|
|
+ DebugLog($" relay: game-capture source-outputs [{string.Join(",", freshSrcIds)}] -> {virtualMicOutPactl}");
|
|
|
|
|
+ MoveCs2SourceOutputs(freshSrcIds, virtualMicOutPactl);
|
|
|
|
|
+
|
|
|
|
|
+ PlayTrack(idx);
|
|
|
|
|
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
|
|
|
|
+ {
|
|
|
|
|
+ foreach (var ti in Tracks) ti.IsPlaying = false;
|
|
|
|
|
+ if (idx < Tracks.Count) Tracks[idx].IsPlaying = true;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ // Don't delete remote/ files - they are Steam Cloud data CS2 relies on.
|
|
|
|
|
+ // seenMtimes prevents re-processing until the next host_writeconfig.
|
|
|
|
|
+ if (!vcfg.Contains("/remote/"))
|
|
|
|
|
+ {
|
|
|
|
|
+ try { File.Delete(vcfg); seenMtimes.Remove(vcfg); } catch { }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (Exception ex) when (ex.HResult != -2147024864)
|
|
|
|
|
+ {
|
|
|
|
|
+ LogError(ex);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Thread.Sleep(_game.PollInterval);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ _audio.Stop();
|
|
|
|
|
+ var regularMicPactl = string.IsNullOrEmpty(Settings.RegularMicPactlId) ? "@DEFAULT_SOURCE@" : Settings.RegularMicPactlId;
|
|
|
|
|
+ MoveCs2SourceOutputs(FindCs2SourceOutputIds(), regularMicPactl);
|
|
|
|
|
+ if (!string.IsNullOrEmpty(_steamAppsPath))
|
|
|
|
|
+ CfgService.DeleteCfgFiles(_game, _steamAppsPath);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static List<int> FindCs2SourceOutputIds()
|
|
|
|
|
+ {
|
|
|
|
|
+ var ids = new List<int>();
|
|
|
|
|
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return ids;
|
|
|
|
|
+ try
|
|
|
|
|
+ {
|
|
|
|
|
+ var psi = new ProcessStartInfo("pactl") { Arguments = "--format=json list source-outputs", UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true };
|
|
|
|
|
+ using var proc = Process.Start(psi)!;
|
|
|
|
|
+ var json = proc.StandardOutput.ReadToEnd();
|
|
|
|
|
+ proc.WaitForExit();
|
|
|
|
|
+ using var doc = JsonDocument.Parse(json);
|
|
|
|
|
+ foreach (var so in doc.RootElement.EnumerateArray())
|
|
|
|
|
+ {
|
|
|
|
|
+ if (!so.TryGetProperty("properties", out var props)) continue;
|
|
|
|
|
+ var mediaRole = props.TryGetProperty("media.role", out var r) ? r.GetString() : null;
|
|
|
|
|
+ var mediaCategory = props.TryGetProperty("media.category", out var c) ? c.GetString() : null;
|
|
|
|
|
+ var binary = props.TryGetProperty("application.process.binary", out var b) ? b.GetString() : null;
|
|
|
|
|
+ // CS2 voice capture appears as "SDL Application" with media.role=game, media.category=Capture
|
|
|
|
|
+ if ((mediaRole == "game" && mediaCategory == "Capture") || binary == "cs2")
|
|
|
|
|
+ if (so.TryGetProperty("index", out var idx)) ids.Add(idx.GetInt32());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (Exception ex) { DebugLog($"FindCs2SourceOutputIds error: {ex.Message}"); }
|
|
|
|
|
+ return ids;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static void MoveCs2SourceOutputs(IEnumerable<int> sourceOutputIds, string pactlSourceName)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return;
|
|
|
|
|
+ foreach (var id in sourceOutputIds)
|
|
|
|
|
+ {
|
|
|
|
|
+ try
|
|
|
|
|
+ {
|
|
|
|
|
+ var args = $"move-source-output {id} {pactlSourceName}";
|
|
|
|
|
+ var psi = new ProcessStartInfo("pactl", args) { UseShellExecute = false, CreateNoWindow = true };
|
|
|
|
|
+ Process.Start(psi)?.WaitForExit();
|
|
|
|
|
+ DebugLog($"pactl {args}");
|
|
|
|
|
+ }
|
|
|
|
|
+ catch { }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static string? FindUserDataDir(string steamAppsPath)
|
|
|
|
|
+ {
|
|
|
|
|
+ var steamDir = Path.GetDirectoryName(steamAppsPath);
|
|
|
|
|
+ if (steamDir == null) return null;
|
|
|
|
|
+
|
|
|
|
|
+ var candidate = Path.Combine(steamDir, "userdata");
|
|
|
|
|
+ if (Directory.Exists(candidate)) return candidate;
|
|
|
|
|
+
|
|
|
|
|
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
|
|
|
|
+ {
|
|
|
|
|
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
|
|
|
+ candidate = Path.Combine(home, ".local/share/Steam/userdata");
|
|
|
|
|
+ if (Directory.Exists(candidate)) return candidate;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<string> FindKeyVcfgs(string userDataDir)
|
|
|
|
|
+ {
|
|
|
|
|
+ // CS2 writes key bindings to remote/ (Steam Cloud) and optionally local/cfg/
|
|
|
|
|
+ var candidates = new List<string>();
|
|
|
|
|
+ foreach (var userDir in Directory.GetDirectories(userDataDir))
|
|
|
|
|
+ {
|
|
|
|
|
+ var gameUserDir = Path.Combine(userDir, _game.Id.ToString());
|
|
|
|
|
+ if (!Directory.Exists(gameUserDir)) continue;
|
|
|
|
|
+
|
|
|
|
|
+ var remoteKeys = Path.Combine(gameUserDir, "remote", "cs2_user_keys.vcfg");
|
|
|
|
|
+ if (File.Exists(remoteKeys)) candidates.Add(remoteKeys);
|
|
|
|
|
+ var localCfg = Path.Combine(gameUserDir, "local", "cfg");
|
|
|
|
|
+ if (Directory.Exists(localCfg))
|
|
|
|
|
+ candidates.AddRange(Directory.GetFiles(localCfg, "*keys*.vcfg"));
|
|
|
|
|
+ }
|
|
|
|
|
+ return candidates;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static string ParseRelayCommand(string cfgContent, string relayKey)
|
|
|
|
|
+ {
|
|
|
|
|
+ var pattern = $"\"{Regex.Escape(relayKey)}\"\\s+\"(.*?)\"";
|
|
|
|
|
+ var m = Regex.Match(cfgContent, pattern, RegexOptions.IgnoreCase);
|
|
|
|
|
+ return m.Success ? m.Groups[1].Value : "";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static int ParseTrackNumber(string relayValue)
|
|
|
|
|
+ {
|
|
|
|
|
+ var m = Regex.Match(relayValue, @"^slam_r_(\d+)_[ab]$");
|
|
|
|
|
+ if (m.Success) return int.Parse(m.Groups[1].Value);
|
|
|
|
|
+ return int.TryParse(relayValue, out var n) ? n : 0; // plain number from an old slam.cfg
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void PlayTrack(int index)
|
|
|
|
|
+ {
|
|
|
|
|
+ DebugLog($"PlayTrack({index}) trackCount={_game.Tracks.Count}");
|
|
|
|
|
+ if (index < 0 || index >= _game.Tracks.Count) { DebugLog(" -> index out of range"); return; }
|
|
|
|
|
+ var track = _game.Tracks[index];
|
|
|
|
|
+ var filePath = Path.Combine(_game.LibraryName, track.Name + _game.FileExtension);
|
|
|
|
|
+ DebugLog($" filePath={filePath} exists={File.Exists(filePath)} globalVol={Settings.GlobalVolume} trackVol={track.Volume} pactlId={Settings.VirtualMicInPactlId}");
|
|
|
|
|
+ if (!File.Exists(filePath)) { DebugLog(" -> file not found"); return; }
|
|
|
|
|
+
|
|
|
|
|
+ try
|
|
|
|
|
+ {
|
|
|
|
|
+ float volume = (Settings.GlobalVolume / 100f) * (track.Volume / 100f);
|
|
|
|
|
+ _audio.Play(filePath, Settings.VirtualMicInDeviceIndex, volume, track.StartPos, track.EndPos, Settings.VirtualMicInPactlId);
|
|
|
|
|
+ DebugLog(" -> audio.Play called");
|
|
|
|
|
+
|
|
|
|
|
+ if (!string.IsNullOrEmpty(_steamAppsPath))
|
|
|
|
|
+ CfgService.WriteTrackCfgs(_game, _steamAppsPath, track);
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (Exception ex)
|
|
|
|
|
+ {
|
|
|
|
|
+ LogError(ex);
|
|
|
|
|
+ ShowError("Playback failed: " + ex.Message);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void StreamToMic(int index)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (index < 0 || index >= _game.Tracks.Count) return;
|
|
|
|
|
+ var track = _game.Tracks[index];
|
|
|
|
|
+ var filePath = Path.Combine(_game.LibraryName, track.Name + _game.FileExtension);
|
|
|
|
|
+ if (!File.Exists(filePath)) return;
|
|
|
|
|
+
|
|
|
|
|
+ try
|
|
|
|
|
+ {
|
|
|
|
|
+ _audio.Play(filePath, Settings.VirtualMicInDeviceIndex, (Settings.GlobalVolume / 100f) * (track.Volume / 100f), track.StartPos, track.EndPos, Settings.VirtualMicInPactlId);
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (Exception ex)
|
|
|
|
|
+ {
|
|
|
|
|
+ LogError(ex);
|
|
|
|
|
+ ShowError("Playback failed: " + ex.Message);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ IsStreaming = true;
|
|
|
|
|
+ StreamButtonText = "Stop Stream";
|
|
|
|
|
+ StreamStatusText = $"Streaming: {track.Name}";
|
|
|
|
|
+
|
|
|
|
|
+ foreach (var ti in Tracks) ti.IsPlaying = false;
|
|
|
|
|
+ if (index < Tracks.Count) Tracks[index].IsPlaying = true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void StopStream()
|
|
|
|
|
+ {
|
|
|
|
|
+ _audio.Stop();
|
|
|
|
|
+ IsStreaming = false;
|
|
|
|
|
+ StreamButtonText = "Stream to Mic";
|
|
|
|
|
+ StreamStatusText = "";
|
|
|
|
|
+ foreach (var ti in Tracks) ti.IsPlaying = false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public async Task ImportFilesAsync(IEnumerable<string> files, bool deleteSource = false)
|
|
|
|
|
+ {
|
|
|
|
|
+ var fileList = files.ToList();
|
|
|
|
|
+ InterfaceEnabled = false;
|
|
|
|
|
+ ProgressMax = fileList.Count;
|
|
|
|
|
+ Progress = 0;
|
|
|
|
|
+
|
|
|
|
|
+ var failed = new List<string>();
|
|
|
|
|
+ await Task.Run(() =>
|
|
|
|
|
+ {
|
|
|
|
|
+ foreach (var file in fileList)
|
|
|
|
|
+ {
|
|
|
|
|
+ try
|
|
|
|
|
+ {
|
|
|
|
|
+ var outFile = Path.Combine(_game.LibraryName,
|
|
|
|
|
+ Path.GetFileNameWithoutExtension(file) + ".wav");
|
|
|
|
|
+ ConversionService.ConvertToWav(file, outFile, _game.SampleRate, _game.Channels);
|
|
|
|
|
+ if (deleteSource) File.Delete(file);
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (Exception ex)
|
|
|
|
|
+ {
|
|
|
|
|
+ LogError(ex);
|
|
|
|
|
+ failed.Add(Path.GetFileName(file));
|
|
|
|
|
+ }
|
|
|
|
|
+ Avalonia.Threading.Dispatcher.UIThread.Post(() => Progress++);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ Progress = 0;
|
|
|
|
|
+ RefreshTracks();
|
|
|
|
|
+ InterfaceEnabled = true;
|
|
|
|
|
+
|
|
|
|
|
+ if (failed.Count > 0)
|
|
|
|
|
+ ShowError("Conversion complete! Failed: " + string.Join(", ", failed));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void DeleteTracks(IEnumerable<int> indices)
|
|
|
|
|
+ {
|
|
|
|
|
+ foreach (var idx in indices.OrderByDescending(x => x))
|
|
|
|
|
+ {
|
|
|
|
|
+ var t = _game.Tracks[idx];
|
|
|
|
|
+ var path = Path.Combine(_game.LibraryName, t.Name + _game.FileExtension);
|
|
|
|
|
+ if (File.Exists(path)) try { File.Delete(path); } catch { }
|
|
|
|
|
+ }
|
|
|
|
|
+ RefreshTracks();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void SetTrackVolume(IEnumerable<int> indices, int volume)
|
|
|
|
|
+ {
|
|
|
|
|
+ foreach (var idx in indices)
|
|
|
|
|
+ if (idx < _game.Tracks.Count)
|
|
|
|
|
+ _game.Tracks[idx].Volume = volume;
|
|
|
|
|
+ TrackSettingsService.Save(_game);
|
|
|
|
|
+ RefreshTracks();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public bool SetTrackHotkey(int index, string key)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (index < 0 || index >= _game.Tracks.Count) return false;
|
|
|
|
|
+ if (key.Equals(Settings.PlayKey, StringComparison.OrdinalIgnoreCase) ||
|
|
|
|
|
+ key.Equals(Settings.RelayKey, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
+ { ShowError($"\"{key}\" is used as the play/relay key!"); return false; }
|
|
|
|
|
+ bool inUse = _game.Tracks.Where((_, i) => i != index).Any(t => t.Hotkey == key);
|
|
|
|
|
+ if (inUse) { ShowError($"\"{key}\" is already assigned!"); return false; }
|
|
|
|
|
+ _game.Tracks[index].Hotkey = key;
|
|
|
|
|
+ TrackSettingsService.Save(_game);
|
|
|
|
|
+ RefreshTracks();
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void RemoveHotkey(int index)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (index < 0 || index >= _game.Tracks.Count) return;
|
|
|
|
|
+ _game.Tracks[index].Hotkey = "";
|
|
|
|
|
+ TrackSettingsService.Save(_game);
|
|
|
|
|
+ RefreshTracks();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void SetTrackTrim(int index, int startPos, int endPos)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (index < 0 || index >= _game.Tracks.Count) return;
|
|
|
|
|
+ _game.Tracks[index].StartPos = startPos;
|
|
|
|
|
+ _game.Tracks[index].EndPos = endPos;
|
|
|
|
|
+ TrackSettingsService.Save(_game);
|
|
|
|
|
+ RefreshTracks();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void RenameTrack(int index, string newName)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (index < 0 || index >= _game.Tracks.Count) return;
|
|
|
|
|
+ var track = _game.Tracks[index];
|
|
|
|
|
+ var oldPath = Path.Combine(_game.LibraryName, track.Name + _game.FileExtension);
|
|
|
|
|
+ var newPath = Path.Combine(_game.LibraryName, newName + _game.FileExtension);
|
|
|
|
|
+ if (File.Exists(newPath)) { ShowError("A track with that name already exists."); return; }
|
|
|
|
|
+ try { File.Move(oldPath, newPath); }
|
|
|
|
|
+ catch (Exception ex) { ShowError("Rename failed: " + ex.Message); return; }
|
|
|
|
|
+ track.Name = newName;
|
|
|
|
|
+ TrackSettingsService.Save(_game);
|
|
|
|
|
+ RefreshTracks();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public string GetTrackFilePath(int index)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (index < 0 || index >= _game.Tracks.Count) return "";
|
|
|
|
|
+ return Path.Combine(_game.LibraryName, _game.Tracks[index].Name + _game.FileExtension);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public Track? GetTrack(int index)
|
|
|
|
|
+ => index >= 0 && index < _game.Tracks.Count ? _game.Tracks[index] : null;
|
|
|
|
|
+
|
|
|
|
|
+ public void SetPlayKey(string key)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (key == Settings.RelayKey) { ShowError("Play key and relay key cannot be the same!"); return; }
|
|
|
|
|
+ Settings.PlayKey = key;
|
|
|
|
|
+ _settings.Save();
|
|
|
|
|
+ PlayKeyButtonText = $"Play key: \"{Settings.PlayKey}\" (change)";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void RefreshPlayKey()
|
|
|
|
|
+ {
|
|
|
|
|
+ PlayKeyButtonText = $"Play key: \"{Settings.PlayKey}\" (change)";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static void CreateTags(SourceGame game)
|
|
|
|
|
+ {
|
|
|
|
|
+ var wordMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
+ var ambiguous = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
+ for (int i = 0; i < game.Tracks.Count; i++)
|
|
|
|
|
+ {
|
|
|
|
|
+ foreach (var w in game.Tracks[i].Name.Split(' ', '.', '-', '_'))
|
|
|
|
|
+ {
|
|
|
|
|
+ // only plain alphanumeric words survive as cfg aliases
|
|
|
|
|
+ if (w.Length == 0 || w.Length >= 32) continue;
|
|
|
|
|
+ if (!w.All(char.IsAsciiLetterOrDigit) || w.All(char.IsDigit)) continue;
|
|
|
|
|
+ if (game.Blacklist.Contains(w.ToLower()) || ambiguous.Contains(w)) continue;
|
|
|
|
|
+
|
|
|
|
|
+ if (wordMap.TryGetValue(w, out var owner))
|
|
|
|
|
+ {
|
|
|
|
|
+ if (owner != i) { wordMap.Remove(w); ambiguous.Add(w); }
|
|
|
|
|
+ }
|
|
|
|
|
+ else wordMap[w] = i;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ foreach (var (word, idx) in wordMap)
|
|
|
|
|
+ game.Tracks[idx].Tags.Add(word);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void LogError(Exception ex)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (!Settings.LogError) return;
|
|
|
|
|
+ File.AppendAllText("errorlog.txt", $"----{DateTime.UtcNow} UTC----\n{ex}\n");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private const bool DebugLogging = false; // flip to true to log to slam_debug.log
|
|
|
|
|
+
|
|
|
|
|
+ private static void DebugLog(string msg)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (DebugLogging)
|
|
|
|
|
+ File.AppendAllText("slam_debug.log", $"{DateTime.UtcNow:O} {msg}\n");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public event Action<string>? ErrorOccurred;
|
|
|
|
|
+ public void ShowError(string msg) => ErrorOccurred?.Invoke(msg);
|
|
|
|
|
+
|
|
|
|
|
+ public void SaveSettings() => _settings.Save();
|
|
|
|
|
+}
|