Bladeren bron

Initial commit: cross-platform SLAM rewrite

UwU 3 maanden geleden
commit
c9e3490aa9

+ 15 - 0
.gitignore

@@ -0,0 +1,15 @@
+bin/
+obj/
+*.user
+.vs/
+.idea/
+*.suo
+*.swp
+.env
+
+# Runtime files
+slam_audio.log
+slam_debug.log
+errorlog.txt
+game/
+TrackSettings.xml

+ 16 - 0
App.axaml

@@ -0,0 +1,16 @@
+<Application xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             x:Class="SlamCross.App"
+             xmlns:local="using:SlamCross"
+             RequestedThemeVariant="Default">
+             <!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
+
+    <Application.DataTemplates>
+        <local:ViewLocator/>
+    </Application.DataTemplates>
+  
+    <Application.Styles>
+        <FluentTheme />
+        <StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
+    </Application.Styles>
+</Application>

+ 19 - 0
App.axaml.cs

@@ -0,0 +1,19 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using SlamCross.Views;
+
+namespace SlamCross;
+
+public partial class App : Application
+{
+    public override void Initialize() => AvaloniaXamlLoader.Load(this);
+
+    public override void OnFrameworkInitializationCompleted()
+    {
+        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+            desktop.MainWindow = new MainWindow();
+
+        base.OnFrameworkInitializationCompleted();
+    }
+}

BIN
Assets/avalonia-logo.ico


+ 110 - 0
Controls/WaveformControl.cs

@@ -0,0 +1,110 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+
+namespace SlamCross.Controls;
+
+public sealed class WaveformControl : Control
+{
+    private (float Low, float High)[] _peaks = Array.Empty<(float, float)>();
+    private double _startNorm;
+    private double _endNorm = 1.0;
+    private bool _dragging;
+    private double _dragOriginNorm;
+
+    private static readonly IBrush BgBrush = new SolidColorBrush(Color.FromRgb(18, 18, 28));
+    private static readonly IBrush SelBgBrush = new SolidColorBrush(Color.FromArgb(50, 80, 160, 255));
+    private static readonly IBrush WaveBrush = new SolidColorBrush(Color.FromRgb(40, 100, 180));
+    private static readonly IBrush SelWaveBrush = new SolidColorBrush(Color.FromRgb(100, 180, 255));
+    private static readonly IPen StartPen = new Pen(Brushes.LimeGreen, 2);
+    private static readonly IPen EndPen = new Pen(Brushes.OrangeRed, 2);
+
+    public event Action<double, double>? SelectionChanged;
+
+    public (double Start, double End) Selection => (_startNorm, _endNorm);
+
+    public void SetPeaks((float Low, float High)[] peaks)
+    {
+        _peaks = peaks;
+        InvalidateVisual();
+    }
+
+    public void SetSelection(double startNorm, double endNorm)
+    {
+        _startNorm = Math.Clamp(startNorm, 0, 1);
+        _endNorm = Math.Clamp(endNorm, 0, 1);
+        InvalidateVisual();
+    }
+
+    private double XToNorm(double x) => Math.Clamp(x / Bounds.Width, 0, 1);
+    private double NormToX(double n) => n * Bounds.Width;
+
+    public override void Render(DrawingContext ctx)
+    {
+        var w = Bounds.Width;
+        var h = Bounds.Height;
+        if (w <= 0 || h <= 0) return;
+
+        ctx.FillRectangle(BgBrush, new Rect(0, 0, w, h));
+
+        if (_peaks.Length == 0) return;
+
+        double midY = h / 2.0;
+        double startX = NormToX(_startNorm);
+        double endX = NormToX(_endNorm);
+
+        double selW = Math.Max(1, endX - startX);
+        ctx.FillRectangle(SelBgBrush, new Rect(startX, 0, selW, h));
+
+        int iw = (int)w;
+        for (int px = 0; px < iw; px++)
+        {
+            int peakIdx = (int)((double)px / iw * _peaks.Length);
+            if (peakIdx >= _peaks.Length) break;
+            var (lo, hi) = _peaks[peakIdx];
+            double top = midY - hi * midY;
+            double bot = midY - lo * midY;
+            bool inSel = px >= startX - 0.5 && px <= endX + 0.5;
+            ctx.FillRectangle(inSel ? SelWaveBrush : WaveBrush,
+                new Rect(px, top, 1, Math.Max(1, bot - top)));
+        }
+
+        ctx.DrawLine(StartPen, new Point(startX, 0), new Point(startX, h));
+        ctx.DrawLine(EndPen, new Point(endX, 0), new Point(endX, h));
+    }
+
+    protected override void OnPointerPressed(PointerPressedEventArgs e)
+    {
+        base.OnPointerPressed(e);
+        if (_peaks.Length == 0) return;
+        _dragging = true;
+        _dragOriginNorm = XToNorm(e.GetPosition(this).X);
+        _startNorm = _dragOriginNorm;
+        _endNorm = _dragOriginNorm;
+        InvalidateVisual();
+        e.Pointer.Capture(this);
+        e.Handled = true;
+    }
+
+    protected override void OnPointerMoved(PointerEventArgs e)
+    {
+        base.OnPointerMoved(e);
+        if (!_dragging) return;
+        double norm = XToNorm(e.GetPosition(this).X);
+        _startNorm = Math.Min(norm, _dragOriginNorm);
+        _endNorm = Math.Max(norm, _dragOriginNorm);
+        InvalidateVisual();
+    }
+
+    protected override void OnPointerReleased(PointerReleasedEventArgs e)
+    {
+        base.OnPointerReleased(e);
+        if (!_dragging) return;
+        _dragging = false;
+        e.Pointer.Capture(null);
+        SelectionChanged?.Invoke(_startNorm, _endNorm);
+        e.Handled = true;
+    }
+}

+ 28 - 0
Models/AppSettings.cs

@@ -0,0 +1,28 @@
+namespace SlamCross.Models;
+
+public class AppSettings
+{
+    public string PlayKey { get; set; } = "MOUSE4";
+    public string RelayKey { get; set; } = "=";
+    public bool StartEnabled { get; set; } = false;
+    public bool StartMinimized { get; set; } = false;
+    public bool HoldToPlay { get; set; } = false;
+    public bool LogError { get; set; } = true;
+    public bool WriteTags { get; set; } = true;
+    public bool OverrideFolders { get; set; } = false;
+    public string SteamAppsOverride { get; set; } = "";
+    public string UserDataOverride { get; set; } = "";
+    public int GlobalVolume { get; set; } = 50;
+
+    public int VirtualMicInDeviceIndex { get; set; } = -1;
+    public string VirtualMicInDeviceName { get; set; } = "";
+    public string VirtualMicInPactlId { get; set; } = "";
+
+    public int VirtualMicOutDeviceIndex { get; set; } = -1;
+    public string VirtualMicOutDeviceName { get; set; } = "";
+    public string VirtualMicOutPactlId { get; set; } = "";
+
+    public int RegularMicDeviceIndex { get; set; } = -1;
+    public string RegularMicDeviceName { get; set; } = "";
+    public string RegularMicPactlId { get; set; } = "";
+}

+ 26 - 0
Models/SourceGame.cs

@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+
+namespace SlamCross.Models;
+
+public class SourceGame
+{
+    public string Name { get; set; } = "";
+    public int Id { get; set; }
+    public string Directory { get; set; } = "";
+    public string ToCfg { get; set; } = "";
+    public string LibraryName { get; set; } = "";
+    public string ExeName { get; set; } = "hl2";
+    public string FileExtension { get; set; } = ".wav";
+    public int SampleRate { get; set; } = 11025;
+    public int Channels { get; set; } = 1;
+    public int PollInterval { get; set; } = 50;
+    public bool VoiceFadeOut { get; set; } = true;
+
+    public List<Track> Tracks { get; set; } = new();
+    public List<string> Blacklist { get; set; } = new()
+    {
+        "slam", "slam_listtracks", "list", "tracks", "la",
+        "slam_play", "slam_play_on", "slam_play_off",
+        "slam_updatecfg", "slam_curtrack", "slam_saycurtrack", "slam_sayteamcurtrack"
+    };
+}

+ 16 - 0
Models/Track.cs

@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+
+namespace SlamCross.Models;
+
+[DataContract]
+public class Track
+{
+    [DataMember] public string Name { get; set; } = "";
+    [DataMember] public string Hotkey { get; set; } = "";
+    [DataMember] public int Volume { get; set; } = 100;
+    [DataMember] public int StartPos { get; set; }
+    [DataMember] public int EndPos { get; set; }
+
+    public List<string> Tags { get; set; } = new();
+}

+ 20 - 0
Program.cs

@@ -0,0 +1,20 @@
+using Avalonia;
+using System;
+
+namespace SlamCross;
+
+sealed class Program
+{
+    [STAThread]
+    public static void Main(string[] args) => BuildAvaloniaApp()
+        .StartWithClassicDesktopLifetime(args);
+
+    public static AppBuilder BuildAvaloniaApp()
+        => AppBuilder.Configure<App>()
+            .UsePlatformDetect()
+#if DEBUG
+            .WithDeveloperTools()
+#endif
+            .WithInterFont()
+            .LogToTrace();
+}

+ 247 - 0
README.md

@@ -0,0 +1,247 @@
+# SLAM Cross-Platform
+
+A cross-platform soundboard for CS2 and general voice chat apps (TeamSpeak, Discord, etc.). Play music and audio clips through your microphone (micspam) using a virtual audio cable. Works on Linux and Windows.
+
+This is a rewrite of [Skintillion/SLAM](https://github.com/Skintillion/SLAM) built with .NET 8 and Avalonia UI.
+
+## How it works
+
+**CS2 mode**: SLAM writes CS2 config files that use `voice_device_override` to switch your active mic to a virtual audio cable when you play a sound, then switches back to your real mic when it stops. Other players hear it through your voice channel.
+
+**Stream to Mic mode**: Plays audio directly into a virtual microphone that any app (TeamSpeak, Discord, etc.) can use as a capture device. Your real mic is mixed in simultaneously so you can talk while sounds play.
+
+This does not inject into CS2 or modify game memory. It only writes `.cfg` files and routes system audio, which is VAC safe.
+
+## Requirements
+
+### Linux
+
+- .NET 8 SDK
+- ffmpeg
+- PipeWire with PulseAudio compatibility (pactl must be available)
+- yt-dlp (optional, for YouTube import)
+
+Install on Fedora/Nobara:
+
+```bash
+sudo dnf install dotnet-sdk-8.0 ffmpeg
+```
+
+Install on Ubuntu/Debian:
+
+```bash
+sudo apt install dotnet-sdk-8.0 ffmpeg
+```
+
+### Windows
+
+- .NET 8 SDK or Runtime
+- ffmpeg on PATH
+- VB-Audio Virtual Cable or similar
+
+## Linux audio setup
+
+This is the most involved part. You need a virtual audio sink that SLAM plays into, a virtual microphone source that apps like TeamSpeak can capture from, and a loopback so your real voice is mixed in alongside the soundboard audio.
+
+### 1. Create the virtual devices (persistent)
+
+Create `~/.config/pipewire/pipewire-pulse.conf.d/virtual-mic.conf` with:
+
+```
+context.exec = [
+    { path = "pactl" args = "load-module module-null-sink sink_name=VirtualMic sink_properties=device.description=VirtualMic" }
+    { path = "pactl" args = "load-module module-virtual-source source_name=VirtualMicInput master=VirtualMic.monitor source_properties=device.description=VirtualMicInput" }
+]
+```
+
+This creates:
+- `VirtualMic` -- a virtual sink SLAM plays audio into
+- `VirtualMicInput` -- a virtual microphone source apps like TeamSpeak see as a capture device
+
+To apply without rebooting:
+
+```bash
+mkdir -p ~/.config/pipewire/pipewire-pulse.conf.d
+# (create the file above, then:)
+systemctl --user restart pipewire-pulse
+```
+
+### 2. Route your real mic into VirtualMic (optional but recommended)
+
+Without this step, only soundboard audio goes through VirtualMic. With it, your voice and the soundboard are mixed together so you can talk and stream sounds at the same time.
+
+Create a systemd user service at `~/.config/systemd/user/slam-mic-loopback.service`:
+
+```ini
+[Unit]
+Description=SLAM mic loopback (your mic -> VirtualMic)
+After=pipewire.service pipewire-pulse.service
+BindsTo=pipewire.service
+
+[Service]
+ExecStartPre=/bin/bash -c 'until pactl list sinks short | grep -q VirtualMic; do sleep 0.5; done'
+ExecStart=/bin/bash -c 'pacat --record \
+  --device=YOUR_MIC_PACTL_ID \
+  --channels=2 --format=s32le --rate=48000 \
+  --latency-msec=20 --process-time-msec=10 \
+  --stream-name=slam-mic-loopback | \
+pacat --playback \
+  --device=VirtualMic \
+  --channels=2 --format=s32le --rate=48000 \
+  --latency-msec=20 --process-time-msec=10 \
+  --stream-name=slam-mic-loopback'
+Restart=on-failure
+RestartSec=2
+
+[Install]
+WantedBy=default.target
+```
+
+Replace `YOUR_MIC_PACTL_ID` with your microphone's pactl name. Find it with:
+
+```bash
+pactl list sources | grep -E "Name:|Description:" | grep -v monitor
+```
+
+Enable and start it:
+
+```bash
+systemctl --user daemon-reload
+systemctl --user enable --now slam-mic-loopback.service
+```
+
+### 3. Configure TeamSpeak (or Discord, etc.)
+
+In TeamSpeak 3: Settings -> Options -> Capture -> set Capture Device to **VirtualMicInput**.
+
+In TeamSpeak 5: Settings -> Audio -> Microphone -> select **VirtualMicInput**.
+
+Restart the app after creating VirtualMicInput so the device list refreshes.
+
+## Building and running
+
+Clone the repo and run from the `SLAM-Cross` directory:
+
+```bash
+cd SLAM-Cross
+dotnet run
+```
+
+To build a release binary:
+
+```bash
+dotnet publish -c Release -r linux-x64 --self-contained
+```
+
+The output will be in `bin/Release/net8.0/linux-x64/publish/`.
+
+## First-time setup in the app
+
+1. Launch the app.
+2. In the three dropdowns at the top, select your audio devices:
+   - **Virtual Cable In**: the sink you play audio into (`VirtualMic` on Linux, CABLE Input on Windows)
+   - **Cable Out (mic)**: the monitor source CS2 uses as your mic (`Monitor of VirtualMic` on Linux)
+   - **Regular Mic**: your actual microphone to switch back to after sounds finish
+3. Click **Import Files** to add audio files. Supported formats: mp3, wav, aac, flac, ogg, m4a, mp4, and others supported by ffmpeg.
+
+## CS2 usage
+
+1. Click **Start**. SLAM will search for your CS2 installation.
+2. In CS2, open the console and type `exec slam`.
+3. Use the relay key (default `=`) to select a track by number or tag, then press the play key (default `MOUSE4`) to play it.
+
+Example: type `3` in console, press `MOUSE4` to play track 3. Type `slam_listtracks` to see all tracks.
+
+## Stream to Mic (TeamSpeak, Discord, etc.)
+
+Select a track in the list and click **Stream to Mic**. The audio plays directly into VirtualMic without needing CS2. If you set up the mic loopback service above, your voice is mixed in simultaneously.
+
+Click **Stream to Mic** again (it becomes a stop button while streaming) or double-click another track to switch.
+
+## CS2 console commands written by SLAM
+
+| Command | What it does |
+|---|---|
+| `exec slam` | Loads all SLAM binds |
+| `slam_listtracks` | Prints track list to console |
+| `slam_play` | Toggle play/stop |
+| `slam_curtrack` | Echoes the current track name |
+| `slam_saycurtrack` | Says current track name in chat |
+| `slam_sayteamcurtrack` | Says current track name in team chat |
+
+## Settings
+
+Click the **Settings** button to configure:
+
+- **Start polling on launch**: automatically start when the app opens
+- **Hold key to play**: hold the play key instead of toggling
+- **Relay key**: the key CS2 uses to pass track selection back to SLAM (default `=`)
+- **Override Steam folder detection**: manually set your steamapps and userdata paths if auto-detection fails
+
+Settings are saved to `~/.config/SLAM/settings.json` on Linux or `%AppData%\SLAM\settings.json` on Windows.
+
+## Track management
+
+Right-click any track in the list for options:
+
+- **Play**: stream the track immediately (works in both CS2 mode and standalone)
+- **Set Hotkey**: bind a keyboard key to load this track directly
+- **Set Volume**: adjust playback volume (1-100)
+- **Trim**: set start and end points
+- **Rename**: rename the track file
+- **Show in Files**: open the track's folder in your file manager
+- **Delete**: remove the track file
+
+## YouTube import
+
+Click **Import from YouTube**. Requires `yt-dlp` installed and on your PATH.
+
+Install yt-dlp:
+
+```bash
+pip install yt-dlp
+```
+
+Or on Fedora:
+
+```bash
+sudo dnf install yt-dlp
+```
+
+## Troubleshooting
+
+**SLAM is stuck on "Searching..."**
+
+CS2 is not running, or auto-detection failed. Enable "Override Steam folder detection" in Settings and set the paths manually. On Linux the steamapps path is typically `~/.local/share/Steam/steamapps` and userdata is `~/.local/share/Steam/userdata`.
+
+**Audio goes to the wrong device or does not reach TeamSpeak**
+
+Make sure VirtualMicInput appears in TeamSpeak's device list. If it was created after TeamSpeak was already open, restart TeamSpeak. Also verify the Virtual Cable In dropdown in SLAM is set to `VirtualMic`.
+
+**Mic loopback has too much delay**
+
+Lower the `--latency-msec` values in the service file (e.g. `10` instead of `20`). If you get crackling, increase them slightly.
+
+**Mic loopback crackles**
+
+Increase `--latency-msec` in the service file. Values between 20 and 50 are usually stable.
+
+**No audio reaches other players in CS2**
+
+Make sure the Cable Out (mic) dropdown matches what CS2 uses for voice. On Linux this is typically `Monitor of VirtualMic`. The device name must match what CS2 reports in `voice_device_override`.
+
+**Sounds play but cut off immediately**
+
+The virtual sink may be suspending due to inactivity. Keep it alive with:
+
+```bash
+paplay --device=VirtualMic /dev/zero &
+```
+
+**Audio distortion or wrong speed**
+
+The app converts files to 22050 Hz mono WAV before playback. If conversion fails, check that ffmpeg is installed with `ffmpeg -version`.
+
+**errorlog.txt**
+
+If "Log errors to errorlog.txt" is enabled in settings, errors are written to `errorlog.txt` in the directory you run the app from.

+ 28 - 0
SLAM-Cross.csproj

@@ -0,0 +1,28 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>WinExe</OutputType>
+    <TargetFramework>net8.0</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ApplicationManifest>app.manifest</ApplicationManifest>
+    <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
+    <DefineConstants Condition="$([MSBuild]::IsOSPlatform('Windows'))">$(DefineConstants);WINDOWS</DefineConstants>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <AvaloniaResource Include="Assets\**" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Avalonia" Version="12.0.4" />
+    <PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
+    <PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
+    <PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
+    <PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
+      <IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
+      <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
+    </PackageReference>
+    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
+    <PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
+    <PackageReference Include="NAudio" Version="2.2.1" Condition="$([MSBuild]::IsOSPlatform('Windows'))" />
+  </ItemGroup>
+</Project>

+ 218 - 0
Services/AudioService.cs

@@ -0,0 +1,218 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Threading.Tasks;
+
+namespace SlamCross.Services;
+
+public record AudioDevice(int Index, string Name, string PactlId = "");
+
+public interface IAudioService : IDisposable
+{
+    List<AudioDevice> GetOutputDevices();
+    List<AudioDevice> GetInputDevices();
+    void Play(string filePath, int deviceIndex, float volume = 1.0f, int startSampleBytes = 0, int endSampleBytes = 0, string pactlId = "");
+    void Stop();
+}
+
+public class LinuxAudioService : IAudioService
+{
+    private Process? _ffmpegProc;
+    private Process? _pacatProc;
+
+    public List<AudioDevice> GetOutputDevices() => ParsePactl("sinks");
+    public List<AudioDevice> GetInputDevices() => ParsePactl("sources");
+
+    public void Play(string filePath, int deviceIndex, float volume = 1.0f, int startSampleBytes = 0, int endSampleBytes = 0, string pactlId = "")
+    {
+        Stop();
+
+        string sinkId = pactlId;
+        if (string.IsNullOrEmpty(sinkId))
+        {
+            var devices = GetOutputDevices();
+            sinkId = deviceIndex >= 0 && deviceIndex < devices.Count ? devices[deviceIndex].PactlId : "";
+        }
+
+        var absPath = Path.GetFullPath(filePath);
+
+        var ffmpegPsi = new ProcessStartInfo("ffmpeg") { UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true };
+        if (startSampleBytes > 0)
+        {
+            ffmpegPsi.ArgumentList.Add("-ss");
+            ffmpegPsi.ArgumentList.Add(BytesToSec(startSampleBytes).ToString("F5"));
+        }
+        ffmpegPsi.ArgumentList.Add("-i"); ffmpegPsi.ArgumentList.Add(absPath);
+        if (endSampleBytes > startSampleBytes && endSampleBytes > 0)
+        {
+            ffmpegPsi.ArgumentList.Add("-t");
+            ffmpegPsi.ArgumentList.Add(BytesToSec(endSampleBytes - startSampleBytes).ToString("F5"));
+        }
+        if (Math.Abs(volume - 1.0f) >= 0.001f)
+        {
+            ffmpegPsi.ArgumentList.Add("-af");
+            ffmpegPsi.ArgumentList.Add($"volume={Math.Pow(volume, 2):F5}");
+        }
+        ffmpegPsi.ArgumentList.Add("-f"); ffmpegPsi.ArgumentList.Add("s16le");
+        ffmpegPsi.ArgumentList.Add("-ar"); ffmpegPsi.ArgumentList.Add("48000");
+        ffmpegPsi.ArgumentList.Add("-ac"); ffmpegPsi.ArgumentList.Add("1");
+        ffmpegPsi.ArgumentList.Add("-loglevel"); ffmpegPsi.ArgumentList.Add("quiet");
+        ffmpegPsi.ArgumentList.Add("pipe:1");
+
+        var pacatPsi = new ProcessStartInfo("pacat") { UseShellExecute = false, RedirectStandardInput = true, CreateNoWindow = true };
+        pacatPsi.ArgumentList.Add("--playback");
+        pacatPsi.ArgumentList.Add("--rate=48000");
+        pacatPsi.ArgumentList.Add("--channels=1");
+        pacatPsi.ArgumentList.Add("--format=s16le");
+        if (!string.IsNullOrEmpty(sinkId)) { pacatPsi.ArgumentList.Add("--device"); pacatPsi.ArgumentList.Add(sinkId); }
+
+        _ffmpegProc = Process.Start(ffmpegPsi)!;
+        _pacatProc = Process.Start(pacatPsi)!;
+
+        var ffOut = _ffmpegProc.StandardOutput.BaseStream;
+        var paIn = _pacatProc.StandardInput.BaseStream;
+        Task.Run(async () =>
+        {
+            try { await ffOut.CopyToAsync(paIn); } catch { }
+            try { paIn.Close(); } catch { }
+        });
+    }
+
+    private static double BytesToSec(int sampleBytes) => sampleBytes / (22050.0 * 2);
+
+    public void Stop()
+    {
+        try { _ffmpegProc?.Kill(); } catch { }
+        try { _pacatProc?.Kill(); } catch { }
+        try { _ffmpegProc?.Dispose(); } catch { }
+        try { _pacatProc?.Dispose(); } catch { }
+        _ffmpegProc = null;
+        _pacatProc = null;
+    }
+
+    private static List<AudioDevice> ParsePactl(string type)
+    {
+        try
+        {
+            var psi = new ProcessStartInfo("pactl", $"list {type}")
+            {
+                RedirectStandardOutput = true,
+                UseShellExecute = false,
+                CreateNoWindow = true
+            };
+            using var proc = Process.Start(psi);
+            var output = proc?.StandardOutput.ReadToEnd() ?? "";
+
+            var devices = new List<AudioDevice>();
+            string? currentName = null;
+            string? currentDesc = null;
+            int idx = 0;
+
+            foreach (var line in output.Split('\n'))
+            {
+                var trimmed = line.Trim();
+                if (trimmed.StartsWith("Name: "))
+                    currentName = trimmed["Name: ".Length..];
+                else if (trimmed.StartsWith("Description: "))
+                    currentDesc = trimmed["Description: ".Length..];
+
+                if (currentName != null && currentDesc != null)
+                {
+                    devices.Add(new AudioDevice(idx++, currentDesc, currentName));
+                    currentName = null;
+                    currentDesc = null;
+                }
+            }
+
+            return devices;
+        }
+        catch { return new List<AudioDevice>(); }
+    }
+
+    public void Dispose() => Stop();
+}
+
+#if WINDOWS
+
+public class WindowsAudioService : IAudioService
+{
+    private NAudio.Wave.WaveOut? _waveOut;
+
+    public List<AudioDevice> GetOutputDevices()
+    {
+        var list = new List<AudioDevice>();
+        for (int i = 0; i < NAudio.Wave.WaveOut.DeviceCount; i++)
+        {
+            var caps = NAudio.Wave.WaveOut.GetCapabilities(i);
+            list.Add(new AudioDevice(i, caps.ProductName));
+        }
+        return list;
+    }
+
+    public List<AudioDevice> GetInputDevices()
+    {
+        var list = new List<AudioDevice>();
+        for (int i = 0; i < NAudio.Wave.WaveIn.DeviceCount; i++)
+        {
+            var caps = NAudio.Wave.WaveIn.GetCapabilities(i);
+            list.Add(new AudioDevice(i, caps.ProductName));
+        }
+        return list;
+    }
+
+    public void Play(string filePath, int deviceIndex, float volume = 1.0f, int startSampleBytes = 0, int endSampleBytes = 0, string pactlId = "")
+    {
+        Stop();
+
+        NAudio.Wave.WaveStream reader;
+        if (Math.Abs(volume - 1.0f) < 0.001f && startSampleBytes <= 0 && endSampleBytes <= 0)
+        {
+            reader = new NAudio.Wave.AudioFileReader(filePath);
+        }
+        else
+        {
+            var wave = new NAudio.Wave.WaveChannel32(new NAudio.Wave.WaveFileReader(filePath));
+            if (Math.Abs(volume - 1.0f) >= 0.001f)
+                wave.Volume = (float)Math.Pow(volume, 2);
+            if (endSampleBytes > 0 && endSampleBytes > startSampleBytes)
+            {
+                int byteCount = (endSampleBytes - startSampleBytes) * 4;
+                var buf = new byte[byteCount];
+                wave.Position = startSampleBytes * 4;
+                wave.Read(buf, 0, byteCount);
+                wave = new NAudio.Wave.WaveChannel32(
+                    new NAudio.Wave.RawSourceWaveStream(new MemoryStream(buf), wave.WaveFormat));
+            }
+            wave.PadWithZeroes = false;
+            reader = wave;
+        }
+
+        _waveOut = new NAudio.Wave.WaveOut { DeviceNumber = deviceIndex };
+        _waveOut.Init(reader);
+        _waveOut.Play();
+    }
+
+    public void Stop()
+    {
+        _waveOut?.Stop();
+        _waveOut?.Dispose();
+        _waveOut = null;
+    }
+
+    public void Dispose() => Stop();
+}
+
+#endif
+
+public static class AudioServiceFactory
+{
+    public static IAudioService Create()
+    {
+#if WINDOWS
+        return new WindowsAudioService();
+#else
+        return new LinuxAudioService();
+#endif
+    }
+}

+ 131 - 0
Services/CfgService.cs

@@ -0,0 +1,131 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.InteropServices;
+using SlamCross.Models;
+
+namespace SlamCross.Services;
+
+public static class CfgService
+{
+    public static void WriteCfgFiles(SourceGame game, string steamAppsPath, AppSettings settings)
+    {
+        var sep = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '\\' : '/';
+        var gameDir = Path.Combine(steamAppsPath, game.Directory.Replace('\\', sep));
+        var cfgFolder = Path.Combine(gameDir, game.ToCfg.Replace('\\', sep));
+
+        if (!Directory.Exists(cfgFolder))
+            throw new Exception($"CS2 cfg folder not found: {cfgFolder}\nCheck your Steam/CS2 installation path.");
+
+        var lines = new List<string>();
+        lines.Add("alias slam_play \"slam_updatecfg;slam_play_on;\"");
+        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+        {
+            lines.Add("alias slam_play_on \"alias slam_play slam_play_off; slam_nonce; slam_updatecfg; voice_loopback 1; +voicerecord\"");
+            lines.Add("alias slam_play_off \"alias slam_play slam_play_on; -voicerecord; voice_loopback 0;\"");
+        }
+        else
+        {
+            var virtualMicOut = string.IsNullOrEmpty(settings.VirtualMicOutDeviceName) ? "CABLE Output (VB-Audio Virtual Cable)" : settings.VirtualMicOutDeviceName.Trim();
+            var regularMic = string.IsNullOrEmpty(settings.RegularMicDeviceName) ? "Microphone" : settings.RegularMicDeviceName.Trim();
+            lines.Add($"alias slam_play_on \"alias slam_play slam_play_off; slam_nonce; slam_updatecfg; voice_loopback 1; voice_device_override {virtualMicOut}; +voicerecord\"");
+            lines.Add($"alias slam_play_off \"alias slam_play slam_play_on; -voicerecord; voice_loopback 0; voice_device_override {regularMic};\"");
+        }
+        lines.Add("alias slam_updatecfg \"host_writeconfig\"");
+
+        // the relay bind flips between _a/_b on each play press, so the app can tell a real
+        // press apart from CS2 rewriting the vcfg on its own (round start, buy menu)
+        lines.Add("alias slam_nonce slam_nonce_a");
+        lines.Add("alias slam_nonce_a \"alias slam_nonce slam_nonce_b; slam_bind_a\"");
+        lines.Add("alias slam_nonce_b \"alias slam_nonce slam_nonce_a; slam_bind_b\"");
+        lines.Add("alias slam_bind_a \"\"");
+        lines.Add("alias slam_bind_b \"\"");
+        lines.Add("alias slam_none \"\"");
+        lines.Add($"bind {settings.RelayKey} slam_none");
+
+        if (settings.HoldToPlay)
+        {
+            lines.Add("alias +slam_hold_play slam_play_on");
+            lines.Add("alias -slam_hold_play slam_play_off");
+            lines.Add($"bind {settings.PlayKey} +slam_hold_play");
+        }
+        else
+        {
+            lines.Add($"bind {settings.PlayKey} slam_play");
+        }
+
+        lines.Add("alias slam_listtracks \"exec slam_tracklist.cfg\"");
+        lines.Add("alias slam_curtrack \"exec slam_curtrack.cfg\"");
+        lines.Add("alias slam_saycurtrack \"exec slam_saycurtrack.cfg\"");
+        lines.Add("alias slam_sayteamcurtrack \"exec slam_sayteamcurtrack.cfg\"");
+
+        for (int i = 0; i < game.Tracks.Count; i++)
+        {
+            var n = i + 1;
+            var track = game.Tracks[i];
+            lines.Add($"alias slam_bindcmd_{n}_a \"bind {settings.RelayKey} slam_r_{n}_a\"");
+            lines.Add($"alias slam_bindcmd_{n}_b \"bind {settings.RelayKey} slam_r_{n}_b\"");
+            lines.Add($"alias slam_r_{n}_a \"\"");
+            lines.Add($"alias slam_r_{n}_b \"\"");
+            var select = $"alias slam_bind_a slam_bindcmd_{n}_a; alias slam_bind_b slam_bindcmd_{n}_b";
+            lines.Add($"alias {n} \"{select}\"");
+            if (settings.WriteTags)
+                foreach (var tag in track.Tags)
+                    lines.Add($"alias {tag} \"{select}\"");
+            if (!string.IsNullOrEmpty(track.Hotkey))
+                lines.Add($"bind {track.Hotkey} \"{select}; slam_play_on;\"");
+        }
+
+        var footer = "voice_modenable 1; con_enable 1";
+        if (game.VoiceFadeOut) footer += "; voice_fadeouttime 0.0";
+        footer += "; host_writeconfig";
+        lines.Add(footer);
+
+        // CS2 limits exec files to 200 lines; chain into slam2, slam3, etc. if needed.
+        const int chunkSize = 199;
+        int fileIdx = 1;
+        for (int written = 0; written < lines.Count; written += chunkSize)
+        {
+            var fileName = fileIdx == 1 ? "slam.cfg" : $"slam{fileIdx}.cfg";
+            var chunk = lines.GetRange(written, Math.Min(chunkSize, lines.Count - written));
+            if (written + chunkSize < lines.Count)
+                chunk.Add($"exec slam{fileIdx + 1}");
+            File.WriteAllLines(Path.Combine(cfgFolder, fileName), chunk);
+            fileIdx++;
+        }
+
+        using var trackList = new StreamWriter(Path.Combine(cfgFolder, "slam_tracklist.cfg"));
+        trackList.WriteLine("echo \"You can select tracks by tag or number.\"");
+        trackList.WriteLine("echo \"--------------------Tracks--------------------\"");
+        for (int i = 0; i < game.Tracks.Count; i++)
+        {
+            var t = game.Tracks[i];
+            var tagStr = settings.WriteTags && t.Tags.Count > 0
+                ? $" ['{string.Join("', '", t.Tags)}']" : "";
+            trackList.WriteLine($"echo \"{i + 1}. {t.Name}{tagStr}\"");
+        }
+        trackList.WriteLine("echo \"----------------------------------------------\"");
+    }
+
+    public static void DeleteCfgFiles(SourceGame game, string steamAppsPath)
+    {
+        var sep = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '\\' : '/';
+        var cfgFolder = Path.Combine(steamAppsPath, game.Directory.Replace('\\', sep), game.ToCfg.Replace('\\', sep));
+        string[] files = ["slam.cfg", "slam_tracklist.cfg", "slam_relay.cfg",
+                          "slam_curtrack.cfg", "slam_saycurtrack.cfg", "slam_sayteamcurtrack.cfg"];
+        foreach (var f in files)
+        {
+            var path = Path.Combine(cfgFolder, f);
+            if (File.Exists(path)) try { File.Delete(path); } catch { }
+        }
+    }
+
+    public static void WriteTrackCfgs(SourceGame game, string steamAppsPath, Track track)
+    {
+        var sep = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '\\' : '/';
+        var cfgFolder = Path.Combine(steamAppsPath, game.Directory.Replace('\\', sep), game.ToCfg.Replace('\\', sep));
+        File.WriteAllText(Path.Combine(cfgFolder, "slam_curtrack.cfg"), $"echo \"[SLAM] Track name: {track.Name}\"\n");
+        File.WriteAllText(Path.Combine(cfgFolder, "slam_saycurtrack.cfg"), $"say \"[SLAM] Track name: {track.Name}\"\n");
+        File.WriteAllText(Path.Combine(cfgFolder, "slam_sayteamcurtrack.cfg"), $"say_team \"[SLAM] Track name: {track.Name}\"\n");
+    }
+}

+ 44 - 0
Services/ConversionService.cs

@@ -0,0 +1,44 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+
+namespace SlamCross.Services;
+
+public static class ConversionService
+{
+    private static string FfmpegPath => FindFfmpeg();
+
+    private static string FindFfmpeg()
+    {
+        string[] candidates = ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "ffmpeg"];
+        foreach (var c in candidates)
+            if (File.Exists(c)) return c;
+        return "ffmpeg";
+    }
+
+    public static void ConvertToWav(string inputFile, string outputFile, int sampleRate, int channels)
+    {
+        var psi = new ProcessStartInfo(FfmpegPath)
+        {
+            RedirectStandardError = true,
+            UseShellExecute = false,
+            CreateNoWindow = true
+        };
+        psi.ArgumentList.Add("-y");
+        psi.ArgumentList.Add("-i"); psi.ArgumentList.Add(inputFile);
+        psi.ArgumentList.Add("-f"); psi.ArgumentList.Add("wav");
+        psi.ArgumentList.Add("-flags"); psi.ArgumentList.Add("bitexact");
+        psi.ArgumentList.Add("-map_metadata"); psi.ArgumentList.Add("-1");
+        psi.ArgumentList.Add("-vn");
+        psi.ArgumentList.Add("-acodec"); psi.ArgumentList.Add("pcm_s16le");
+        psi.ArgumentList.Add("-ar"); psi.ArgumentList.Add(sampleRate.ToString());
+        psi.ArgumentList.Add("-ac"); psi.ArgumentList.Add(channels.ToString());
+        psi.ArgumentList.Add(outputFile);
+
+        using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg");
+        var err = proc.StandardError.ReadToEnd();
+        proc.WaitForExit();
+        if (proc.ExitCode != 0)
+            throw new Exception($"ffmpeg failed: {err}");
+    }
+}

+ 70 - 0
Services/GameLocatorService.cs

@@ -0,0 +1,70 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+
+namespace SlamCross.Services;
+
+public static class GameLocatorService
+{
+    public static string? FindSteamAppsPath(string exeName)
+    {
+        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+            return FindSteamAppsLinux(exeName);
+        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+            return FindSteamAppsWindows(exeName);
+        return null;
+    }
+
+    private static string? FindSteamAppsLinux(string exeName)
+    {
+        try
+        {
+            var proc = Process.GetProcessesByName(exeName).FirstOrDefault();
+            if (proc?.MainModule?.FileName is string exe)
+            {
+                var dir = Path.GetDirectoryName(exe);
+                while (dir != null)
+                {
+                    if (Path.GetFileName(dir) == "steamapps")
+                        return dir;
+                    dir = Path.GetDirectoryName(dir);
+                }
+            }
+        }
+        catch { }
+
+        var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+        string[] candidates =
+        [
+            Path.Combine(home, ".local/share/Steam/steamapps"),
+            Path.Combine(home, ".steam/steam/steamapps"),
+            "/usr/local/games/steamapps",
+        ];
+        return candidates.FirstOrDefault(Directory.Exists);
+    }
+
+    private static string? FindSteamAppsWindows(string exeName)
+    {
+        try
+        {
+            var proc = Process.GetProcessesByName(exeName).FirstOrDefault();
+            if (proc?.MainModule?.FileName is string exe)
+            {
+                var gameDir = "common\\" + GetGameFolderName(exeName) + "\\";
+                var idx = exe.IndexOf(gameDir, StringComparison.OrdinalIgnoreCase);
+                if (idx >= 0) return exe[..idx];
+            }
+        }
+        catch { }
+        return null;
+    }
+
+    private static string GetGameFolderName(string exeName) => exeName switch
+    {
+        "cs2" => "Counter-Strike Global Offensive",
+        _ => exeName
+    };
+
+}

+ 42 - 0
Services/SettingsService.cs

@@ -0,0 +1,42 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using SlamCross.Models;
+
+namespace SlamCross.Services;
+
+public class SettingsService
+{
+    private static readonly string SettingsPath = Path.Combine(
+        Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+        "SLAM", "settings.json");
+
+    public AppSettings Settings { get; private set; } = new();
+
+    public void Load()
+    {
+        try
+        {
+            if (File.Exists(SettingsPath))
+            {
+                var json = File.ReadAllText(SettingsPath);
+                Settings = JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
+            }
+        }
+        catch
+        {
+            Settings = new AppSettings();
+        }
+    }
+
+    public void Save()
+    {
+        try
+        {
+            Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
+            var json = JsonSerializer.Serialize(Settings, new JsonSerializerOptions { WriteIndented = true });
+            File.WriteAllText(SettingsPath, json);
+        }
+        catch { }
+    }
+}

+ 65 - 0
Services/TrackSettingsService.cs

@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.Serialization;
+using System.Xml;
+using SlamCross.Models;
+
+namespace SlamCross.Services;
+
+public static class TrackSettingsService
+{
+    public static void Save(SourceGame game)
+    {
+        var settingsFile = Path.Combine(game.LibraryName, "TrackSettings.xml");
+        var toSave = new List<Track>();
+
+        foreach (var t in game.Tracks)
+        {
+            if (!string.IsNullOrEmpty(t.Hotkey) || t.Volume != 100 || t.EndPos > 0)
+                toSave.Add(t);
+        }
+
+        if (toSave.Count > 0)
+        {
+            var serializer = new DataContractSerializer(typeof(List<Track>));
+            using var writer = new StreamWriter(settingsFile);
+            using var xml = new XmlTextWriter(writer);
+            serializer.WriteObject(xml, toSave);
+        }
+        else if (File.Exists(settingsFile))
+        {
+            File.Delete(settingsFile);
+        }
+    }
+
+    public static void Load(SourceGame game)
+    {
+        var settingsFile = Path.Combine(game.LibraryName, "TrackSettings.xml");
+        if (!File.Exists(settingsFile)) return;
+
+        List<Track> saved;
+        try
+        {
+            var serializer = new DataContractSerializer(typeof(List<Track>));
+            using var reader = new StreamReader(settingsFile);
+            using var xml = new XmlTextReader(reader);
+            saved = (List<Track>)serializer.ReadObject(xml)! ?? new List<Track>();
+        }
+        catch { return; }
+
+        foreach (var track in game.Tracks)
+        {
+            foreach (var s in saved)
+            {
+                if (track.Name == s.Name)
+                {
+                    track.Hotkey = s.Hotkey;
+                    track.Volume = s.Volume;
+                    track.StartPos = s.StartPos;
+                    track.EndPos = s.EndPos;
+                }
+            }
+        }
+    }
+}

+ 21 - 0
ViewLocator.cs

@@ -0,0 +1,21 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using SlamCross.ViewModels;
+
+namespace SlamCross;
+
+public class ViewLocator : IDataTemplate
+{
+    public Control? Build(object? param)
+    {
+        if (param is null) return null;
+        var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
+        var type = Type.GetType(name);
+        return type != null
+            ? (Control)Activator.CreateInstance(type)!
+            : new TextBlock { Text = "Not Found: " + name };
+    }
+
+    public bool Match(object? data) => data is ViewModelBase;
+}

+ 653 - 0
ViewModels/MainWindowViewModel.cs

@@ -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();
+}

+ 5 - 0
ViewModels/ViewModelBase.cs

@@ -0,0 +1,5 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace SlamCross.ViewModels;
+
+public abstract class ViewModelBase : ObservableObject { }

+ 13 - 0
Views/ConfirmWindow.axaml

@@ -0,0 +1,13 @@
+<Window xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        x:Class="SlamCross.Views.ConfirmWindow"
+        Title="Confirm" Width="360" Height="120"
+        CanResize="False" WindowStartupLocation="CenterOwner">
+    <StackPanel Margin="16" Spacing="12">
+        <TextBlock x:Name="MessageLabel" TextWrapping="Wrap"/>
+        <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
+            <Button Content="Yes" Click="Yes_Click"/>
+            <Button Content="No"  Click="No_Click"/>
+        </StackPanel>
+    </StackPanel>
+</Window>

+ 18 - 0
Views/ConfirmWindow.axaml.cs

@@ -0,0 +1,18 @@
+using Avalonia.Controls;
+
+namespace SlamCross.Views;
+
+public partial class ConfirmWindow : Window
+{
+    public bool Confirmed { get; private set; }
+
+    public ConfirmWindow(string message)
+    {
+        InitializeComponent();
+        if (this.FindControl<TextBlock>("MessageLabel") is TextBlock lbl)
+            lbl.Text = message;
+    }
+
+    private void Yes_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { Confirmed = true; Close(); }
+    private void No_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => Close();
+}

+ 111 - 0
Views/MainWindow.axaml

@@ -0,0 +1,111 @@
+<Window xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:vm="using:SlamCross.ViewModels"
+        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+        mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
+        x:Class="SlamCross.Views.MainWindow"
+        x:DataType="vm:MainWindowViewModel"
+        Title="SLAM - Source Live Audio Mixer"
+        Width="900" Height="600"
+        MinWidth="700" MinHeight="450">
+
+    <Design.DataContext>
+        <vm:MainWindowViewModel/>
+    </Design.DataContext>
+
+    <DockPanel Margin="8">
+
+        <StackPanel DockPanel.Dock="Top" Spacing="6" Margin="0,0,0,6">
+
+            <Grid ColumnDefinitions="Auto,Auto,Auto,*,Auto">
+                <Button Grid.Column="0" Content="Import Files" Click="ImportButton_Click"
+                        IsEnabled="{Binding InterfaceEnabled}" Margin="0,0,4,0"/>
+                <Button Grid.Column="1" Content="Import from YouTube" Click="YTButton_Click"
+                        IsEnabled="{Binding InterfaceEnabled}" Margin="0,0,4,0"/>
+                <Button Grid.Column="2" Content="{Binding StartButtonText}"
+                        Click="StartStopButton_Click" Margin="0,0,8,0"/>
+                <TextBlock Grid.Column="3" Text="{Binding StatusText}"
+                           VerticalAlignment="Center"/>
+                <Button Grid.Column="4" Content="Settings" Click="SettingsButton_Click"
+                        IsEnabled="{Binding InterfaceEnabled}"/>
+            </Grid>
+
+            <ProgressBar Value="{Binding Progress}" Maximum="{Binding ProgressMax}" Height="6"/>
+
+            <Grid ColumnDefinitions="Auto,8,Auto,8,Auto,*">
+                <Button Grid.Column="0" Content="{Binding PlayKeyButtonText}" Click="PlayKeyButton_Click"
+                        IsEnabled="{Binding InterfaceEnabled}"/>
+                <Button Grid.Column="2" Content="{Binding StreamButtonText}" Click="StreamButton_Click"
+                        Background="#2a7a2a"/>
+                <Button Grid.Column="4" Content="Stop Stream" Click="StopStreamButton_Click"
+                        IsEnabled="{Binding IsStreaming}" Background="#7a2a2a"/>
+                <TextBlock Grid.Column="5" Text="{Binding StreamStatusText}"
+                           VerticalAlignment="Center" Margin="8,0,0,0" FontStyle="Italic"/>
+            </Grid>
+
+            <Grid ColumnDefinitions="Auto,8,*,8,40">
+                <TextBlock Grid.Column="0" Text="Volume:" VerticalAlignment="Center"/>
+                <Slider Grid.Column="2" Minimum="0" Maximum="100"
+                        Value="{Binding GlobalVolume}"
+                        VerticalAlignment="Center"/>
+                <TextBlock Grid.Column="4" Text="{Binding GlobalVolume, StringFormat='{}{0}%'}"
+                           VerticalAlignment="Center" HorizontalAlignment="Right"/>
+            </Grid>
+
+            <Grid ColumnDefinitions="Auto,*,8,Auto,*,8,Auto,*" RowDefinitions="Auto">
+                <TextBlock Grid.Column="0" Text="Virtual Cable In:" VerticalAlignment="Center" Margin="0,0,4,0"/>
+                <ComboBox Grid.Column="1"
+                          ItemsSource="{Binding OutputDevices}"
+                          SelectedItem="{Binding SelectedVirtualInCable}"
+                          DisplayMemberBinding="{Binding Name}"/>
+
+                <TextBlock Grid.Column="3" Text="Cable Out (mic):" VerticalAlignment="Center" Margin="0,0,4,0"/>
+                <ComboBox Grid.Column="4"
+                          ItemsSource="{Binding InputDevices}"
+                          SelectedItem="{Binding SelectedVirtualOutCable}"
+                          DisplayMemberBinding="{Binding Name}"/>
+
+                <TextBlock Grid.Column="6" Text="Regular Mic:" VerticalAlignment="Center" Margin="0,0,4,0"/>
+                <ComboBox Grid.Column="7"
+                          ItemsSource="{Binding InputDevices}"
+                          SelectedItem="{Binding SelectedRegularMic}"
+                          DisplayMemberBinding="{Binding Name}"/>
+            </Grid>
+        </StackPanel>
+
+        <DataGrid x:Name="TrackGrid"
+                  ItemsSource="{Binding Tracks}"
+                  SelectionMode="Extended"
+                  CanUserSortColumns="True"
+                  CanUserResizeColumns="True"
+                  IsReadOnly="True"
+                  DoubleTapped="TrackGrid_DoubleTapped">
+            <DataGrid.Columns>
+                <DataGridTextColumn Header="Playing" Binding="{Binding IsPlaying}" Width="60"/>
+                <DataGridTextColumn Header="Name"    Binding="{Binding Name}"      Width="*"/>
+                <DataGridTextColumn Header="Hotkey"  Binding="{Binding Hotkey}"    Width="70"/>
+                <DataGridTextColumn Header="Volume"  Binding="{Binding Volume}"    Width="65"/>
+                <DataGridTextColumn Header="Trimmed" Binding="{Binding Trimmed}"   Width="65"/>
+                <DataGridTextColumn Header="Tags"    Binding="{Binding Tags}"      Width="200"/>
+            </DataGrid.Columns>
+            <DataGrid.ContextMenu>
+                <ContextMenu x:Name="TrackContextMenu">
+                    <MenuItem Header="Play"          Click="MenuPlay_Click"/>
+                    <MenuItem Header="Refresh"       Click="MenuRefresh_Click"/>
+                    <Separator/>
+                    <MenuItem Header="Set Volume"    Click="MenuVolume_Click"/>
+                    <MenuItem Header="Set Hotkey"    Click="MenuHotkey_Click"/>
+                    <MenuItem Header="Remove Hotkey" Click="MenuRemoveHotkey_Click"/>
+                    <MenuItem Header="Trim"          Click="MenuTrim_Click"/>
+                    <MenuItem Header="Rename"        Click="MenuRename_Click"/>
+                    <Separator/>
+                    <MenuItem Header="Show in Files" Click="MenuGoTo_Click"/>
+                    <Separator/>
+                    <MenuItem Header="Delete"        Click="MenuDelete_Click"/>
+                </ContextMenu>
+            </DataGrid.ContextMenu>
+        </DataGrid>
+
+    </DockPanel>
+</Window>

+ 245 - 0
Views/MainWindow.axaml.cs

@@ -0,0 +1,245 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Platform.Storage;
+using SlamCross.ViewModels;
+
+namespace SlamCross.Views;
+
+public partial class MainWindow : Window
+{
+    private MainWindowViewModel Vm => (MainWindowViewModel)DataContext!;
+
+    public MainWindow()
+    {
+        InitializeComponent();
+        DataContext = new MainWindowViewModel();
+        Vm.ErrorOccurred += msg => Avalonia.Threading.Dispatcher.UIThread.Post(() => ShowMessage(msg));
+        Closing += (_, e) => { Vm.StopPoll(); Vm.StopStream(); Vm.SaveSettings(); };
+        if (Vm.Settings.StartMinimized)
+            WindowState = WindowState.Minimized;
+    }
+
+    private async void ShowMessage(string msg)
+    {
+        var ok = new Button { Content = "OK", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right };
+        var dlg = new Window
+        {
+            Title = "SLAM",
+            Width = 400,
+            Height = 150,
+            Content = new StackPanel
+            {
+                Margin = new Avalonia.Thickness(16),
+                Spacing = 12,
+                Children =
+                {
+                    new TextBlock { Text = msg, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
+                    ok
+                }
+            }
+        };
+        ok.Click += (_, _) => dlg.Close();
+        await dlg.ShowDialog(this);
+    }
+
+    private async void ImportButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+        {
+            Title = "Import Audio Files",
+            AllowMultiple = true,
+            FileTypeFilter = new[]
+            {
+                new FilePickerFileType("Audio/Video")
+                {
+                    Patterns = new[] { "*.mp3","*.wav","*.aac","*.wma","*.m4a","*.mp4","*.wmv","*.avi","*.m4v","*.mov","*.flac","*.ogg" }
+                }
+            }
+        });
+        if (files.Count > 0)
+            await Vm.ImportFilesAsync(files.Select(f => f.Path.LocalPath));
+    }
+
+    private async void YTButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        var dlg = new YTImportWindow();
+        await dlg.ShowDialog(this);
+        if (dlg.ResultUrl is string url)
+            await DownloadAndImportYt(url);
+    }
+
+    private async Task DownloadAndImportYt(string url)
+    {
+        var tmpDir = Path.Combine(Path.GetTempPath(), "slam_yt");
+        Directory.CreateDirectory(tmpDir);
+        var outTemplate = Path.Combine(tmpDir, "%(title)s.%(ext)s");
+
+        var psi = new ProcessStartInfo("yt-dlp")
+        {
+            UseShellExecute = false,
+            CreateNoWindow = true
+        };
+        psi.ArgumentList.Add("-x");
+        psi.ArgumentList.Add("--audio-format"); psi.ArgumentList.Add("mp3");
+        psi.ArgumentList.Add("-o"); psi.ArgumentList.Add(outTemplate);
+        psi.ArgumentList.Add(url);
+        try
+        {
+            using var proc = Process.Start(psi);
+            if (proc != null) await proc.WaitForExitAsync();
+        }
+        catch
+        {
+            Vm.ShowError("yt-dlp not found. Install it to import from YouTube.");
+            return;
+        }
+
+        var downloaded = Directory.GetFiles(tmpDir);
+        if (downloaded.Length > 0)
+            await Vm.ImportFilesAsync(downloaded, deleteSource: true);
+
+        try { Directory.Delete(tmpDir, true); } catch { }
+    }
+
+    private async void SettingsButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        var dlg = new SettingsWindow(Vm.Settings);
+        await dlg.ShowDialog(this);
+        Vm.SaveSettings();
+    }
+
+    private async void PlayKeyButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        var dlg = new SelectKeyWindow();
+        await dlg.ShowDialog(this);
+        if (dlg.ChosenKey is string key)
+            Vm.SetPlayKey(key);
+    }
+
+    private void StartStopButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        if (Vm.IsRunning) Vm.StopPoll();
+        else _ = Vm.StartPollAsync();
+    }
+
+    private void StreamButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        if (Vm.IsStreaming) { Vm.StopStream(); return; }
+        var idx = GetSelectedIndices().FirstOrDefault(-1);
+        if (idx < 0) { Vm.ShowError("Select a track first."); return; }
+        Vm.StreamToMic(idx);
+    }
+
+    private void StopStreamButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+        => Vm.StopStream();
+
+    private void TrackGrid_DoubleTapped(object? sender, TappedEventArgs e)
+    {
+        var selected = GetSelectedIndices().FirstOrDefault(-1);
+        if (selected < 0) return;
+        if (Vm.IsRunning)
+            Vm.PlayTrack(selected);
+        else
+            Vm.StreamToMic(selected);
+    }
+
+    private void MenuPlay_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        var idx = GetSelectedIndices().FirstOrDefault(-1);
+        if (idx < 0) return;
+        if (Vm.IsRunning) Vm.PlayTrack(idx);
+        else Vm.StreamToMic(idx);
+    }
+
+    private void MenuRefresh_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+        => Vm.RefreshTracks();
+
+    private async void MenuHotkey_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        var idx = GetSelectedIndices().FirstOrDefault(-1);
+        if (idx < 0) return;
+        var dlg = new SelectKeyWindow();
+        await dlg.ShowDialog(this);
+        if (dlg.ChosenKey is string key)
+            Vm.SetTrackHotkey(idx, key);
+    }
+
+    private void MenuRemoveHotkey_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        foreach (var idx in GetSelectedIndices())
+            Vm.RemoveHotkey(idx);
+    }
+
+    private async void MenuVolume_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        var indices = GetSelectedIndices().ToList();
+        if (indices.Count == 0) return;
+        var dlg = new SetVolumeWindow();
+        await dlg.ShowDialog(this);
+        if (dlg.Volume >= 0)
+            Vm.SetTrackVolume(indices, dlg.Volume);
+    }
+
+    private async void MenuTrim_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        var idx = GetSelectedIndices().FirstOrDefault(-1);
+        if (idx < 0) return;
+        var track = Vm.GetTrack(idx);
+        if (track == null) return;
+        var filePath = Vm.GetTrackFilePath(idx);
+        var dlg = new TrimWindow(filePath, track.StartPos, track.EndPos);
+        await dlg.ShowDialog(this);
+        if (dlg.Confirmed)
+            Vm.SetTrackTrim(idx, dlg.StartPos, dlg.EndPos);
+    }
+
+    private async void MenuRename_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        var idx = GetSelectedIndices().FirstOrDefault(-1);
+        if (idx < 0) return;
+        var track = Vm.GetTrack(idx);
+        if (track == null) return;
+        var dlg = new RenameWindow(track.Name);
+        await dlg.ShowDialog(this);
+        if (dlg.NewName is string name)
+            Vm.RenameTrack(idx, name);
+    }
+
+    private void MenuGoTo_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        var idx = GetSelectedIndices().FirstOrDefault(-1);
+        if (idx < 0) return;
+        var path = Vm.GetTrackFilePath(idx);
+        if (!File.Exists(path)) return;
+        var dir = Path.GetDirectoryName(path)!;
+        if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(
+            System.Runtime.InteropServices.OSPlatform.Linux))
+            Process.Start("xdg-open", dir);
+        else
+            Process.Start("explorer.exe", $"/select,\"{path}\"");
+    }
+
+    private async void MenuDelete_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        var indices = GetSelectedIndices().ToList();
+        if (indices.Count == 0) return;
+        var names = indices.Select(i => Vm.Tracks[i].Name);
+        var confirm = new ConfirmWindow($"Delete {string.Join(", ", names)}?");
+        await confirm.ShowDialog(this);
+        if (confirm.Confirmed)
+            Vm.DeleteTracks(indices);
+    }
+
+    private IEnumerable<int> GetSelectedIndices()
+    {
+        var grid = this.FindControl<DataGrid>("TrackGrid");
+        if (grid == null) yield break;
+        foreach (var item in grid.SelectedItems.OfType<TrackItem>())
+            yield return item.Index;
+    }
+}

+ 13 - 0
Views/RenameWindow.axaml

@@ -0,0 +1,13 @@
+<Window xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        x:Class="SlamCross.Views.RenameWindow"
+        Title="Rename Track" Width="320" Height="120"
+        CanResize="False" WindowStartupLocation="CenterOwner">
+    <StackPanel Margin="16" Spacing="10">
+        <TextBox x:Name="NameBox"/>
+        <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
+            <Button Content="OK"     Click="OK_Click"/>
+            <Button Content="Cancel" Click="Cancel_Click"/>
+        </StackPanel>
+    </StackPanel>
+</Window>

+ 24 - 0
Views/RenameWindow.axaml.cs

@@ -0,0 +1,24 @@
+using Avalonia.Controls;
+
+namespace SlamCross.Views;
+
+public partial class RenameWindow : Window
+{
+    public string? NewName { get; private set; }
+
+    public RenameWindow(string currentName)
+    {
+        InitializeComponent();
+        if (this.FindControl<TextBox>("NameBox") is TextBox box)
+            box.Text = currentName;
+    }
+
+    private void OK_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        if (this.FindControl<TextBox>("NameBox") is TextBox box && !string.IsNullOrWhiteSpace(box.Text))
+            NewName = box.Text.Trim();
+        Close();
+    }
+
+    private void Cancel_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => Close();
+}

+ 14 - 0
Views/SelectKeyWindow.axaml

@@ -0,0 +1,14 @@
+<Window xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        x:Class="SlamCross.Views.SelectKeyWindow"
+        Title="Select Key" Width="300" Height="140"
+        CanResize="False" WindowStartupLocation="CenterOwner">
+    <StackPanel Margin="16" Spacing="10">
+        <TextBlock Text="Press any key or type a bind name, then click OK."/>
+        <TextBox x:Name="KeyBox" KeyDown="KeyBox_KeyDown" PlaceholderText="e.g. MOUSE4, F5, KP_INS"/>
+        <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
+            <Button Content="OK"     Click="OK_Click"/>
+            <Button Content="Cancel" Click="Cancel_Click"/>
+        </StackPanel>
+    </StackPanel>
+</Window>

+ 56 - 0
Views/SelectKeyWindow.axaml.cs

@@ -0,0 +1,56 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+
+namespace SlamCross.Views;
+
+public partial class SelectKeyWindow : Window
+{
+    public string? ChosenKey { get; private set; }
+
+    public SelectKeyWindow() => InitializeComponent();
+
+    private void KeyBox_KeyDown(object? sender, KeyEventArgs e)
+    {
+        ChosenKey = MapKey(e.Key);
+        if (this.FindControl<TextBox>("KeyBox") is TextBox box)
+            box.Text = ChosenKey;
+        e.Handled = true;
+    }
+
+    private void OK_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        if (this.FindControl<TextBox>("KeyBox") is TextBox box && !string.IsNullOrWhiteSpace(box.Text))
+            ChosenKey = box.Text.Trim().ToUpper();
+        Close();
+    }
+
+    private void Cancel_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        ChosenKey = null;
+        Close();
+    }
+
+    private static string MapKey(Key key) => key switch
+    {
+        >= Key.D0 and <= Key.D9 => ((char)('0' + (key - Key.D0))).ToString(),
+        Key.F1 => "F1", Key.F2 => "F2", Key.F3 => "F3", Key.F4 => "F4",
+        Key.F5 => "F5", Key.F6 => "F6", Key.F7 => "F7", Key.F8 => "F8",
+        Key.F9 => "F9", Key.F10 => "F10", Key.F11 => "F11", Key.F12 => "F12",
+        Key.Insert => "INS", Key.Delete => "DEL",
+        Key.Home => "HOME", Key.End => "END",
+        Key.PageUp => "PGUP", Key.PageDown => "PGDN",
+        Key.NumPad0 => "KP_INS", Key.NumPad1 => "KP_END",
+        Key.NumPad2 => "KP_DOWNARROW", Key.NumPad3 => "KP_PGDN",
+        Key.NumPad4 => "KP_LEFTARROW", Key.NumPad5 => "KP_5",
+        Key.NumPad6 => "KP_RIGHTARROW", Key.NumPad7 => "KP_HOME",
+        Key.NumPad8 => "KP_UPARROW", Key.NumPad9 => "KP_PGUP",
+        Key.Multiply => "KP_MULTIPLY", Key.Add => "KP_PLUS",
+        Key.Subtract => "KP_MINUS", Key.Divide => "KP_SLASH",
+        Key.OemMinus => "MINUS", Key.OemPlus => "EQUALS",
+        Key.OemOpenBrackets => "LBRACKET", Key.OemCloseBrackets => "RBRACKET",
+        Key.OemSemicolon => "SEMICOLON", Key.OemQuotes => "APOSTROPHE",
+        Key.OemBackslash => "BACKSLASH", Key.OemComma => "COMMA",
+        Key.OemPeriod => "PERIOD",
+        _ => key.ToString().ToUpper()
+    };
+}

+ 19 - 0
Views/SetVolumeWindow.axaml

@@ -0,0 +1,19 @@
+<Window xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        x:Class="SlamCross.Views.SetVolumeWindow"
+        Title="Set Volume" Width="300" Height="140"
+        CanResize="False" WindowStartupLocation="CenterOwner">
+    <StackPanel Margin="16" Spacing="10">
+        <TextBlock Text="Set volume (1-100):"/>
+        <Grid ColumnDefinitions="*,8,60">
+            <Slider x:Name="VolumeSlider" Minimum="1" Maximum="100" Value="100"
+                    ValueChanged="Slider_Changed"/>
+            <TextBox Grid.Column="2" x:Name="VolumeBox" Text="100"
+                     TextChanged="Box_Changed"/>
+        </Grid>
+        <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
+            <Button Content="OK"     Click="OK_Click"/>
+            <Button Content="Cancel" Click="Cancel_Click"/>
+        </StackPanel>
+    </StackPanel>
+</Window>

+ 43 - 0
Views/SetVolumeWindow.axaml.cs

@@ -0,0 +1,43 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+
+namespace SlamCross.Views;
+
+public partial class SetVolumeWindow : Window
+{
+    public int Volume { get; private set; } = -1;
+    private bool _updating;
+
+    public SetVolumeWindow() => InitializeComponent();
+
+    private void Slider_Changed(object? sender, RangeBaseValueChangedEventArgs e)
+    {
+        if (_updating) return;
+        _updating = true;
+        if (this.FindControl<TextBox>("VolumeBox") is TextBox box)
+            box.Text = ((int)e.NewValue).ToString();
+        _updating = false;
+    }
+
+    private void Box_Changed(object? sender, TextChangedEventArgs e)
+    {
+        if (_updating) return;
+        if (this.FindControl<TextBox>("VolumeBox") is not TextBox box) return;
+        if (int.TryParse(box.Text, out int v) && v is >= 1 and <= 100)
+        {
+            _updating = true;
+            if (this.FindControl<Slider>("VolumeSlider") is Slider s) s.Value = v;
+            _updating = false;
+        }
+    }
+
+    private void OK_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        if (this.FindControl<Slider>("VolumeSlider") is Slider s)
+            Volume = (int)s.Value;
+        Close();
+    }
+
+    private void Cancel_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+        => Close();
+}

+ 38 - 0
Views/SettingsWindow.axaml

@@ -0,0 +1,38 @@
+<Window xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        x:Class="SlamCross.Views.SettingsWindow"
+        Title="Settings" Width="480" Height="400"
+        WindowStartupLocation="CenterOwner">
+    <ScrollViewer>
+        <StackPanel Margin="16" Spacing="10">
+            <TextBlock Text="Settings" FontSize="18" FontWeight="Bold"/>
+
+            <CheckBox x:Name="StartEnabled" Content="Start polling on launch"/>
+            <CheckBox x:Name="StartMinimized" Content="Start minimized"/>
+            <CheckBox x:Name="HoldToPlay" Content="Hold key to play (instead of toggle)"/>
+            <CheckBox x:Name="LogError" Content="Log errors to errorlog.txt"/>
+            <CheckBox x:Name="WriteTags" Content="Show tags in track list"/>
+
+            <Separator/>
+            <TextBlock Text="Relay key (the key CS2 writes the command to):"/>
+            <TextBox x:Name="RelayKeyBox" MaxLength="20" PlaceholderText="default: ="/>
+
+            <Separator/>
+            <CheckBox x:Name="OverrideFolders" Content="Override Steam folder detection"
+                      IsCheckedChanged="OverrideChanged"/>
+            <Grid x:Name="OverridePanel" ColumnDefinitions="Auto,*" RowDefinitions="Auto,8,Auto"
+                  IsVisible="False">
+                <TextBlock Grid.Row="0" Grid.Column="0" Text="steamapps path:" VerticalAlignment="Center" Margin="0,0,8,0"/>
+                <TextBox    Grid.Row="0" Grid.Column="1" x:Name="SteamAppsBox"/>
+                <TextBlock Grid.Row="2" Grid.Column="0" Text="userdata path:"  VerticalAlignment="Center" Margin="0,0,8,0"/>
+                <TextBox    Grid.Row="2" Grid.Column="1" x:Name="UserDataBox"/>
+            </Grid>
+
+            <Separator/>
+            <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
+                <Button Content="Save" Click="Save_Click"/>
+                <Button Content="Close" Click="Close_Click"/>
+            </StackPanel>
+        </StackPanel>
+    </ScrollViewer>
+</Window>

+ 64 - 0
Views/SettingsWindow.axaml.cs

@@ -0,0 +1,64 @@
+using Avalonia.Controls;
+using SlamCross.Models;
+
+namespace SlamCross.Views;
+
+public partial class SettingsWindow : Window
+{
+    private readonly AppSettings _settings;
+
+    public SettingsWindow(AppSettings settings)
+    {
+        _settings = settings;
+        InitializeComponent();
+        LoadSettings();
+    }
+
+    private void LoadSettings()
+    {
+        Set<CheckBox>("StartEnabled",  b => b.IsChecked = _settings.StartEnabled);
+        Set<CheckBox>("StartMinimized",b => b.IsChecked = _settings.StartMinimized);
+        Set<CheckBox>("HoldToPlay",    b => b.IsChecked = _settings.HoldToPlay);
+        Set<CheckBox>("LogError",      b => b.IsChecked = _settings.LogError);
+        Set<CheckBox>("WriteTags",     b => b.IsChecked = _settings.WriteTags);
+        Set<CheckBox>("OverrideFolders",b => b.IsChecked = _settings.OverrideFolders);
+        Set<TextBox>("RelayKeyBox",    b => b.Text = _settings.RelayKey);
+        Set<TextBox>("SteamAppsBox",   b => b.Text = _settings.SteamAppsOverride);
+        Set<TextBox>("UserDataBox",    b => b.Text = _settings.UserDataOverride);
+        UpdateOverrideVisibility();
+    }
+
+    private void OverrideChanged(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+        => UpdateOverrideVisibility();
+
+    private void UpdateOverrideVisibility()
+    {
+        var visible = this.FindControl<CheckBox>("OverrideFolders")?.IsChecked == true;
+        if (this.FindControl<Grid>("OverridePanel") is Grid panel)
+            panel.IsVisible = visible;
+    }
+
+    private void Save_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        _settings.StartEnabled   = Get<CheckBox>("StartEnabled")?.IsChecked == true;
+        _settings.StartMinimized = Get<CheckBox>("StartMinimized")?.IsChecked == true;
+        _settings.HoldToPlay     = Get<CheckBox>("HoldToPlay")?.IsChecked == true;
+        _settings.LogError       = Get<CheckBox>("LogError")?.IsChecked == true;
+        _settings.WriteTags      = Get<CheckBox>("WriteTags")?.IsChecked == true;
+        _settings.OverrideFolders= Get<CheckBox>("OverrideFolders")?.IsChecked == true;
+        var relay = Get<TextBox>("RelayKeyBox")?.Text?.Trim();
+        if (!string.IsNullOrEmpty(relay) && !relay.Equals(_settings.PlayKey, System.StringComparison.OrdinalIgnoreCase))
+            _settings.RelayKey = relay;
+        _settings.SteamAppsOverride = Get<TextBox>("SteamAppsBox")?.Text?.Trim() ?? "";
+        _settings.UserDataOverride  = Get<TextBox>("UserDataBox")?.Text?.Trim() ?? "";
+        Close();
+    }
+
+    private void Close_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => Close();
+
+    private T? Get<T>(string name) where T : Control => this.FindControl<T>(name);
+    private void Set<T>(string name, System.Action<T> action) where T : Control
+    {
+        if (this.FindControl<T>(name) is T ctrl) action(ctrl);
+    }
+}

+ 35 - 0
Views/TrimWindow.axaml

@@ -0,0 +1,35 @@
+<Window xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:controls="using:SlamCross.Controls"
+        x:Class="SlamCross.Views.TrimWindow"
+        Title="Trim Track" Width="720" Height="440"
+        CanResize="True" WindowStartupLocation="CenterOwner">
+    <DockPanel Margin="8" LastChildFill="True">
+
+        <TextBlock DockPanel.Dock="Top" x:Name="DurationLabel"
+                   Text="Loading waveform..." Margin="0,0,0,6"
+                   Foreground="#aaa" FontSize="12"/>
+
+        <StackPanel DockPanel.Dock="Bottom" Spacing="8" Margin="0,8,0,0">
+            <Grid ColumnDefinitions="Auto,8,110,16,Auto,8,110">
+                <TextBlock Grid.Column="0" Text="Start (s):" VerticalAlignment="Center"/>
+                <NumericUpDown Grid.Column="2" x:Name="StartSec"
+                               Minimum="0" Increment="0.1" FormatString="F2"
+                               ValueChanged="Time_Changed"/>
+                <TextBlock Grid.Column="4" Text="End (s):" VerticalAlignment="Center"/>
+                <NumericUpDown Grid.Column="6" x:Name="EndSec"
+                               Minimum="0" Increment="0.1" FormatString="F2"
+                               ValueChanged="Time_Changed"/>
+            </Grid>
+            <StackPanel Orientation="Horizontal" Spacing="8">
+                <Button x:Name="PlayButton" Content="▶ Preview" Click="Play_Click"/>
+                <Button Content="Reset" Click="Reset_Click"/>
+                <Button Content="OK" Click="OK_Click" IsDefault="True"/>
+                <Button Content="Cancel" Click="Cancel_Click" IsCancel="True"/>
+            </StackPanel>
+        </StackPanel>
+
+        <controls:WaveformControl x:Name="Waveform" MinHeight="160"/>
+
+    </DockPanel>
+</Window>

+ 262 - 0
Views/TrimWindow.axaml.cs

@@ -0,0 +1,262 @@
+using System;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using SlamCross.Controls;
+
+namespace SlamCross.Views;
+
+public partial class TrimWindow : Window
+{
+    // Trim byte positions are offsets into s16le @ 22050 Hz mono (matches AudioService)
+    private const int TrimSampleRate = 22050;
+    private const int TrimBytesPerSample = 2;
+
+    private double _totalDuration;
+    private Process? _previewFf;
+    private Process? _previewPa;
+    private bool _isPreviewing;
+    private bool _suppressChange;
+    private string _filePath = "";
+
+    public bool Confirmed { get; private set; }
+    public int StartPos { get; private set; }
+    public int EndPos { get; private set; }
+
+    public TrimWindow()
+    {
+        InitializeComponent();
+    }
+
+    public TrimWindow(string filePath, int startPos, int endPos)
+    {
+        InitializeComponent();
+        _filePath = filePath;
+        StartPos = startPos;
+        EndPos = endPos;
+        _ = LoadAsync(filePath, startPos, endPos);
+    }
+
+    private static double BytesToSec(int b) => b / (TrimSampleRate * (double)TrimBytesPerSample);
+    private static int SecToBytes(double sec) => (int)(sec * TrimSampleRate * TrimBytesPerSample);
+
+    private async Task LoadAsync(string filePath, int startPos, int endPos)
+    {
+        _totalDuration = await GetDurationAsync(filePath);
+
+        double startSec = BytesToSec(startPos);
+        double endSec = endPos > 0 ? BytesToSec(endPos) : _totalDuration;
+
+        if (this.FindControl<TextBlock>("DurationLabel") is TextBlock lbl)
+            lbl.Text = $"Duration: {_totalDuration:F2}s  |  Drag to select trim region";
+
+        _suppressChange = true;
+        if (this.FindControl<NumericUpDown>("StartSec") is NumericUpDown sb)
+        {
+            sb.Maximum = (decimal)_totalDuration;
+            sb.Value = (decimal)startSec;
+        }
+        if (this.FindControl<NumericUpDown>("EndSec") is NumericUpDown eb)
+        {
+            eb.Maximum = (decimal)_totalDuration;
+            eb.Value = (decimal)endSec;
+        }
+        _suppressChange = false;
+
+        var peaks = await Task.Run(() => ComputePeaks(filePath, 4096));
+
+        var wf = this.FindControl<WaveformControl>("Waveform");
+        if (wf == null) return;
+
+        wf.SetPeaks(peaks);
+        wf.SetSelection(
+            _totalDuration > 0 ? startSec / _totalDuration : 0,
+            _totalDuration > 0 ? endSec / _totalDuration : 1);
+
+        wf.SelectionChanged += (startNorm, endNorm) =>
+        {
+            double s = startNorm * _totalDuration;
+            double e = endNorm * _totalDuration;
+            _suppressChange = true;
+            if (this.FindControl<NumericUpDown>("StartSec") is NumericUpDown nsb) nsb.Value = (decimal)s;
+            if (this.FindControl<NumericUpDown>("EndSec") is NumericUpDown neb) neb.Value = (decimal)e;
+            _suppressChange = false;
+        };
+    }
+
+    private static async Task<double> GetDurationAsync(string filePath)
+    {
+        try
+        {
+            var psi = new ProcessStartInfo("ffprobe") { UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true };
+            psi.ArgumentList.Add("-v"); psi.ArgumentList.Add("quiet");
+            psi.ArgumentList.Add("-show_entries"); psi.ArgumentList.Add("format=duration");
+            psi.ArgumentList.Add("-of"); psi.ArgumentList.Add("csv=p=0");
+            psi.ArgumentList.Add(filePath);
+            using var proc = Process.Start(psi)!;
+            var s = await proc.StandardOutput.ReadToEndAsync();
+            await proc.WaitForExitAsync();
+            return double.TryParse(s.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0;
+        }
+        catch { return 0; }
+    }
+
+    private static (float Low, float High)[] ComputePeaks(string filePath, int binCount)
+    {
+        try
+        {
+            var psi = new ProcessStartInfo("ffmpeg") { UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true };
+            psi.ArgumentList.Add("-i"); psi.ArgumentList.Add(filePath);
+            psi.ArgumentList.Add("-f"); psi.ArgumentList.Add("f32le");
+            psi.ArgumentList.Add("-ar"); psi.ArgumentList.Add("8000");
+            psi.ArgumentList.Add("-ac"); psi.ArgumentList.Add("1");
+            psi.ArgumentList.Add("-loglevel"); psi.ArgumentList.Add("quiet");
+            psi.ArgumentList.Add("pipe:1");
+
+            using var proc = Process.Start(psi)!;
+            using var ms = new MemoryStream();
+            proc.StandardOutput.BaseStream.CopyTo(ms);
+            proc.WaitForExit();
+
+            var bytes = ms.ToArray();
+            int totalSamples = bytes.Length / 4;
+            if (totalSamples == 0) return Array.Empty<(float, float)>();
+
+            var samples = MemoryMarshal.Cast<byte, float>(bytes.AsSpan());
+            int actualBins = Math.Min(binCount, totalSamples);
+            var peaks = new (float Low, float High)[actualBins];
+
+            for (int i = 0; i < actualBins; i++)
+            {
+                int binStart = (int)((long)i * totalSamples / actualBins);
+                int binEnd = (int)((long)(i + 1) * totalSamples / actualBins);
+                float lo = 0f, hi = 0f;
+                for (int j = binStart; j < binEnd; j++)
+                {
+                    float v = samples[j];
+                    if (v < lo) lo = v;
+                    if (v > hi) hi = v;
+                }
+                peaks[i] = (lo, hi);
+            }
+            return peaks;
+        }
+        catch { return Array.Empty<(float, float)>(); }
+    }
+
+    private void Time_Changed(object? sender, NumericUpDownValueChangedEventArgs e)
+    {
+        if (_suppressChange || _totalDuration <= 0) return;
+        var wf = this.FindControl<WaveformControl>("Waveform");
+        if (wf == null) return;
+        double s = (double)(this.FindControl<NumericUpDown>("StartSec")?.Value ?? 0m);
+        double en = (double)(this.FindControl<NumericUpDown>("EndSec")?.Value ?? (decimal)_totalDuration);
+        wf.SetSelection(s / _totalDuration, en / _totalDuration);
+    }
+
+    private void StopPreview()
+    {
+        _isPreviewing = false;
+        try { _previewFf?.Kill(); } catch { }
+        try { _previewPa?.Kill(); } catch { }
+        _previewFf = null;
+        _previewPa = null;
+    }
+
+    private void Play_Click(object? sender, RoutedEventArgs e)
+    {
+        if (_isPreviewing)
+        {
+            StopPreview();
+            if (this.FindControl<Button>("PlayButton") is Button sb) sb.Content = "▶ Preview";
+            return;
+        }
+
+        double startSec = (double)(this.FindControl<NumericUpDown>("StartSec")?.Value ?? 0m);
+        double endSec = (double)(this.FindControl<NumericUpDown>("EndSec")?.Value ?? (decimal)_totalDuration);
+
+        var ffPsi = new ProcessStartInfo("ffmpeg") { UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true };
+        ffPsi.ArgumentList.Add("-ss"); ffPsi.ArgumentList.Add(startSec.ToString("F5", CultureInfo.InvariantCulture));
+        ffPsi.ArgumentList.Add("-i"); ffPsi.ArgumentList.Add(_filePath);
+        if (endSec > startSec)
+        {
+            ffPsi.ArgumentList.Add("-t");
+            ffPsi.ArgumentList.Add((endSec - startSec).ToString("F5", CultureInfo.InvariantCulture));
+        }
+        ffPsi.ArgumentList.Add("-f"); ffPsi.ArgumentList.Add("s16le");
+        ffPsi.ArgumentList.Add("-ar"); ffPsi.ArgumentList.Add("48000");
+        ffPsi.ArgumentList.Add("-ac"); ffPsi.ArgumentList.Add("1");
+        ffPsi.ArgumentList.Add("-loglevel"); ffPsi.ArgumentList.Add("quiet");
+        ffPsi.ArgumentList.Add("pipe:1");
+
+        var paPsi = new ProcessStartInfo("pacat") { UseShellExecute = false, RedirectStandardInput = true, CreateNoWindow = true };
+        paPsi.ArgumentList.Add("--playback");
+        paPsi.ArgumentList.Add("--rate=48000");
+        paPsi.ArgumentList.Add("--channels=1");
+        paPsi.ArgumentList.Add("--format=s16le");
+
+        Process ff, pa;
+        try
+        {
+            ff = Process.Start(ffPsi)!;
+            pa = Process.Start(paPsi)!;
+        }
+        catch { return; }
+        _previewFf = ff;
+        _previewPa = pa;
+        _isPreviewing = true;
+
+        if (this.FindControl<Button>("PlayButton") is Button btn) btn.Content = "■ Stop";
+
+        Task.Run(async () =>
+        {
+            // ffmpeg pipes data into pacat and exits quickly; pacat buffers and plays it out in real time
+            try { await ff.StandardOutput.BaseStream.CopyToAsync(pa.StandardInput.BaseStream); } catch { }
+            try { pa.StandardInput.Close(); } catch { }
+            // wait for pacat to finish playing before resetting the button
+            ff.WaitForExit();
+            pa.WaitForExit();
+            Dispatcher.UIThread.Post(() =>
+            {
+                _isPreviewing = false;
+                _previewFf = null;
+                _previewPa = null;
+                if (this.FindControl<Button>("PlayButton") is Button b) b.Content = "▶ Preview";
+            });
+        });
+    }
+
+    private void Reset_Click(object? sender, RoutedEventArgs e)
+    {
+        _suppressChange = true;
+        if (this.FindControl<NumericUpDown>("StartSec") is NumericUpDown sb) sb.Value = 0m;
+        if (this.FindControl<NumericUpDown>("EndSec") is NumericUpDown eb) eb.Value = (decimal)_totalDuration;
+        _suppressChange = false;
+        if (this.FindControl<WaveformControl>("Waveform") is WaveformControl wf)
+            wf.SetSelection(0, 1);
+    }
+
+    private void OK_Click(object? sender, RoutedEventArgs e)
+    {
+        double startSec = (double)(this.FindControl<NumericUpDown>("StartSec")?.Value ?? 0m);
+        double endSec = (double)(this.FindControl<NumericUpDown>("EndSec")?.Value ?? (decimal)_totalDuration);
+        StartPos = startSec > 0.001 ? SecToBytes(startSec) : 0;
+        // EndPos=0 means "play to end" - only set if user trimmed it short
+        EndPos = (_totalDuration > 0 && endSec < _totalDuration - 0.1) ? SecToBytes(endSec) : 0;
+        Confirmed = true;
+        Close();
+    }
+
+    private void Cancel_Click(object? sender, RoutedEventArgs e) => Close();
+
+    protected override void OnClosed(EventArgs e)
+    {
+        StopPreview();
+        base.OnClosed(e);
+    }
+}

+ 14 - 0
Views/YTImportWindow.axaml

@@ -0,0 +1,14 @@
+<Window xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        x:Class="SlamCross.Views.YTImportWindow"
+        Title="Import from YouTube" Width="440" Height="130"
+        CanResize="False" WindowStartupLocation="CenterOwner">
+    <StackPanel Margin="16" Spacing="10">
+        <TextBlock Text="Paste YouTube URL (requires yt-dlp installed):"/>
+        <TextBox x:Name="UrlBox" PlaceholderText="https://www.youtube.com/watch?v=..."/>
+        <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
+            <Button Content="OK"     Click="OK_Click"/>
+            <Button Content="Cancel" Click="Cancel_Click"/>
+        </StackPanel>
+    </StackPanel>
+</Window>

+ 19 - 0
Views/YTImportWindow.axaml.cs

@@ -0,0 +1,19 @@
+using Avalonia.Controls;
+
+namespace SlamCross.Views;
+
+public partial class YTImportWindow : Window
+{
+    public string? ResultUrl { get; private set; }
+
+    public YTImportWindow() => InitializeComponent();
+
+    private void OK_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        var text = this.FindControl<TextBox>("UrlBox")?.Text?.Trim();
+        if (!string.IsNullOrEmpty(text)) ResultUrl = text;
+        Close();
+    }
+
+    private void Cancel_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => Close();
+}

+ 18 - 0
app.manifest

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
+  <!-- This manifest is used on Windows only.
+       Don't remove it as it might cause problems with window transparency and embedded controls.
+       For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
+  <assemblyIdentity version="1.0.0.0" name="SLAM_Cross.Desktop"/>
+
+  <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+    <application>
+      <!-- A list of the Windows versions that this application has been tested on
+           and is designed to work with. Uncomment the appropriate elements
+           and Windows will automatically select the most compatible environment. -->
+
+      <!-- Windows 10 -->
+      <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
+    </application>
+  </compatibility>
+</assembly>