diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index face2e4..5059c36 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,6 +2,7 @@ ## General Guidelines - The display language of the application shall be English and must be documented as a design guideline. +- Provide a professional README without generic AI formulations; it should start with a clear explanation of GCB, highlight improvements since version 1.0, and include explicit screenshot placeholders. ## Projektrichtlinien - Der Nutzer möchte ein zentrales Umsetzungsbacklog und eine Designentscheidungsdokumentation als Steuerungs- und Gedächtnisartefakte im Repository; außerdem soll die Migration auf eine aktuelle .NET-Version (net10.0-windows) bereits in einer frühen Projektphase eingeplant werden. @@ -10,6 +11,7 @@ - Der Nutzer möchte den Migrationsplan um einen expliziten Schritt erweitern, der den Systray-/Lifecycle-Host von Form1 entkoppelt, damit Form1 später vollständig entfernt werden kann. - Änderungen sollen sorgfältig geplant und stabil umgesetzt werden, damit nichts kaputt geht. - Umsetzung soll in kleinen Schritten erfolgen. +- Minimal-risk Refactoring ist bevorzugt; bestehende event-getriebene Architektur strikt beibehalten; kein neues Polling/Timer-Refresh; Fokus auf zentralen Runtime-State, Entkopplung MainWindow-Programmlogik und Verkleinerung großer Dateien in kleinen stabilen Schritten. ## Architekturelles Prinzip (verbindlich) ### Event Driven diff --git a/AI Agent/AudioManager-Replacement-Analysis.md b/AI Agent/AudioManager-Replacement-Analysis.md new file mode 100644 index 0000000..6e291b9 --- /dev/null +++ b/AI Agent/AudioManager-Replacement-Analysis.md @@ -0,0 +1,104 @@ +# AudioManager-Ersatz – Funktions- und Abhängigkeitsanalyse + +## Status + +Diese Analyse wurde vor Änderungen an der Audio-Service-Implementierung erstellt. `AudioControl/AudioManager.cs` bleibt bis zur bestätigten Umsetzung unverändert. + +## Ist-Zustand und Herkunftshinweise + +`AudioManager.cs` enthält eine statische Audio-Implementierung mit eigener COM-Interop. Im produktiven Quelltext stehen derzeit folgende Herkunftsverweise: + +- StackOverflow: `controling-volume-mixer` +- CodePlex: `netcoreaudio` +- Gist: `sverrirs` und `d099b34b7f72bb4fb386` + +Die COM-Interfaces, `MMDeviceEnumerator`, Session-Notification-Klasse, Monitoring-Thread und Audiooperationen sind in `AudioManager.cs` zusammengefasst. Es gibt keine separate Audio-NuGet-Abhängigkeit in `AudioControl.csproj`. + +## Repositoryweite öffentliche Funktionalität + +| Bestehende Funktion | Aktuelle Repository-Verwendung | Ziel der neuen Implementierung | Windows-Core-Audio-Zuordnung | +|---|---|---|---| +| Audio-Sessions des Multimedia-Render-Endpunkts enumerieren | Aktiv: `GetAudioApplications` über `IAudioSessionService`; Anzeige im Tray/UI | Beibehalten | `IMMDeviceEnumerator`, `IMMDevice`, `IAudioSessionManager2`, `IAudioSessionEnumerator`, `IAudioSessionControl2` | +| Anwendungen nach Prozessnamen/PID ausgeben | Aktiv: Anzeige mit/ohne PID | Beibehalten, einschließlich des bestehenden Ausgabeformats soweit UI-Vertrag es verlangt | `IAudioSessionControl2.GetProcessId`, Prozessauflösung über `Process` | +| Lautstärke anhand eines Anwendungsnamens setzen | Aktiv: `AudioBalanceService` für Game-/Chat-Listen | Beibehalten; Matching-Verhalten vor Änderung testen und dokumentieren | `ISimpleAudioVolume.SetMasterVolume` | +| Mehrere Session-Einträge je Anwendung behandeln | Aktiv implizit durch Enumeration und Namensvergleich | Beibehalten | Jede passende Session einzeln über `ISimpleAudioVolume` | +| Neue Audio-Session melden | Aktiv: `IAudioSessionNotification` → `AudioSessionsChanged` | Beibehalten, ohne Polling | `IAudioSessionManager2.RegisterSessionNotification`, `IAudioSessionNotification` | +| Session-Monitoring starten | Aktiv: `AppHost.Start` | Beibehalten, idempotent | Eigener `WindowsAudioSessionController.Start` | +| Session-Monitoring stoppen | Aktiv: `AppHost.Stop`/Dispose | Beibehalten, mit sicherer Abmeldung und COM-Freigabe | `UnregisterSessionNotification` plus kontrollierter COM-Lifecycle | +| Master-Lautstärke lesen | Keine aktuelle Call-Site gefunden; öffentliche AudioManager-Funktion | Bis zur expliziten Paritätsentscheidung erhalten oder als geprüfte, ungenutzte Kompatibilitätsfunktion dokumentieren | `IAudioEndpointVolume.GetMasterVolumeLevelScalar` | +| Master-Lautstärke setzen/Schritt setzen | Keine aktuelle Call-Site gefunden; öffentliche AudioManager-Funktionen | Nicht stillschweigend entfernen; Ziel-API muss Entscheidung abbilden | `IAudioEndpointVolume.SetMasterVolumeLevelScalar`, `GetMasterVolumeLevelScalar` | +| Master-Mute lesen/setzen/toggeln | Keine aktuelle Call-Site gefunden; öffentliche AudioManager-Funktionen | Nicht stillschweigend entfernen; Ziel-API muss Entscheidung abbilden | `IAudioEndpointVolume.GetMute`, `SetMute` | +| Anwendungslautstärke per PID lesen/setzen | Keine aktuelle Call-Site gefunden; öffentliche AudioManager-Funktionen | Prüfen, ob öffentliche Kompatibilität benötigt wird; falls ja erhalten | Session-Enumeration + `ISimpleAudioVolume` | +| Anwendungs-Mute per PID setzen | Keine aktuelle Call-Site gefunden; öffentliche AudioManager-Funktion | Prüfen, ob öffentliche Kompatibilität benötigt wird; falls ja erhalten | `ISimpleAudioVolume.SetMute` | +| PIDs anhand eines Anwendungsnamens finden | Keine aktuelle Call-Site gefunden; öffentliche AudioManager-Funktion | Prüfen, ob öffentliche Kompatibilität benötigt wird; falls ja erhalten | `IAudioSessionControl2.GetProcessId` + Prozessauflösung | +| Diagnose-Logging übergeben | Aktiv indirekt: `AudioSessionService` übergibt `IDiagnosticsSink` | Durch Constructor-/Dependency-Injection in der Audio-Infrastruktur ersetzen; kein statischer Sink | `IDiagnosticsSink` bleibt Application-Vertrag, COM-Callbacks bleiben leichtgewichtig | + +## Direkte Abhängigkeiten und Call-Graph + +```text +LegacyApplicationHostFactory + -> AudioSessionService(IDiagnosticsSink) + -> IAudioSessionService + -> AudioManager.AudioManager (statischer Fremd-/Interop-Bestand) + +AppHost + -> StartSessionMonitoring / StopSessionMonitoring + +AudioBalanceService + -> SetApplicationVolumeByName(gameApps, gameVolume) + -> SetApplicationVolumeByName(chatApps, chatVolume) + +TrayIconApplicationHost + -> AudioSessionsChanged + -> GetAudioApplications(false) +``` + +Die Application-Schicht kennt aktuell nur `IAudioSessionService`. COM-Typen dürfen in der Zielarchitektur ausschließlich unter `AudioControl/Infrastructure/Audio` liegen. + +## Ereignis- und Lifecycle-Verhalten + +| Ereignis/Zustand | Aktuelles Verhalten | Zielverhalten | +|---|---|---| +| Monitoring-Start | Hintergrundthread, MTA, COM-Initialisierung, Default-Rendergerät, Session Manager | Eigener Controller mit explizitem Start/Stop und COM-Lifetime | +| Initiale Sessions | `GetSessionEnumerator` und `GetCount` beim Start | Initiale Enumeration über Infrastructure; Application erhält weiterhin den vorhandenen Refresh-Impuls bzw. Snapshot-Vertrag | +| Neue Session | `IAudioSessionNotification.OnSessionCreated` löst `AudioSessionsChanged` aus | Callback nur als minimale Signalweitergabe; Dispatch/Business-Logik außerhalb des COM-Callbacks | +| Session entfernt/disconnected | Kein eigener `IAudioSessionEvents`- oder State-Tracker vorhanden; UI refresh erfolgt primär über neue Session | Zielimplementierung muss die tatsächlich erforderliche Entfernung/State-Semantik festlegen und event-getrieben abbilden | +| Session-Volume geändert | Nicht an Application weitergereicht | Nur ergänzen, wenn bestehende Consumer oder der neue Vertrag dies benötigen; keine Polling-Schleife | +| Device-Wechsel | Kein `IMMNotificationClient` vorhanden | Default-Device-/Invalidation-Verhalten analysieren und bei benötigtem Verhalten event-getrieben ergänzen | +| Stop/Dispose | Notification-Abmeldung und COM-Release im Worker-Finally; Fehler werden teilweise verschluckt | Deterministische Abmeldung, HRESULT-Prüfung, sichere Shutdown-Reihenfolge, keine Unregister-/Release-Operation im Callback | + +## Zielgrenze + +```text +Application + -> IAudioSessionService / IAudioSessionController (COM-freier Vertrag) + -> WindowsAudioSessionController + -> CoreAudio-Interop (nur Infrastructure) + -> Windows Core Audio / WASAPI +``` + +Vorgesehene kleine Struktur: + +- `AudioControl/Infrastructure/Audio/WindowsAudioSessionController.cs` +- `AudioControl/Infrastructure/Audio/CoreAudio/` für die minimal benötigten COM-Interfaces und Callback-Adapter +- bestehender `IAudioSessionService`-Vertrag, sofern für die Feature-Parität ausreichend + +`IAudioSessionEvents`, `IAudioSessionNotification`, `IMMNotificationClient`, `IAudioEndpointVolume` und weitere Interfaces werden nur umgesetzt, wenn sie durch die bestehende Funktion oder die bestätigte Zielsemantik erforderlich sind. Kein Polling und keine periodische Session-Enumeration. + +## Validierungs- und Paritätscheckliste + +- [ ] Bestehende Sessions werden über den Default-Multimedia-Render-Endpunkt gefunden. +- [ ] Ausgabe mit und ohne PID bleibt kompatibel. +- [ ] Game-/Chat-Anwendungen werden nach bestehendem Matching-Verhalten geregelt. +- [ ] Mehrere passende Sessions werden korrekt behandelt. +- [ ] Neue Sessions lösen weiterhin den Application-Refresh aus. +- [ ] Session-, Device- und Shutdown-Lifecycle besitzen keine offenen COM-Referenzen oder Event-Subscriptions. +- [ ] Callback-Code blockiert nicht und führt keine komplexe Business-Logik aus. +- [ ] Start, Stop, Dispose, Device-Invalidierung und Fehlerfälle sind getestet. +- [ ] Der Build und vorhandene Tests sind erfolgreich. +- [ ] Nach der Umstellung verbleiben keine produktiven Gist-/CodePlex-/StackOverflow-Referenzen. +- [ ] Erst nach Feature-Parität wird `AudioManager.cs` entfernt. + +## Freigabepunkt + +Die Implementierung verändert Audio-Service-Logik und COM-Interop. Daher ist vor dem nächsten Schritt eine ausdrückliche Bestätigung erforderlich. Nach Bestätigung wird die Zielarchitektur in kleinen Schritten umgesetzt und nach jedem Schritt gebaut bzw. getestet. diff --git a/AI Agent/Migration-Analysis.md b/AI Agent/Migration-Analysis.md index 299cd52..0c378e4 100644 --- a/AI Agent/Migration-Analysis.md +++ b/AI Agent/Migration-Analysis.md @@ -92,7 +92,7 @@ Hinweis: .NET 7 ist außerhalb regulärem Supportfenster. Für Windows-11-Zielbi ## 6) Windows-Audio-Integration -- Eigene CoreAudio-COM-Interop in `AudioManager.cs` +- Eigene CoreAudio-COM-Interop unter `AudioControl/Infrastructure/Audio/CoreAudio/` - Session-Enumeration über `IAudioSessionManager2` - Prozess-basierte Volume-Steuerung per Prozessname - Logik: diff --git a/AI Agent/Refactoring-Minimal-Result.md b/AI Agent/Refactoring-Minimal-Result.md new file mode 100644 index 0000000..7e7abdd --- /dev/null +++ b/AI Agent/Refactoring-Minimal-Result.md @@ -0,0 +1,59 @@ +# Refactoring-Ergebnis (minimal, risikoarm) + +## Scope +- Zentraler Runtime-State eingeführt +- MainWindow-Konstruktorabhängigkeiten gekapselt +- TrayIconApplicationHost um Assignment-Verantwortung entlastet +- Event-driven Architektur unverändert beibehalten + +## Umgesetzte Änderungen + +### 1) Zentraler Runtime-State +- Neu: + - `AudioControl/GameChatState.cs` + - `AudioControl/GameChatStateChangedEventArgs.cs` + - `AudioControl/IGameChatStateStore.cs` + - `AudioControl/GameChatStateStore.cs` +- `StateChanged` wird nur bei echten Änderungen ausgelöst (Change-Detection inkl. App-Listen). + +### 2) Event-Quellen angebunden +- `TrayIconApplicationHost` synchronisiert bestehende Event-/Handlerpfade in den State: + - Hardware Connected/Port + - Input/Display/Game/Chat-Volume + - InvertControl + - NoiseReduction + - Game/Chat-App-Assignments +- Keine Polling-/Timer-Erweiterung eingeführt. + +### 3) MainWindow entkoppelt (inkrementell) +- Neu: `GameChatBalancer.Wpf/MainWindowViewModel.cs` +- `MainWindow` akzeptiert jetzt zusätzlich `MainWindow(MainWindowViewModel viewModel, bool showDebugOption)`. +- Bestehende Event- und UI-Logik im Code-Behind bleibt unverändert. + +### 4) Func/Action-Fläche reduziert +- `TrayIconApplicationHost.CreateMainWindow` erstellt jetzt ein `MainWindowViewModel` und übergibt dieses als gebündelte Abhängigkeit. + +### 5) Verantwortlichkeit aus TrayHost extrahiert +- Neu: `AudioControl/AppAssignmentManager.cs` + - verfügbare Apps bestimmen + - Assignment-Path auflösen/persistieren + - Game/Chat-CSV anwenden + - `AssignmentsChanged`-Event +- `TrayIconApplicationHost` reagiert weiterhin event-driven auf `AssignmentsChanged` (State-Sync + UI-Refresh). + +### 6) Doppelte Runtime-State-Nutzung reduziert +- Mehrere Leser wurden auf `stateStore.Current` umgestellt: + - Tray-Statusprovider + - MainWindow-Statusprovider + - Start-UI-Initialisierung + - Software-Control-Step/Center-Berechnung + +## Validierung +- `dotnet build` erfolgreich +- `dotnet test AudioControl.sln` ohne Testfälle (keine Tests im aktuellen Scope gefunden) + +## Nicht geändert (bewusst) +- Keine neue Polling-Mechanik +- Keine Änderung an Hardware-/Audio-Protokollen +- Keine Änderung an Autostart-/Settings-System-Architektur +- Kein Rewrite der bestehenden Event-Ketten diff --git a/AudioControl/AppAssignmentManager.cs b/AudioControl/AppAssignmentManager.cs new file mode 100644 index 0000000..3923d69 --- /dev/null +++ b/AudioControl/AppAssignmentManager.cs @@ -0,0 +1,75 @@ +namespace AudioControl; + +internal sealed class AppAssignmentManager +{ + private readonly ISettingsStore settingsStore; + private readonly AppAssignmentPathStore appAssignmentPathStore; + private readonly Func audioSessionServiceProvider; + + public AppAssignmentManager( + ISettingsStore settingsStore, + AppAssignmentPathStore appAssignmentPathStore, + Func audioSessionServiceProvider) + { + this.settingsStore = settingsStore; + this.appAssignmentPathStore = appAssignmentPathStore; + this.audioSessionServiceProvider = audioSessionServiceProvider; + } + + public event EventHandler? AssignmentsChanged; + + public IEnumerable GetAvailableApps() + { + var audioSessionService = audioSessionServiceProvider(); + if (audioSessionService == null) + { + return Enumerable.Empty(); + } + + var gameApps = ParseCsv(settingsStore.Game).ToHashSet(StringComparer.OrdinalIgnoreCase); + var chatApps = ParseCsv(settingsStore.Chat).ToHashSet(StringComparer.OrdinalIgnoreCase); + + return audioSessionService.GetAudioApplications(false) + .Split("\r\n", StringSplitOptions.RemoveEmptyEntries) + .Select(item => item.Trim()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Where(item => !item.Equals("Idle", StringComparison.OrdinalIgnoreCase)) + .Where(item => !gameApps.Contains(item) && !chatApps.Contains(item)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public string? ResolveAssignedAppPath(string appName) + { + return appAssignmentPathStore.GetPathOrResolve(appName); + } + + public void PersistResolvedAssignedAppPath(string appName, string exePath) + { + appAssignmentPathStore.SetPath(appName, exePath); + } + + public void ApplyGameApps(string csv) + { + settingsStore.Game = csv ?? string.Empty; + settingsStore.ScheduleSave(); + appAssignmentPathStore.SyncAssignedApps(ParseCsv(settingsStore.Game), ParseCsv(settingsStore.Chat)); + AssignmentsChanged?.Invoke(this, EventArgs.Empty); + } + + public void ApplyChatApps(string csv) + { + settingsStore.Chat = csv ?? string.Empty; + settingsStore.ScheduleSave(); + appAssignmentPathStore.SyncAssignedApps(ParseCsv(settingsStore.Game), ParseCsv(settingsStore.Chat)); + AssignmentsChanged?.Invoke(this, EventArgs.Empty); + } + + private static IEnumerable ParseCsv(string csv) + { + return (csv ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)); + } +} diff --git a/AudioControl/AppHost.cs b/AudioControl/AppHost.cs index 7b17a4a..0b7019b 100644 --- a/AudioControl/AppHost.cs +++ b/AudioControl/AppHost.cs @@ -66,6 +66,7 @@ public void Stop() public void Dispose() { Stop(); + audioSessionService.Dispose(); } private static string MapNoiseReductionToCommand(string noiseReduction) diff --git a/AudioControl/AudioControl.csproj b/AudioControl/AudioControl.csproj index a27f074..f517919 100644 --- a/AudioControl/AudioControl.csproj +++ b/AudioControl/AudioControl.csproj @@ -3,6 +3,7 @@ WinExe net10.0-windows + 2.0.1 enable true enable diff --git a/AudioControl/AudioManager.cs b/AudioControl/AudioManager.cs deleted file mode 100644 index c08d5c0..0000000 --- a/AudioControl/AudioManager.cs +++ /dev/null @@ -1,1138 +0,0 @@ -using AudioControl; -using System.Diagnostics; -using System.Linq.Expressions; -using System.Runtime.InteropServices; -using System.Threading; -//using System.Security.Cryptography; - -// ReSharper disable SuspiciousTypeConversion.Global -// ReSharper disable InconsistentNaming - -namespace AudioManager -{ - /// - /// Controls audio using the Windows CoreAudio API - /// from: http://stackoverflow.com/questions/14306048/controling-volume-mixer - /// and: http://netcoreaudio.codeplex.com/ - /// and: https://gist.github.com/sverrirs - /// and: https://gist.github.com/sverrirs/d099b34b7f72bb4fb386 - /// - - - public static class AudioManager - { - public static event EventHandler? AudioSessionsChanged; - private static readonly object monitoringSync = new(); - private static Thread? monitoringThread; - private static ManualResetEventSlim? monitoringStopSignal; - private static volatile bool monitoringActive; - - private const uint COINIT_MULTITHREADED = 0x0; - - [DllImport("ole32.dll")] - private static extern int CoInitializeEx(IntPtr pvReserved, uint dwCoInit); - - [DllImport("ole32.dll")] - private static extern void CoUninitialize(); - - public static void StartAudioSessionMonitoring() - { - lock (monitoringSync) - { - if (monitoringThread is { IsAlive: true }) - { - return; - } - - monitoringStopSignal = new ManualResetEventSlim(false); - monitoringThread = new Thread(MonitorAudioSessionsWorker) - { - IsBackground = true, - Name = "AudioSessionMonitoring" - }; - - monitoringThread.SetApartmentState(ApartmentState.MTA); - monitoringThread.Start(); - } - } - - public static void StopAudioSessionMonitoring() - { - Thread? threadToJoin; - - lock (monitoringSync) - { - monitoringStopSignal?.Set(); - threadToJoin = monitoringThread; - } - - try - { - threadToJoin?.Join(1000); - } - catch - { - } - finally - { - lock (monitoringSync) - { - monitoringThread = null; - monitoringStopSignal?.Dispose(); - monitoringStopSignal = null; - monitoringActive = false; - } - } - } - - private static void MonitorAudioSessionsWorker() - { - IMMDeviceEnumerator? deviceEnumerator = null; - IMMDevice? speakers = null; - IAudioSessionManager2? sessionManager = null; - IAudioSessionEnumerator? sessionEnumerator = null; - AudioSessionNotificationClient? notificationClient = null; - var hr = CoInitializeEx(IntPtr.Zero, COINIT_MULTITHREADED); - var comInitialized = hr == 0 || hr == 1; - - try - { - if (!comInitialized) - { - return; - } - - deviceEnumerator = (IMMDeviceEnumerator)(new MMDeviceEnumerator()); - deviceEnumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eMultimedia, out speakers); - if (speakers == null) - { - return; - } - - var iid = typeof(IAudioSessionManager2).GUID; - speakers.Activate(ref iid, 0, IntPtr.Zero, out var o); - sessionManager = (IAudioSessionManager2)o; - - notificationClient = new AudioSessionNotificationClient(); - notificationClient.SessionCreated += (_, _) => - { - try - { - if (DiagnosticsSink != null && DiagnosticsSink.DebugEnabled) - { - DiagnosticsSink.Log("AudioSessionMonitoring: OnSessionCreated received."); - } - } - catch - { - } - - AudioSessionsChanged?.Invoke(null, EventArgs.Empty); - }; - - var registerHr = sessionManager.RegisterSessionNotification(notificationClient); - try - { - if (DiagnosticsSink != null && DiagnosticsSink.DebugEnabled) - { - DiagnosticsSink.Log($"AudioSessionMonitoring: RegisterSessionNotification HR=0x{registerHr:X8}"); - } - } - catch - { - } - - // Laut Microsoft-Dokumentation muss GetCount einmal aufgerufen werden, - // damit neue Session-Notifications tatsächlich zugestellt werden. - sessionManager.GetSessionEnumerator(out sessionEnumerator); - var initialCount = 0; - sessionEnumerator?.GetCount(out initialCount); - try - { - if (DiagnosticsSink != null && DiagnosticsSink.DebugEnabled) - { - DiagnosticsSink.Log($"AudioSessionMonitoring: Initial session count={initialCount}."); - } - } - catch - { - } - - monitoringActive = true; - - monitoringStopSignal?.Wait(); - } - catch - { - } - finally - { - try - { - if (sessionManager != null && notificationClient != null) - { - var unregisterHr = sessionManager.UnregisterSessionNotification(notificationClient); - try - { - if (DiagnosticsSink != null && DiagnosticsSink.DebugEnabled) - { - DiagnosticsSink.Log($"AudioSessionMonitoring: UnregisterSessionNotification HR=0x{unregisterHr:X8}"); - } - } - catch - { - } - } - } - catch - { - } - - if (sessionManager != null) - { - Marshal.ReleaseComObject(sessionManager); - } - - if (sessionEnumerator != null) - { - Marshal.ReleaseComObject(sessionEnumerator); - } - - if (speakers != null) - { - Marshal.ReleaseComObject(speakers); - } - - if (deviceEnumerator != null) - { - Marshal.ReleaseComObject(deviceEnumerator); - } - - if (comInitialized) - { - CoUninitialize(); - } - } - } - #region Master Volume Manipulation - - /// - /// Gets the current master volume in scalar values (percentage) - /// - /// -1 in case of an error, if successful the value will be between 0 and 100 - - private static AudioControl.IDiagnosticsSink? DiagnosticsSink; - public static void HandOverDiagnosticsSink(AudioControl.IDiagnosticsSink diagnosticsSink) - { - DiagnosticsSink = diagnosticsSink; - } - - - public static float GetMasterVolume() - { - IAudioEndpointVolume masterVol = null; - try - { - masterVol = GetMasterVolumeObject(); - if (masterVol == null) - return -1; - - float volumeLevel; - masterVol.GetMasterVolumeLevelScalar(out volumeLevel); - return volumeLevel * 100; - } - finally - { - if (masterVol != null) - Marshal.ReleaseComObject(masterVol); - } - } - - /// - /// Gets the mute state of the master volume. - /// While the volume can be muted the will still return the pre-muted volume value. - /// - /// false if not muted, true if volume is muted - public static bool GetMasterVolumeMute() - { - IAudioEndpointVolume masterVol = null; - try - { - masterVol = GetMasterVolumeObject(); - if (masterVol == null) - return false; - - bool isMuted; - masterVol.GetMute(out isMuted); - return isMuted; - } - finally - { - if (masterVol != null) - Marshal.ReleaseComObject(masterVol); - } - } - - /// - /// Sets the master volume to a specific level - /// - /// Value between 0 and 100 indicating the desired scalar value of the volume - public static void SetMasterVolume(float newLevel) - { - IAudioEndpointVolume masterVol = null; - try - { - masterVol = GetMasterVolumeObject(); - if (masterVol == null) - return; - - masterVol.SetMasterVolumeLevelScalar(newLevel / 100, Guid.Empty); - } - finally - { - if (masterVol != null) - Marshal.ReleaseComObject(masterVol); - } - } - - /// - /// Increments or decrements the current volume level by the . - /// - /// Value between -100 and 100 indicating the desired step amount. Use negative numbers to decrease - /// the volume and positive numbers to increase it. - /// the new volume level assigned - public static float StepMasterVolume(float stepAmount) - { - IAudioEndpointVolume masterVol = null; - try - { - masterVol = GetMasterVolumeObject(); - if (masterVol == null) - return -1; - - float stepAmountScaled = stepAmount / 100; - - // Get the level - float volumeLevel; - masterVol.GetMasterVolumeLevelScalar(out volumeLevel); - - // Calculate the new level - float newLevel = volumeLevel + stepAmountScaled; - newLevel = Math.Min(1, newLevel); - newLevel = Math.Max(0, newLevel); - - masterVol.SetMasterVolumeLevelScalar(newLevel, Guid.Empty); - - // Return the new volume level that was set - return newLevel * 100; - } - finally - { - if (masterVol != null) - Marshal.ReleaseComObject(masterVol); - } - } - - /// - /// Mute or unmute the master volume - /// - /// true to mute the master volume, false to unmute - public static void SetMasterVolumeMute(bool isMuted) - { - IAudioEndpointVolume masterVol = null; - try - { - masterVol = GetMasterVolumeObject(); - if (masterVol == null) - return; - - masterVol.SetMute(isMuted, Guid.Empty); - } - finally - { - if (masterVol != null) - Marshal.ReleaseComObject(masterVol); - } - } - - /// - /// Switches between the master volume mute states depending on the current state - /// - /// the current mute state, true if the volume was muted, false if unmuted - public static bool ToggleMasterVolumeMute() - { - IAudioEndpointVolume masterVol = null; - try - { - masterVol = GetMasterVolumeObject(); - if (masterVol == null) - return false; - - bool isMuted; - masterVol.GetMute(out isMuted); - masterVol.SetMute(!isMuted, Guid.Empty); - - return !isMuted; - } - finally - { - if (masterVol != null) - Marshal.ReleaseComObject(masterVol); - } - } - - private static IAudioEndpointVolume GetMasterVolumeObject() - { - IMMDeviceEnumerator deviceEnumerator = null; - IMMDevice speakers = null; - try - { - deviceEnumerator = (IMMDeviceEnumerator)(new MMDeviceEnumerator()); - deviceEnumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eMultimedia, out speakers); - - Guid IID_IAudioEndpointVolume = typeof(IAudioEndpointVolume).GUID; - object o; - speakers.Activate(ref IID_IAudioEndpointVolume, 0, IntPtr.Zero, out o); - IAudioEndpointVolume masterVol = (IAudioEndpointVolume)o; - - return masterVol; - } - finally - { - if (speakers != null) Marshal.ReleaseComObject(speakers); - if (deviceEnumerator != null) Marshal.ReleaseComObject(deviceEnumerator); - } - } - - #endregion - - #region Individual Application Volume Manipulation - - public static float? GetApplicationVolume(int pid) - { - ISimpleAudioVolume volume = GetVolumeObject(pid); - if (volume == null) - return null; - - float level; - volume.GetMasterVolume(out level); - //Marshal.ReleaseComObject(volume); - return level * 100; - } - - public static bool? GetApplicationMute(int pid) - { - ISimpleAudioVolume volume = GetVolumeObject(pid); - if (volume == null) - return null; - - bool mute; - volume.GetMute(out mute); - Marshal.ReleaseComObject(volume); - return mute; - } - - public static void SetApplicationVolume(int pid, float level) - { - ISimpleAudioVolume volume = GetVolumeObject(pid); - if (volume == null) - return; - - Guid guid = Guid.Empty; - volume.SetMasterVolume(level / 100, ref guid); - Marshal.ReleaseComObject(volume); - } - - public static void SetApplicationMute(int pid, bool mute) - { - ISimpleAudioVolume volume = GetVolumeObject(pid); - if (volume == null) - return; - - Guid guid = Guid.Empty; - volume.SetMute(mute, ref guid); - Marshal.ReleaseComObject(volume); - } - - private static ISimpleAudioVolume GetVolumeObject(int pid) - { - IMMDeviceEnumerator? deviceEnumerator = null; - IAudioSessionEnumerator? sessionEnumerator = null; - IAudioSessionManager2? mgr = null; - IMMDevice? speakers = null; - try - { - // get the speakers (1st render + multimedia) device - deviceEnumerator = (IMMDeviceEnumerator)(new MMDeviceEnumerator()); - deviceEnumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eMultimedia, out speakers); - - - // activate the session manager. we need the enumerator - Guid IID_IAudioSessionManager2 = typeof(IAudioSessionManager2).GUID; - object o; - speakers.Activate(ref IID_IAudioSessionManager2, 0, IntPtr.Zero, out o); - mgr = (IAudioSessionManager2)o; - - // enumerate sessions for on this device - mgr.GetSessionEnumerator(out sessionEnumerator); - int count; - sessionEnumerator.GetCount(out count); - - // search for an audio session with the required process-id - ISimpleAudioVolume? volumeControl = null; - for (int i = 0; i < count; ++i) - { - IAudioSessionControl2 ctl = null; - try - { - sessionEnumerator.GetSession(i, out ctl); - - // NOTE: we could also use the app name from ctl.GetDisplayName() - int cpid; - ctl.GetProcessId(out cpid); - - if (cpid == pid) - { - volumeControl = ctl as ISimpleAudioVolume; - break; - } - } - finally - { - //if (ctl != null) Marshal.ReleaseComObject(ctl); - } - } - - return volumeControl; - } - finally - { - if (sessionEnumerator != null) Marshal.ReleaseComObject(sessionEnumerator); - if (mgr != null) Marshal.ReleaseComObject(mgr); - if (speakers != null) Marshal.ReleaseComObject(speakers); - if (deviceEnumerator != null) Marshal.ReleaseComObject(deviceEnumerator); - } - //return volumeControl; - } - - - - public static int[] GetApplicationPIDByName(string AppName) - { - int[] RetVal = new int[0]; - string WHAT = ""; - int cpid; - string cAppName; - IMMDeviceEnumerator deviceEnumerator = null; - IAudioSessionEnumerator sessionEnumerator = null; - IAudioSessionManager2 mgr = null; - IMMDevice speakers = null; - try - { - // get the speakers (1st render + multimedia) device - deviceEnumerator = (IMMDeviceEnumerator)(new MMDeviceEnumerator()); - deviceEnumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eMultimedia, out speakers); - - // activate the session manager. we need the enumerator - Guid IID_IAudioSessionManager2 = typeof(IAudioSessionManager2).GUID; - //RetVal = IID_IAudioSessionManager2.ToString(); - object o; - speakers.Activate(ref IID_IAudioSessionManager2, 0, IntPtr.Zero, out o); - mgr = (IAudioSessionManager2)o; - - // enumerate sessions for on this device - mgr.GetSessionEnumerator(out sessionEnumerator); - int count; - sessionEnumerator.GetCount(out count); - - // search for an audio session with the required process-id - ISimpleAudioVolume volumeControl = null; - for (int i = 0; i < count; ++i) - { - IAudioSessionControl2 ctl = null; - try - { - sessionEnumerator.GetSession(i, out ctl); - // NOTE: we could also use the app name from ctl.GetDisplayName() - - ctl.GetProcessId(out cpid); - cAppName = Process.GetProcessById(cpid).ProcessName; - } - finally - { - if (ctl != null) Marshal.ReleaseComObject(ctl); - } - if (cAppName.ToUpper() == AppName.ToUpper()) - { - Array.Resize(ref RetVal, RetVal.Length + 1); - RetVal[RetVal.Length - 1] = cpid; - - //RetVal.Append(cpid); - //RetVal += cAppName + "(" + cpid.ToString() + ")" + "\r\n"; - } - - } - } - catch { } - finally - { - if (sessionEnumerator != null) Marshal.ReleaseComObject(sessionEnumerator); - if (mgr != null) Marshal.ReleaseComObject(mgr); - if (speakers != null) Marshal.ReleaseComObject(speakers); - if (deviceEnumerator != null) Marshal.ReleaseComObject(deviceEnumerator); - } - return RetVal; - } - - - public static string GetAudioApplications(bool IncludingPID) - { - string RetVal = ""; - int cpid; - string cAppName; - IMMDeviceEnumerator deviceEnumerator = null; - IAudioSessionEnumerator sessionEnumerator = null; - IAudioSessionManager2 mgr = null; - IMMDevice speakers = null; - try - { - // get the speakers (1st render + multimedia) device - deviceEnumerator = (IMMDeviceEnumerator)(new MMDeviceEnumerator()); - deviceEnumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eMultimedia, out speakers); - - // activate the session manager. we need the enumerator - Guid IID_IAudioSessionManager2 = typeof(IAudioSessionManager2).GUID; - //RetVal = IID_IAudioSessionManager2.ToString(); - object o; - speakers.Activate(ref IID_IAudioSessionManager2, 0, IntPtr.Zero, out o); - mgr = (IAudioSessionManager2)o; - - // enumerate sessions for on this device - mgr.GetSessionEnumerator(out sessionEnumerator); - int count; - sessionEnumerator.GetCount(out count); - - // search for an audio session with the required process-id - ISimpleAudioVolume volumeControl = null; - for (int i = 0; i < count; ++i) - { - IAudioSessionControl2 ctl = null; - try - { - sessionEnumerator.GetSession(i, out ctl); - // NOTE: we could also use the app name from ctl.GetDisplayName() - - ctl.GetProcessId(out cpid); - cAppName = Process.GetProcessById(cpid).ProcessName; - } - finally - { - if (ctl != null) Marshal.ReleaseComObject(ctl); - } - if (IncludingPID) - { - RetVal += cAppName + "(" + cpid.ToString() + ")" + "\r\n"; - } - else - { - RetVal += cAppName + "\r\n"; - } - } - } - catch { } - finally - { - if (sessionEnumerator != null) Marshal.ReleaseComObject(sessionEnumerator); - if (mgr != null) Marshal.ReleaseComObject(mgr); - if (speakers != null) Marshal.ReleaseComObject(speakers); - if (deviceEnumerator != null) Marshal.ReleaseComObject(deviceEnumerator); - } - return RetVal; - } - - - /* - public static void SetApplicationVolumeByName(string AppName, float level) - { - int cpid; - string cAppName; - //IMMDeviceEnumerator deviceEnumerator = null; - //IAudioSessionEnumerator sessionEnumerator = null; - //IAudioSessionManager2 mgr = null; - //IMMDevice speakers = null; - SessionInUse = true; - try - { - - // get the speakers (1st render + multimedia) device - deviceEnumerator = (IMMDeviceEnumerator)(new MMDeviceEnumerator()); - deviceEnumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eMultimedia, out speakers); - - // activate the session manager. we need the enumerator - Guid IID_IAudioSessionManager2 = typeof(IAudioSessionManager2).GUID; - //RetVal = IID_IAudioSessionManager2.ToString(); - object o; - speakers.Activate(ref IID_IAudioSessionManager2, 0, IntPtr.Zero, out o); - mgr = (IAudioSessionManager2)o; - - // enumerate sessions for on this device - mgr.GetSessionEnumerator(out sessionEnumerator); - - if (!OpenAudioSession()) { return; } - int count; - sessionEnumerator.GetCount(out count); - - // search for an audio session with the required process-id - ISimpleAudioVolume volumeControl = null; - for (int i = 0; i < count; ++i) - { - IAudioSessionControl2 ctl = null; - try - { - sessionEnumerator.GetSession(i, out ctl); - // NOTE: we could also use the app name from ctl.GetDisplayName() - ctl.GetProcessId(out cpid); - cAppName = Process.GetProcessById(cpid).ProcessName; - if (AppName.ToUpper().Contains(cAppName.ToUpper())) - { - Guid guid = Guid.Empty; - volumeControl = ctl as ISimpleAudioVolume; - volumeControl.SetMasterVolume(level / 100, ref guid); - } - } - finally - { - if (ctl != null) Marshal.ReleaseComObject(ctl); - if (volumeControl != null) Marshal.ReleaseComObject(volumeControl); - } - - - - } - } - finally - { - SessionInUse = false; - //if (sessionEnumerator != null) Marshal.ReleaseComObject(sessionEnumerator); - //if (mgr != null) Marshal.ReleaseComObject(mgr); - //if (speakers != null) Marshal.ReleaseComObject(speakers); - //if (deviceEnumerator != null) Marshal.ReleaseComObject(deviceEnumerator); - //if (volumeControl != null) Marshal.ReleaseComObject(volumeControl); - } - } - */ - - - public static void SetApplicationVolumeByName(string AppName, float level) - { - int cpid; - string cAppName; - IMMDeviceEnumerator deviceEnumerator = null; - IAudioSessionEnumerator sessionEnumerator = null; - IAudioSessionManager2 mgr = null; - IMMDevice speakers = null; - try - { - // get the speakers (1st render + multimedia) device - deviceEnumerator = (IMMDeviceEnumerator)(new MMDeviceEnumerator()); - deviceEnumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eMultimedia, out speakers); - - // activate the session manager. we need the enumerator - Guid IID_IAudioSessionManager2 = typeof(IAudioSessionManager2).GUID; - //RetVal = IID_IAudioSessionManager2.ToString(); - object o; - speakers.Activate(ref IID_IAudioSessionManager2, 0, IntPtr.Zero, out o); - mgr = (IAudioSessionManager2)o; - - // enumerate sessions for on this device - mgr.GetSessionEnumerator(out sessionEnumerator); - int count; - sessionEnumerator.GetCount(out count); - - // search for an audio session with the required process-id - ISimpleAudioVolume volumeControl = null; - for (int i = 0; i < count; ++i) - { - IAudioSessionControl2 ctl = null; - try - { - sessionEnumerator.GetSession(i, out ctl); - // NOTE: we could also use the app name from ctl.GetDisplayName() - ctl.GetProcessId(out cpid); - cAppName = Process.GetProcessById(cpid).ProcessName; - if (AppName.ToUpper().Contains(cAppName.ToUpper())) - { - Guid guid = Guid.Empty; - volumeControl = ctl as ISimpleAudioVolume; - volumeControl.SetMasterVolume(level / 100, ref guid); - if (DiagnosticsSink != null && DiagnosticsSink.DebugEnabled) { DiagnosticsSink.Log(cAppName.ToString() + ".Volume=" + level.ToString()); } - } - } - catch { } - finally - { - if (ctl != null) Marshal.ReleaseComObject(ctl); - if (volumeControl != null) Marshal.ReleaseComObject(volumeControl); - } - } - } - catch { } - finally - { - if (sessionEnumerator != null) Marshal.ReleaseComObject(sessionEnumerator); - if (mgr != null) Marshal.ReleaseComObject(mgr); - if (speakers != null) Marshal.ReleaseComObject(speakers); - if (deviceEnumerator != null) Marshal.ReleaseComObject(deviceEnumerator); - //if (volumeControl != null) Marshal.ReleaseComObject(volumeControl); - } - } - - - #endregion - - } - - #region Abstracted COM interfaces from Windows CoreAudio API - - [ComImport] - [Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] - internal class MMDeviceEnumerator - { - } - - internal enum EDataFlow - { - eRender, - eCapture, - eAll, - EDataFlow_enum_count - } - - internal enum ERole - { - eConsole, - eMultimedia, - eCommunications, - ERole_enum_count - } - - [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - internal interface IMMDeviceEnumerator - { - int NotImpl1(); - - [PreserveSig] - int GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role, out IMMDevice ppDevice); - - // the rest is not implemented - } - - [Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - internal interface IMMDevice - { - [PreserveSig] - int Activate(ref Guid iid, int dwClsCtx, IntPtr pActivationParams, [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface); - - // the rest is not implemented - } - - [Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - internal interface IAudioSessionManager2 - { - int NotImpl1(); - int NotImpl2(); - - [PreserveSig] - int GetSessionEnumerator(out IAudioSessionEnumerator SessionEnum); - - [PreserveSig] - int RegisterSessionNotification(IAudioSessionNotification SessionNotification); - - [PreserveSig] - int UnregisterSessionNotification(IAudioSessionNotification SessionNotification); - - // the rest is not implemented - } - - [ComImport] - [ComVisible(true)] - [Guid("641DD20B-4D41-49CC-ABA3-174B9477BB08")] - [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - internal interface IAudioSessionNotification - { - [PreserveSig] - int OnSessionCreated(IntPtr newSession); - } - - [ComVisible(true)] - [ClassInterface(ClassInterfaceType.None)] - internal sealed class AudioSessionNotificationClient : IAudioSessionNotification, IDisposable - { - public event EventHandler? SessionCreated; - - public int OnSessionCreated(IntPtr newSession) - { - SessionCreated?.Invoke(this, EventArgs.Empty); - return 0; - } - - public void Dispose() - { - } - } - - [Guid("E2F5BB11-0570-40CA-ACDD-3AA01277DEE8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - internal interface IAudioSessionEnumerator - { - [PreserveSig] - int GetCount(out int SessionCount); - - [PreserveSig] - int GetSession(int SessionCount, out IAudioSessionControl2 Session); - } - - [Guid("87CE5498-68D6-44E5-9215-6DA47EF883D8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - internal interface ISimpleAudioVolume - { - [PreserveSig] - int SetMasterVolume(float fLevel, ref Guid EventContext); - - [PreserveSig] - int GetMasterVolume(out float pfLevel); - - [PreserveSig] - int SetMute(bool bMute, ref Guid EventContext); - - [PreserveSig] - int GetMute(out bool pbMute); - } - - [Guid("bfb7ff88-7239-4fc9-8fa2-07c950be9c6d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - internal interface IAudioSessionControl2 - { - // IAudioSessionControl - [PreserveSig] - int NotImpl0(); - - [PreserveSig] - int GetDisplayName([MarshalAs(UnmanagedType.LPWStr)] out string pRetVal); - - [PreserveSig] - int SetDisplayName([MarshalAs(UnmanagedType.LPWStr)] string Value, [MarshalAs(UnmanagedType.LPStruct)] Guid EventContext); - - [PreserveSig] - int GetIconPath([MarshalAs(UnmanagedType.LPWStr)] out string pRetVal); - - [PreserveSig] - int SetIconPath([MarshalAs(UnmanagedType.LPWStr)] string Value, [MarshalAs(UnmanagedType.LPStruct)] Guid EventContext); - - [PreserveSig] - int GetGroupingParam(out Guid pRetVal); - - [PreserveSig] - int SetGroupingParam([MarshalAs(UnmanagedType.LPStruct)] Guid Override, [MarshalAs(UnmanagedType.LPStruct)] Guid EventContext); - - [PreserveSig] - int NotImpl1(); - - [PreserveSig] - int NotImpl2(); - - // IAudioSessionControl2 - [PreserveSig] - int GetSessionIdentifier([MarshalAs(UnmanagedType.LPWStr)] out string pRetVal); - - [PreserveSig] - int GetSessionInstanceIdentifier([MarshalAs(UnmanagedType.LPWStr)] out string pRetVal); - - [PreserveSig] - int GetProcessId(out int pRetVal); - - [PreserveSig] - int IsSystemSoundsSession(); - - [PreserveSig] - int SetDuckingPreference(bool optOut); - } - - // http://netcoreaudio.codeplex.com/SourceControl/latest#trunk/Code/CoreAudio/Interfaces/IAudioEndpointVolume.cs - [Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - public interface IAudioEndpointVolume - { - [PreserveSig] - int NotImpl1(); - - [PreserveSig] - int NotImpl2(); - - /// - /// Gets a count of the channels in the audio stream. - /// - /// The number of channels. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int GetChannelCount( - [Out][MarshalAs(UnmanagedType.U4)] out UInt32 channelCount); - - /// - /// Sets the master volume level of the audio stream, in decibels. - /// - /// The new master volume level in decibels. - /// A user context value that is passed to the notification callback. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int SetMasterVolumeLevel( - [In][MarshalAs(UnmanagedType.R4)] float level, - [In][MarshalAs(UnmanagedType.LPStruct)] Guid eventContext); - - /// - /// Sets the master volume level, expressed as a normalized, audio-tapered value. - /// - /// The new master volume level expressed as a normalized value between 0.0 and 1.0. - /// A user context value that is passed to the notification callback. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int SetMasterVolumeLevelScalar( - [In][MarshalAs(UnmanagedType.R4)] float level, - [In][MarshalAs(UnmanagedType.LPStruct)] Guid eventContext); - - /// - /// Gets the master volume level of the audio stream, in decibels. - /// - /// The volume level in decibels. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int GetMasterVolumeLevel( - [Out][MarshalAs(UnmanagedType.R4)] out float level); - - /// - /// Gets the master volume level, expressed as a normalized, audio-tapered value. - /// - /// The volume level expressed as a normalized value between 0.0 and 1.0. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int GetMasterVolumeLevelScalar( - [Out][MarshalAs(UnmanagedType.R4)] out float level); - - /// - /// Sets the volume level, in decibels, of the specified channel of the audio stream. - /// - /// The channel number. - /// The new volume level in decibels. - /// A user context value that is passed to the notification callback. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int SetChannelVolumeLevel( - [In][MarshalAs(UnmanagedType.U4)] UInt32 channelNumber, - [In][MarshalAs(UnmanagedType.R4)] float level, - [In][MarshalAs(UnmanagedType.LPStruct)] Guid eventContext); - - /// - /// Sets the normalized, audio-tapered volume level of the specified channel in the audio stream. - /// - /// The channel number. - /// The new master volume level expressed as a normalized value between 0.0 and 1.0. - /// A user context value that is passed to the notification callback. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int SetChannelVolumeLevelScalar( - [In][MarshalAs(UnmanagedType.U4)] UInt32 channelNumber, - [In][MarshalAs(UnmanagedType.R4)] float level, - [In][MarshalAs(UnmanagedType.LPStruct)] Guid eventContext); - - /// - /// Gets the volume level, in decibels, of the specified channel in the audio stream. - /// - /// The zero-based channel number. - /// The volume level in decibels. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int GetChannelVolumeLevel( - [In][MarshalAs(UnmanagedType.U4)] UInt32 channelNumber, - [Out][MarshalAs(UnmanagedType.R4)] out float level); - - /// - /// Gets the normalized, audio-tapered volume level of the specified channel of the audio stream. - /// - /// The zero-based channel number. - /// The volume level expressed as a normalized value between 0.0 and 1.0. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int GetChannelVolumeLevelScalar( - [In][MarshalAs(UnmanagedType.U4)] UInt32 channelNumber, - [Out][MarshalAs(UnmanagedType.R4)] out float level); - - /// - /// Sets the muting state of the audio stream. - /// - /// True to mute the stream, or false to unmute the stream. - /// A user context value that is passed to the notification callback. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int SetMute( - [In][MarshalAs(UnmanagedType.Bool)] Boolean isMuted, - [In][MarshalAs(UnmanagedType.LPStruct)] Guid eventContext); - - /// - /// Gets the muting state of the audio stream. - /// - /// The muting state. True if the stream is muted, false otherwise. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int GetMute( - [Out][MarshalAs(UnmanagedType.Bool)] out Boolean isMuted); - - /// - /// Gets information about the current step in the volume range. - /// - /// The current zero-based step index. - /// The total number of steps in the volume range. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int GetVolumeStepInfo( - [Out][MarshalAs(UnmanagedType.U4)] out UInt32 step, - [Out][MarshalAs(UnmanagedType.U4)] out UInt32 stepCount); - - /// - /// Increases the volume level by one step. - /// - /// A user context value that is passed to the notification callback. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int VolumeStepUp( - [In][MarshalAs(UnmanagedType.LPStruct)] Guid eventContext); - - /// - /// Decreases the volume level by one step. - /// - /// A user context value that is passed to the notification callback. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int VolumeStepDown( - [In][MarshalAs(UnmanagedType.LPStruct)] Guid eventContext); - - /// - /// Queries the audio endpoint device for its hardware-supported functions. - /// - /// A hardware support mask that indicates the capabilities of the endpoint. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int QueryHardwareSupport( - [Out][MarshalAs(UnmanagedType.U4)] out UInt32 hardwareSupportMask); - - /// - /// Gets the volume range of the audio stream, in decibels. - /// - /// The minimum volume level in decibels. - /// The maximum volume level in decibels. - /// The volume increment level in decibels. - /// An HRESULT code indicating whether the operation passed of failed. - [PreserveSig] - int GetVolumeRange( - [Out][MarshalAs(UnmanagedType.R4)] out float volumeMin, - [Out][MarshalAs(UnmanagedType.R4)] out float volumeMax, - [Out][MarshalAs(UnmanagedType.R4)] out float volumeStep); - } - - #endregion -} \ No newline at end of file diff --git a/AudioControl/AudioSessionService.cs b/AudioControl/AudioSessionService.cs index 76dcbdd..56db900 100644 --- a/AudioControl/AudioSessionService.cs +++ b/AudioControl/AudioSessionService.cs @@ -1,33 +1,48 @@ +using AudioControl.Infrastructure.Audio; + namespace AudioControl { - public class AudioSessionService : IAudioSessionService + public sealed class AudioSessionService : IAudioSessionService, IDisposable { + private readonly WindowsAudioSessionController controller; + public event EventHandler? AudioSessionsChanged; public AudioSessionService(IDiagnosticsSink diagnosticsSink) { - global::AudioManager.AudioManager.HandOverDiagnosticsSink(diagnosticsSink); - global::AudioManager.AudioManager.AudioSessionsChanged += (_, _) => AudioSessionsChanged?.Invoke(this, EventArgs.Empty); + controller = new WindowsAudioSessionController(diagnosticsSink); + controller.AudioSessionsChanged += Controller_AudioSessionsChanged; } public string GetAudioApplications(bool includingPid) { - return global::AudioManager.AudioManager.GetAudioApplications(includingPid); + return controller.GetAudioApplications(includingPid); } public void SetApplicationVolumeByName(string appName, float level) { - global::AudioManager.AudioManager.SetApplicationVolumeByName(appName, level); + controller.SetApplicationVolumeByName(appName, level); } public void StartSessionMonitoring() { - global::AudioManager.AudioManager.StartAudioSessionMonitoring(); + controller.Start(); } public void StopSessionMonitoring() { - global::AudioManager.AudioManager.StopAudioSessionMonitoring(); + controller.Stop(); + } + + public void Dispose() + { + controller.AudioSessionsChanged -= Controller_AudioSessionsChanged; + controller.Dispose(); + } + + private void Controller_AudioSessionsChanged(object? sender, EventArgs e) + { + AudioSessionsChanged?.Invoke(this, e); } } } diff --git a/AudioControl/GameChatState.cs b/AudioControl/GameChatState.cs new file mode 100644 index 0000000..f3f10ee --- /dev/null +++ b/AudioControl/GameChatState.cs @@ -0,0 +1,21 @@ +namespace AudioControl; + +public sealed record class GameChatState +{ + public bool HardwareConnected { get; init; } + public string CurrentPort { get; init; } = "disconnected"; + public float InputVolume { get; init; } = 50f; + public float DisplayVolume { get; init; } = 50f; + public float GameVolume { get; init; } = 100f; + public float ChatVolume { get; init; } = 100f; + public bool InvertControl { get; init; } + public string NoiseReduction { get; init; } = "High"; + public bool OverlayEnabled { get; init; } = true; + public int OverlayDurationSeconds { get; init; } = 3; + public string OverlayPosition { get; init; } = "BottomCenter"; + public double OverlayOpacity { get; init; } = 0.9; + public IReadOnlyList GameApplications { get; init; } = Array.Empty(); + public IReadOnlyList ChatApplications { get; init; } = Array.Empty(); + + public static GameChatState Default { get; } = new(); +} diff --git a/AudioControl/GameChatStateChangedEventArgs.cs b/AudioControl/GameChatStateChangedEventArgs.cs new file mode 100644 index 0000000..9fa33f8 --- /dev/null +++ b/AudioControl/GameChatStateChangedEventArgs.cs @@ -0,0 +1,13 @@ +namespace AudioControl; + +public sealed class GameChatStateChangedEventArgs : EventArgs +{ + public GameChatStateChangedEventArgs(GameChatState previous, GameChatState current) + { + Previous = previous; + Current = current; + } + + public GameChatState Previous { get; } + public GameChatState Current { get; } +} diff --git a/AudioControl/GameChatStateStore.cs b/AudioControl/GameChatStateStore.cs new file mode 100644 index 0000000..ec51d39 --- /dev/null +++ b/AudioControl/GameChatStateStore.cs @@ -0,0 +1,62 @@ +using System.Linq; + +namespace AudioControl; + +public sealed class GameChatStateStore : IGameChatStateStore +{ + private readonly object sync = new(); + private GameChatState state = GameChatState.Default; + + public event EventHandler? StateChanged; + + public GameChatState Current + { + get + { + lock (sync) + { + return state; + } + } + } + + public bool Update(Func updater) + { + GameChatState previous; + GameChatState current; + + lock (sync) + { + previous = state; + current = updater(previous) ?? previous; + + if (StateEquals(previous, current)) + { + return false; + } + + state = current; + } + + StateChanged?.Invoke(this, new GameChatStateChangedEventArgs(previous, current)); + return true; + } + + private static bool StateEquals(GameChatState left, GameChatState right) + { + return left.HardwareConnected == right.HardwareConnected + && string.Equals(left.CurrentPort, right.CurrentPort, StringComparison.Ordinal) + && left.InputVolume.Equals(right.InputVolume) + && left.DisplayVolume.Equals(right.DisplayVolume) + && left.GameVolume.Equals(right.GameVolume) + && left.ChatVolume.Equals(right.ChatVolume) + && left.InvertControl == right.InvertControl + && string.Equals(left.NoiseReduction, right.NoiseReduction, StringComparison.Ordinal) + && left.OverlayEnabled == right.OverlayEnabled + && left.OverlayDurationSeconds == right.OverlayDurationSeconds + && string.Equals(left.OverlayPosition, right.OverlayPosition, StringComparison.Ordinal) + && left.OverlayOpacity.Equals(right.OverlayOpacity) + && left.GameApplications.SequenceEqual(right.GameApplications, StringComparer.OrdinalIgnoreCase) + && left.ChatApplications.SequenceEqual(right.ChatApplications, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/AudioControl/IAudioSessionService.cs b/AudioControl/IAudioSessionService.cs index 3b135b7..523f794 100644 --- a/AudioControl/IAudioSessionService.cs +++ b/AudioControl/IAudioSessionService.cs @@ -1,6 +1,6 @@ namespace AudioControl { - public interface IAudioSessionService + public interface IAudioSessionService : IDisposable { event EventHandler? AudioSessionsChanged; diff --git a/AudioControl/IGameChatStateStore.cs b/AudioControl/IGameChatStateStore.cs new file mode 100644 index 0000000..b495871 --- /dev/null +++ b/AudioControl/IGameChatStateStore.cs @@ -0,0 +1,9 @@ +namespace AudioControl; + +public interface IGameChatStateStore +{ + event EventHandler? StateChanged; + + GameChatState Current { get; } + bool Update(Func updater); +} diff --git a/AudioControl/Infrastructure/Audio/CoreAudio/CoreAudioInterop.cs b/AudioControl/Infrastructure/Audio/CoreAudio/CoreAudioInterop.cs new file mode 100644 index 0000000..a9dc865 --- /dev/null +++ b/AudioControl/Infrastructure/Audio/CoreAudio/CoreAudioInterop.cs @@ -0,0 +1,287 @@ +using System.Runtime.InteropServices; + +namespace AudioControl.Infrastructure.Audio.CoreAudio; + +internal enum EDataFlow +{ + Render, + Capture, + All, + EnumCount +} + +internal enum ERole +{ + Console, + Multimedia, + Communications, + EnumCount +} + +internal enum AudioSessionState +{ + Inactive, + Active, + Expired +} + +internal enum AudioSessionDisconnectReason +{ + DeviceRemoval, + ServerShutdown, + FormatChanged, + SessionLogoff, + SessionDisconnected, + ExclusiveModeOverride +} + +[ComImport] +[Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] +internal class MMDeviceEnumerator +{ +} + +[ComImport] +[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IMMDeviceEnumerator +{ + [PreserveSig] + int EnumAudioEndpoints(EDataFlow dataFlow, uint stateMask, out IntPtr devices); + + [PreserveSig] + int GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role, out IMMDevice device); + + [PreserveSig] + int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device); + + [PreserveSig] + int RegisterEndpointNotificationCallback(IMMNotificationClient client); + + [PreserveSig] + int UnregisterEndpointNotificationCallback(IMMNotificationClient client); +} + +[ComImport] +[Guid("D666063F-1587-4E43-81F1-B948E807363F")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IMMDevice +{ + [PreserveSig] + int Activate(ref Guid iid, uint clsContext, IntPtr activationParams, [MarshalAs(UnmanagedType.IUnknown)] out object activatedInterface); + + [PreserveSig] + int OpenPropertyStore(uint access, out IntPtr properties); + + [PreserveSig] + int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id); + + [PreserveSig] + int GetState(out uint state); +} + +[ComImport] +[Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IAudioSessionManager2 +{ + [PreserveSig] + int GetAudioSessionControl(ref Guid audioSessionGuid, uint streamFlags, IntPtr sessionControl, out IAudioSessionControl session); + + [PreserveSig] + int GetSimpleAudioVolume(ref Guid audioSessionGuid, uint streamFlags, out ISimpleAudioVolume volume); + + [PreserveSig] + int GetSessionEnumerator(out IAudioSessionEnumerator sessionEnumerator); + + [PreserveSig] + int RegisterSessionNotification(IAudioSessionNotification notification); + + [PreserveSig] + int UnregisterSessionNotification(IAudioSessionNotification notification); + + [PreserveSig] + int GetSessionInstanceIdentifier([MarshalAs(UnmanagedType.LPWStr)] string sessionInstanceIdentifier, out IAudioSessionControl session); + + [PreserveSig] + int RegisterDuckNotification([MarshalAs(UnmanagedType.LPWStr)] string sessionId, IntPtr duckNotification); + + [PreserveSig] + int UnregisterDuckNotification(IntPtr duckNotification); +} + +[ComImport] +[Guid("BFA971F1-4D5E-40BB-935E-967039BFBEED")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IAudioSessionControl +{ + [PreserveSig] + int GetState(out AudioSessionState state); + + [PreserveSig] + int GetDisplayName([MarshalAs(UnmanagedType.LPWStr)] out string displayName); + + [PreserveSig] + int SetDisplayName([MarshalAs(UnmanagedType.LPWStr)] string displayName, ref Guid eventContext); + + [PreserveSig] + int GetIconPath([MarshalAs(UnmanagedType.LPWStr)] out string iconPath); + + [PreserveSig] + int SetIconPath([MarshalAs(UnmanagedType.LPWStr)] string iconPath, ref Guid eventContext); + + [PreserveSig] + int GetGroupingParam(out Guid groupingParam); + + [PreserveSig] + int SetGroupingParam(ref Guid groupingParam, ref Guid eventContext); + + [PreserveSig] + int RegisterAudioSessionNotification(IAudioSessionEvents events); + + [PreserveSig] + int UnregisterAudioSessionNotification(IAudioSessionEvents events); +} + +[ComImport] +[Guid("BFB7FF88-7239-4FC9-8FA2-07C950BE9C6D")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IAudioSessionControl2 +{ + [PreserveSig] + int GetState(out AudioSessionState state); + + [PreserveSig] + int GetDisplayName([MarshalAs(UnmanagedType.LPWStr)] out string displayName); + + [PreserveSig] + int SetDisplayName([MarshalAs(UnmanagedType.LPWStr)] string displayName, ref Guid eventContext); + + [PreserveSig] + int GetIconPath([MarshalAs(UnmanagedType.LPWStr)] out string iconPath); + + [PreserveSig] + int SetIconPath([MarshalAs(UnmanagedType.LPWStr)] string iconPath, ref Guid eventContext); + + [PreserveSig] + int GetGroupingParam(out Guid groupingParam); + + [PreserveSig] + int SetGroupingParam(ref Guid groupingParam, ref Guid eventContext); + + [PreserveSig] + int RegisterAudioSessionNotification(IAudioSessionEvents events); + + [PreserveSig] + int UnregisterAudioSessionNotification(IAudioSessionEvents events); + + [PreserveSig] + int GetSessionIdentifier([MarshalAs(UnmanagedType.LPWStr)] out string identifier); + + [PreserveSig] + int GetSessionInstanceIdentifier([MarshalAs(UnmanagedType.LPWStr)] out string instanceIdentifier); + + [PreserveSig] + int GetProcessId(out uint processId); + + [PreserveSig] + int IsSystemSoundsSession(); + + [PreserveSig] + int SetDuckingPreference([MarshalAs(UnmanagedType.Bool)] bool optOut); +} + +[ComImport] +[Guid("E2F5BB11-0570-40CA-ACDD-3AA01277DEE8")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IAudioSessionEnumerator +{ + [PreserveSig] + int GetCount(out int sessionCount); + + [PreserveSig] + int GetSession(int index, out IAudioSessionControl2 session); +} + +[ComImport] +[Guid("87CE5498-68D6-44E5-9215-6DA47EF883D8")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface ISimpleAudioVolume +{ + [PreserveSig] + int SetMasterVolume(float level, ref Guid eventContext); + + [PreserveSig] + int GetMasterVolume(out float level); + + [PreserveSig] + int SetMute([MarshalAs(UnmanagedType.Bool)] bool mute, ref Guid eventContext); + + [PreserveSig] + int GetMute([MarshalAs(UnmanagedType.Bool)] out bool mute); +} + +[Guid("641DD20B-4D41-49CC-ABA3-174B9477BB08")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IAudioSessionNotification +{ + [PreserveSig] + int OnSessionCreated(IntPtr newSession); +} + +[Guid("24918ACC-64B3-37C1-8CA9-74A66E9957A8")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IAudioSessionEvents +{ + [PreserveSig] + int OnDisplayNameChanged([MarshalAs(UnmanagedType.LPWStr)] string newDisplayName, ref Guid eventContext); + + [PreserveSig] + int OnIconPathChanged([MarshalAs(UnmanagedType.LPWStr)] string newIconPath, ref Guid eventContext); + + [PreserveSig] + int OnSimpleVolumeChanged(float volume, [MarshalAs(UnmanagedType.Bool)] bool mute, ref Guid eventContext); + + [PreserveSig] + int OnChannelVolumeChanged(uint channelCount, IntPtr newChannelVolume, uint changedChannel, ref Guid eventContext); + + [PreserveSig] + int OnGroupingParamChanged(ref Guid newGroupingParam, ref Guid eventContext); + + [PreserveSig] + int OnStateChanged(AudioSessionState newState); + + [PreserveSig] + int OnSessionDisconnected(AudioSessionDisconnectReason disconnectReason); +} + +[Guid("7991EEC9-7E89-4D85-8390-6C703CEC60C0")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IMMNotificationClient +{ + [PreserveSig] + int OnDeviceStateChanged([MarshalAs(UnmanagedType.LPWStr)] string deviceId, uint newState); + + [PreserveSig] + int OnDeviceAdded([MarshalAs(UnmanagedType.LPWStr)] string deviceId); + + [PreserveSig] + int OnDeviceRemoved([MarshalAs(UnmanagedType.LPWStr)] string deviceId); + + [PreserveSig] + int OnDefaultDeviceChanged(EDataFlow flow, ERole role, [MarshalAs(UnmanagedType.LPWStr)] string defaultDeviceId); + + [PreserveSig] + int OnPropertyValueChanged([MarshalAs(UnmanagedType.LPWStr)] string deviceId, IntPtr key); +} + +internal static class CoreAudioHResult +{ + internal static void ThrowIfFailed(int hresult, string operation) + { + if (hresult < 0) + { + throw new COMException($"{operation} failed with HRESULT 0x{hresult:X8}.", hresult); + } + } +} diff --git a/AudioControl/Infrastructure/Audio/WindowsAudioSessionController.cs b/AudioControl/Infrastructure/Audio/WindowsAudioSessionController.cs new file mode 100644 index 0000000..b33e7e4 --- /dev/null +++ b/AudioControl/Infrastructure/Audio/WindowsAudioSessionController.cs @@ -0,0 +1,850 @@ +using System.Diagnostics; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; +using System.Threading; +using AudioControl.Infrastructure.Audio.CoreAudio; + +namespace AudioControl.Infrastructure.Audio; + +internal sealed class WindowsAudioSessionController : IDisposable +{ + private const uint CoInitMultithreaded = 0; + private const uint ClsCtxAll = 0x17; + private const int HResultNotFound = unchecked((int)0x80070490); + private const int HResultDeviceInvalidated = unchecked((int)0x88890004); + + [Flags] + private enum MonitoringAction + { + None = 0, + RefreshSessions = 1, + RebindDefaultDevice = 2 + } + + [Flags] + private enum AudioChangeKind + { + None = 0, + SessionCreated = 1, + SessionChanged = 2, + DeviceChanged = 4, + DeviceRebound = 8 + } + + private readonly object sync = new(); + private readonly IDiagnosticsSink diagnosticsSink; + private readonly MtaWorker audioWorker; + private readonly AutoResetEvent monitoringSignal = new(false); + private Thread? monitoringThread; + private ManualResetEventSlim? stopSignal; + private bool disposed; + private int pendingMonitoringActions; + private int pendingRefreshReasons; + private IAudioSessionManager2? monitoringSessionManager; + private IAudioSessionEnumerator? monitoringSessionEnumerator; + private IMMDevice? monitoringDevice; + private IMMDeviceEnumerator? monitoringDeviceEnumerator; + private AudioSessionNotificationCallback? sessionNotification; + private EndpointNotificationCallback? endpointNotification; + private readonly List monitoredSessions = new(); + + public WindowsAudioSessionController(IDiagnosticsSink diagnosticsSink) + { + this.diagnosticsSink = diagnosticsSink; + audioWorker = new MtaWorker("AudioSessionComWorker", LogIfEnabled); + } + + public event EventHandler? AudioSessionsChanged; + + public void Start() + { + lock (sync) + { + ObjectDisposedException.ThrowIf(disposed, this); + + if (monitoringThread is { IsAlive: true }) + { + return; + } + + stopSignal = new ManualResetEventSlim(false); + monitoringThread = new Thread(MonitoringThreadEntry) + { + IsBackground = true, + Name = "AudioSessionMonitoring" + }; + monitoringThread.SetApartmentState(ApartmentState.MTA); + monitoringThread.Start(); + } + } + + public void Stop() + { + Thread? thread; + ManualResetEventSlim? signal; + + lock (sync) + { + signal = stopSignal; + thread = monitoringThread; + signal?.Set(); + } + + if (thread != null && thread != Thread.CurrentThread) + { + thread.Join(TimeSpan.FromSeconds(2)); + } + + lock (sync) + { + if (monitoringThread == thread) + { + monitoringThread = null; + stopSignal?.Dispose(); + stopSignal = null; + } + } + } + + public string GetAudioApplications(bool includeProcessId) + { + return audioWorker.Invoke(() => GetAudioApplicationsCore(includeProcessId)); + } + + public void SetApplicationVolumeByName(string applicationName, float level) + { + audioWorker.Invoke(() => SetApplicationVolumeByNameCore(applicationName, level)); + } + + private string GetAudioApplicationsCore(bool includeProcessId) + { + SessionContext? context = null; + try + { + context = SessionContext.Open(); + } + catch (COMException exception) when (IsAudioEndpointUnavailable(exception)) + { + LogIfEnabled($"AudioSessionMonitoring: no audio endpoint while listing applications: {FormatException(exception)}"); + RequestMonitoringAction(MonitoringAction.RebindDefaultDevice); + return string.Empty; + } + + using (context) + { + var result = new List(); + context.Enumerator.GetCount(out var count).ThrowIfFailed("IAudioSessionEnumerator.GetCount"); + + for (var index = 0; index < count; index++) + { + IAudioSessionControl2? session = null; + try + { + context.Enumerator.GetSession(index, out session).ThrowIfFailed("IAudioSessionEnumerator.GetSession"); + session.GetProcessId(out var processId).ThrowIfFailed("IAudioSessionControl2.GetProcessId"); + + string processName; + try + { + processName = Process.GetProcessById((int)processId).ProcessName; + } + catch (ArgumentException) + { + continue; + } + + result.Add(includeProcessId ? $"{processName}({processId})" : processName); + } + finally + { + Release(session); + } + } + + return string.Join("\r\n", result) + (result.Count == 0 ? string.Empty : "\r\n"); + } + } + + private void SetApplicationVolumeByNameCore(string applicationName, float level) + { + SessionContext? context = null; + try + { + context = SessionContext.Open(); + } + catch (COMException exception) when (IsAudioEndpointUnavailable(exception)) + { + LogIfEnabled($"AudioSessionMonitoring: no audio endpoint while setting application volume: {FormatException(exception)}"); + RequestMonitoringAction(MonitoringAction.RebindDefaultDevice); + return; + } + + using (context) + { + context.Enumerator.GetCount(out var count).ThrowIfFailed("IAudioSessionEnumerator.GetCount"); + var normalizedLevel = Math.Clamp(level / 100f, 0f, 1f); + + for (var index = 0; index < count; index++) + { + IAudioSessionControl2? session = null; + ISimpleAudioVolume? volume = null; + try + { + context.Enumerator.GetSession(index, out session).ThrowIfFailed("IAudioSessionEnumerator.GetSession"); + session.GetProcessId(out var processId).ThrowIfFailed("IAudioSessionControl2.GetProcessId"); + + string processName; + try + { + processName = Process.GetProcessById((int)processId).ProcessName; + } + catch (ArgumentException) + { + continue; + } + + if (!applicationName.Contains(processName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + volume = session as ISimpleAudioVolume + ?? throw new InvalidOperationException("Audio session does not expose ISimpleAudioVolume."); + var eventContext = Guid.Empty; + volume.SetMasterVolume(normalizedLevel, ref eventContext).ThrowIfFailed("ISimpleAudioVolume.SetMasterVolume"); + LogIfEnabled($"{processName}.Volume={level}"); + } + finally + { + Release(volume); + Release(session); + } + } + } + } + + public void Dispose() + { + lock (sync) + { + if (disposed) + { + return; + } + + disposed = true; + } + + Stop(); + audioWorker.Dispose(); + monitoringSignal.Dispose(); + } + + private sealed class MtaWorker : IDisposable + { + private readonly Queue queue = new(); + private readonly object queueSync = new(); + private readonly AutoResetEvent signal = new(false); + private readonly Thread thread; + private readonly Action? onSlowInvoke; + private bool disposed; + + public MtaWorker(string name, Action? onSlowInvoke = null) + { + this.onSlowInvoke = onSlowInvoke; + thread = new Thread(ThreadEntry) + { + IsBackground = true, + Name = name + }; + thread.SetApartmentState(ApartmentState.MTA); + thread.Start(); + } + + public void Invoke(Action action) + { + Invoke(() => + { + action(); + return null; + }); + } + + public T Invoke(Func func) + { + ExceptionDispatchInfo? error = null; + T? result = default; + using var completed = new ManualResetEventSlim(false); + + Enqueue(() => + { + try + { + result = func(); + } + catch (Exception ex) + { + error = ExceptionDispatchInfo.Capture(ex); + } + finally + { + completed.Set(); + } + }); + + if (!completed.Wait(TimeSpan.FromSeconds(2))) + { + onSlowInvoke?.Invoke($"{thread.Name}: Invoke wartet länger als 2s."); + completed.Wait(); + } + + error?.Throw(); + return result!; + } + + public void Dispose() + { + lock (queueSync) + { + if (disposed) + { + return; + } + + disposed = true; + queue.Enqueue(static () => { }); + signal.Set(); + } + + if (Thread.CurrentThread != thread) + { + thread.Join(TimeSpan.FromSeconds(2)); + } + + signal.Dispose(); + } + + private void Enqueue(Action action) + { + lock (queueSync) + { + ObjectDisposedException.ThrowIf(disposed, this); + queue.Enqueue(action); + signal.Set(); + } + } + + private void ThreadEntry() + { + while (true) + { + Action? workItem = null; + + lock (queueSync) + { + if (queue.Count > 0) + { + workItem = queue.Dequeue(); + } + else if (disposed) + { + return; + } + } + + if (workItem == null) + { + signal.WaitOne(); + continue; + } + + workItem(); + + if (disposed) + { + lock (queueSync) + { + if (queue.Count == 0) + { + return; + } + } + } + } + } + } + + private void MonitoringThreadEntry() + { + var initializeResult = NativeMethods.CoInitializeEx(IntPtr.Zero, CoInitMultithreaded); + var initialized = initializeResult >= 0; + + try + { + if (!initialized) + { + CoreAudioHResult.ThrowIfFailed(initializeResult, "CoInitializeEx"); + return; + } + + monitoringDeviceEnumerator = (IMMDeviceEnumerator)new MMDeviceEnumerator(); + sessionNotification = new AudioSessionNotificationCallback(this); + endpointNotification = new EndpointNotificationCallback(this); + monitoringDeviceEnumerator.RegisterEndpointNotificationCallback(endpointNotification) + .ThrowIfFailed("IMMDeviceEnumerator.RegisterEndpointNotificationCallback"); + + BindToCurrentDefaultDevice(); + MonitoringLoop(); + } + catch (Exception exception) + { + LogIfEnabled($"AudioSessionMonitoring: monitoring thread failed: {FormatException(exception)}"); + } + finally + { + CleanupMonitoring(); + if (initialized) + { + NativeMethods.CoUninitialize(); + } + } + } + + private void MonitoringLoop() + { + if (stopSignal == null) + { + return; + } + + var waitHandles = new WaitHandle[] { stopSignal.WaitHandle, monitoringSignal }; + + while (true) + { + var signaled = WaitHandle.WaitAny(waitHandles); + if (signaled == 0) + { + return; + } + + ProcessPendingMonitoringActions(); + } + } + + private void ProcessPendingMonitoringActions() + { + var actions = (MonitoringAction)Interlocked.Exchange(ref pendingMonitoringActions, 0); + if (actions == MonitoringAction.None) + { + return; + } + + if ((actions & MonitoringAction.RebindDefaultDevice) != 0) + { + RebindToDefaultDevice(); + } + if ((actions & MonitoringAction.RefreshSessions) != 0) + { + RefreshSessionsSnapshot(); + var refreshReasons = (AudioChangeKind)Interlocked.Exchange(ref pendingRefreshReasons, 0); + if (refreshReasons == AudioChangeKind.None) + { + refreshReasons = AudioChangeKind.SessionChanged; + } + + NotifyAudioSessionsChanged(refreshReasons); + } + } + + private void RefreshSessionsSnapshot() + { + if (monitoringSessionEnumerator == null) + { + return; + } + + monitoringSessionEnumerator.GetCount(out var count).ThrowIfFailed("IAudioSessionEnumerator.GetCount"); + RefreshMonitoredSessions(count); + } + + private void RequestMonitoringAction(MonitoringAction action) + { + Interlocked.Or(ref pendingMonitoringActions, (int)action); + monitoringSignal.Set(); + } + + private void RequestSessionRefresh(AudioChangeKind reason) + { + Interlocked.Or(ref pendingRefreshReasons, (int)reason); + RequestMonitoringAction(MonitoringAction.RefreshSessions); + } + + private void RequestDefaultDeviceRebind(EDataFlow flow, ERole role) + { + if (flow != EDataFlow.Render || role != ERole.Multimedia) + { + return; + } + + RequestMonitoringAction(MonitoringAction.RebindDefaultDevice); + } + + private void RebindToDefaultDevice() + { + try + { + ReleaseDeviceScopedObjects(); + BindToCurrentDefaultDevice(); + NotifyAudioSessionsChanged(AudioChangeKind.DeviceRebound); + } + catch (COMException exception) when (IsAudioEndpointUnavailable(exception)) + { + LogIfEnabled($"AudioSessionMonitoring: default endpoint unavailable during rebind: {FormatException(exception)}"); + } + catch (Exception exception) + { + LogIfEnabled($"AudioSessionMonitoring: Rebind default device failed: {FormatException(exception)}"); + } + } + + private void BindToCurrentDefaultDevice() + { + if (monitoringDeviceEnumerator == null || sessionNotification == null) + { + return; + } + + monitoringDeviceEnumerator.GetDefaultAudioEndpoint(EDataFlow.Render, ERole.Multimedia, out monitoringDevice) + .ThrowIfFailed("IMMDeviceEnumerator.GetDefaultAudioEndpoint"); + + var managerId = typeof(IAudioSessionManager2).GUID; + monitoringDevice!.Activate(ref managerId, ClsCtxAll, IntPtr.Zero, out var activated) + .ThrowIfFailed("IMMDevice.Activate(IAudioSessionManager2)"); + monitoringSessionManager = (IAudioSessionManager2)activated; + + monitoringSessionManager.RegisterSessionNotification(sessionNotification) + .ThrowIfFailed("IAudioSessionManager2.RegisterSessionNotification"); + + monitoringSessionManager.GetSessionEnumerator(out monitoringSessionEnumerator) + .ThrowIfFailed("IAudioSessionManager2.GetSessionEnumerator"); + RefreshSessionsSnapshot(); + LogIfEnabled("AudioSessionMonitoring: Default render device bound."); + } + + private void RefreshMonitoredSessions(int count) + { + DisposeMonitoredSessions(); + + for (var index = 0; index < count; index++) + { + IAudioSessionControl2? control = null; + try + { + monitoringSessionEnumerator!.GetSession(index, out control) + .ThrowIfFailed("IAudioSessionEnumerator.GetSession"); + var monitoredSession = new MonitoredSession(control, this); + monitoredSessions.Add(monitoredSession); + control = null; + } + finally + { + Release(control); + } + } + } + + private void CleanupMonitoring() + { + if (monitoringDeviceEnumerator != null && endpointNotification != null) + { + TryUnregister(() => monitoringDeviceEnumerator.UnregisterEndpointNotificationCallback(endpointNotification), "IMMDeviceEnumerator.UnregisterEndpointNotificationCallback"); + } + ReleaseDeviceScopedObjects(); + Release(monitoringDeviceEnumerator); + monitoringDeviceEnumerator = null; + sessionNotification = null; + endpointNotification = null; + } + + private void ReleaseDeviceScopedObjects() + { + if (monitoringSessionManager != null && sessionNotification != null) + { + TryUnregister(() => monitoringSessionManager.UnregisterSessionNotification(sessionNotification), "IAudioSessionManager2.UnregisterSessionNotification"); + } + + DisposeMonitoredSessions(); + Release(monitoringSessionEnumerator); + Release(monitoringSessionManager); + Release(monitoringDevice); + monitoringSessionEnumerator = null; + monitoringSessionManager = null; + monitoringDevice = null; + } + + private void DisposeMonitoredSessions() + { + foreach (var session in monitoredSessions) + { + session.Dispose(); + } + + monitoredSessions.Clear(); + } + + private void NotifyAudioSessionsChanged(AudioChangeKind changeKind) + { + ThreadPool.QueueUserWorkItem(_ => + { + try + { + LogIfEnabled($"AudioSessionMonitoring: change detected ({changeKind})."); + AudioSessionsChanged?.Invoke(this, EventArgs.Empty); + } + catch (Exception exception) + { + LogIfEnabled($"AudioSessionMonitoring: application event failed: {FormatException(exception)}"); + } + }); + } + + private void LogIfEnabled(string message) + { + if (diagnosticsSink.DebugEnabled) + { + diagnosticsSink.Log(message); + } + } + + private void TryUnregister(Func unregister, string operation) + { + try + { + unregister().ThrowIfFailed(operation); + } + catch (Exception exception) + { + LogIfEnabled($"AudioSessionMonitoring: {operation} failed: {FormatException(exception)}"); + } + } + + private static string FormatException(Exception exception) + { + return exception is COMException comException + ? $"{exception.Message} (HRESULT=0x{comException.HResult:X8})" + : exception.Message; + } + + private static bool IsAudioEndpointUnavailable(COMException exception) + { + if (exception.HResult == HResultNotFound || exception.HResult == HResultDeviceInvalidated) + { + return true; + } + + return exception.Message.Contains("IMMDeviceEnumerator.GetDefaultAudioEndpoint", StringComparison.Ordinal) + || exception.Message.Contains("IMMDevice.Activate(IAudioSessionManager2)", StringComparison.Ordinal); + } + + private static void Release(object? comObject) + { + if (comObject != null && Marshal.IsComObject(comObject)) + { + Marshal.FinalReleaseComObject(comObject); + } + } + + private sealed class SessionContext : IDisposable + { + private readonly bool comInitialized; + private IMMDeviceEnumerator? deviceEnumerator; + private IMMDevice? device; + private IAudioSessionManager2? manager; + + private SessionContext(bool comInitialized, IMMDeviceEnumerator deviceEnumerator, IMMDevice device, IAudioSessionManager2 manager, IAudioSessionEnumerator enumerator) + { + this.comInitialized = comInitialized; + this.deviceEnumerator = deviceEnumerator; + this.device = device; + this.manager = manager; + Enumerator = enumerator; + } + + public IAudioSessionEnumerator Enumerator { get; } + + public static SessionContext Open() + { + var initializeResult = NativeMethods.CoInitializeEx(IntPtr.Zero, CoInitMultithreaded); + CoreAudioHResult.ThrowIfFailed(initializeResult, "CoInitializeEx"); + + try + { + var deviceEnumerator = (IMMDeviceEnumerator)new MMDeviceEnumerator(); + deviceEnumerator.GetDefaultAudioEndpoint(EDataFlow.Render, ERole.Multimedia, out var device) + .ThrowIfFailed("IMMDeviceEnumerator.GetDefaultAudioEndpoint"); + var managerId = typeof(IAudioSessionManager2).GUID; + device.Activate(ref managerId, ClsCtxAll, IntPtr.Zero, out var activated) + .ThrowIfFailed("IMMDevice.Activate(IAudioSessionManager2)"); + var manager = (IAudioSessionManager2)activated; + manager.GetSessionEnumerator(out var enumerator) + .ThrowIfFailed("IAudioSessionManager2.GetSessionEnumerator"); + return new SessionContext(true, deviceEnumerator, device, manager, enumerator); + } + catch + { + NativeMethods.CoUninitialize(); + throw; + } + } + + public void Dispose() + { + Release(Enumerator); + Release(manager); + Release(device); + Release(deviceEnumerator); + if (comInitialized) + { + NativeMethods.CoUninitialize(); + } + } + } + + private sealed class MonitoredSession : IDisposable + { + private readonly IAudioSessionControl2 control; + private readonly SessionEventsCallback events; + private readonly WindowsAudioSessionController owner; + private bool disposed; + + public MonitoredSession(IAudioSessionControl2 control, WindowsAudioSessionController owner) + { + this.control = control; + this.owner = owner; + events = new SessionEventsCallback(owner); + control.RegisterAudioSessionNotification(events).ThrowIfFailed("IAudioSessionControl2.RegisterAudioSessionNotification"); + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + owner.TryUnregister(() => control.UnregisterAudioSessionNotification(events), "IAudioSessionControl2.UnregisterAudioSessionNotification"); + Release(control); + } + } + + [ComVisible(true)] + [ClassInterface(ClassInterfaceType.None)] + private sealed class AudioSessionNotificationCallback : IAudioSessionNotification + { + private readonly WindowsAudioSessionController owner; + + public AudioSessionNotificationCallback(WindowsAudioSessionController owner) + { + this.owner = owner; + } + + public int OnSessionCreated(IntPtr newSession) + { + owner.RequestSessionRefresh(AudioChangeKind.SessionCreated); + return 0; + } + } + + [ComVisible(true)] + [ClassInterface(ClassInterfaceType.None)] + private sealed class SessionEventsCallback : IAudioSessionEvents + { + private readonly WindowsAudioSessionController owner; + + public SessionEventsCallback(WindowsAudioSessionController owner) + { + this.owner = owner; + } + + public int OnDisplayNameChanged(string newDisplayName, ref Guid eventContext) => Notify(); + public int OnIconPathChanged(string newIconPath, ref Guid eventContext) => Notify(); + public int OnSimpleVolumeChanged(float volume, bool mute, ref Guid eventContext) => Notify(); + public int OnChannelVolumeChanged(uint channelCount, IntPtr newChannelVolume, uint changedChannel, ref Guid eventContext) => Notify(); + public int OnGroupingParamChanged(ref Guid newGroupingParam, ref Guid eventContext) => Notify(); + public int OnStateChanged(AudioSessionState newState) => Notify(); + public int OnSessionDisconnected(AudioSessionDisconnectReason disconnectReason) => Notify(); + + private int Notify() + { + owner.RequestSessionRefresh(AudioChangeKind.SessionChanged); + return 0; + } + } + + [ComVisible(true)] + [ClassInterface(ClassInterfaceType.None)] + private sealed class EndpointNotificationCallback : IMMNotificationClient + { + private readonly WindowsAudioSessionController owner; + + public EndpointNotificationCallback(WindowsAudioSessionController owner) + { + this.owner = owner; + } + + public int OnDeviceStateChanged(string deviceId, uint newState) + { + owner.RequestMonitoringAction(MonitoringAction.RebindDefaultDevice); + return NotifyDeviceChanged(); + } + + public int OnDeviceAdded(string deviceId) + { + owner.RequestMonitoringAction(MonitoringAction.RebindDefaultDevice); + return NotifyDeviceChanged(); + } + + public int OnDeviceRemoved(string deviceId) + { + owner.RequestMonitoringAction(MonitoringAction.RebindDefaultDevice); + return NotifyDeviceChanged(); + } + + public int OnDefaultDeviceChanged(EDataFlow flow, ERole role, string defaultDeviceId) + { + owner.RequestDefaultDeviceRebind(flow, role); + return NotifyDeviceChanged(); + } + + public int OnPropertyValueChanged(string deviceId, IntPtr key) + { + owner.RequestMonitoringAction(MonitoringAction.RebindDefaultDevice); + return NotifyDeviceChanged(); + } + + private int NotifyDeviceChanged() + { + owner.NotifyAudioSessionsChanged(AudioChangeKind.DeviceChanged); + return 0; + } + } + + private static class NativeMethods + { + [DllImport("ole32.dll")] + internal static extern int CoInitializeEx(IntPtr reserved, uint coInit); + + [DllImport("ole32.dll")] + internal static extern void CoUninitialize(); + } +} + +internal static class CoreAudioHResultExtensions +{ + internal static int ThrowIfFailed(this int hresult, string operation) + { + CoreAudioHResult.ThrowIfFailed(hresult, operation); + return hresult; + } +} diff --git a/AudioControl/LegacyApplicationHostFactory.cs b/AudioControl/LegacyApplicationHostFactory.cs index b2b3ed2..0239693 100644 --- a/AudioControl/LegacyApplicationHostFactory.cs +++ b/AudioControl/LegacyApplicationHostFactory.cs @@ -6,7 +6,8 @@ public IApplicationHost Create() { var settings = new SettingsStore(); var autoStart = new AutoStartService(); - var uiHost = new TrayIconApplicationHost(settings, autoStart); + var stateStore = new GameChatStateStore(); + var uiHost = new TrayIconApplicationHost(settings, autoStart, stateStore); var serial = new SerialDeviceService(uiHost); var usb = new UsbWatcherService(); var audioSessions = new AudioSessionService(uiHost); diff --git a/AudioControl/TrayIconApplicationHost.cs b/AudioControl/TrayIconApplicationHost.cs index 7cc6e47..f52dc5b 100644 --- a/AudioControl/TrayIconApplicationHost.cs +++ b/AudioControl/TrayIconApplicationHost.cs @@ -36,7 +36,9 @@ internal sealed class TrayIconApplicationHost : IApplicationHost, ISerialHostBri private readonly ISettingsStore settingsStore; private readonly IAutoStartService autoStartService; + private readonly IGameChatStateStore stateStore; private readonly AppAssignmentPathStore appAssignmentPathStore; + private readonly AppAssignmentManager appAssignmentManager; private readonly List debugMessages = new(); private readonly object debugMessagesSync = new(); @@ -64,11 +66,18 @@ internal sealed class TrayIconApplicationHost : IApplicationHost, ISerialHostBri private bool started; private bool overlayPrimed; - public TrayIconApplicationHost(ISettingsStore settingsStore, IAutoStartService autoStartService) + public TrayIconApplicationHost(ISettingsStore settingsStore, IAutoStartService autoStartService, IGameChatStateStore stateStore) { this.settingsStore = settingsStore; this.autoStartService = autoStartService; + this.stateStore = stateStore; appAssignmentPathStore = new AppAssignmentPathStore(); + appAssignmentManager = new AppAssignmentManager(settingsStore, appAssignmentPathStore, () => audioSessionService); + appAssignmentManager.AssignmentsChanged += (_, _) => + { + SyncState(); + Dispatch(() => uiCoordinator?.RefreshAppAssignments()); + }; overlayService = new WpfOverlayService(TimeSpan.FromSeconds(3), Log); } @@ -100,6 +109,7 @@ public void Start() uiThreadId = Environment.CurrentManagedThreadId; connected = serialDeviceService?.IsConnected() == true; currentPort = serialDeviceService?.CurrentPort ?? "disconnected"; + SyncState(); trayIcon = new NotifyIcon { @@ -128,9 +138,13 @@ public void Start() startWithWindowsSetter: ApplyStartWithWindowsFromWpf, invertControlProvider: () => settingsStore.Invert, invertControlSetter: ApplyInvertControlFromWpf, - connectedProvider: () => connected, - currentPortProvider: () => currentPort, - balanceProvider: () => (displayVolume, gameVolume, chatVolume)); + connectedProvider: () => stateStore.Current.HardwareConnected, + currentPortProvider: () => stateStore.Current.CurrentPort, + balanceProvider: () => + { + var state = stateStore.Current; + return (state.DisplayVolume, state.GameVolume, state.ChatVolume); + }); trayHostService.RefreshState(); hotkeyWindow = new HotkeyMessageWindow(HandleHotkeyMessage); @@ -229,12 +243,14 @@ public void Dispatch(Action action) public void SetSystrayCom(string value) { currentPort = string.IsNullOrWhiteSpace(value) ? "disconnected" : value; + SyncState(); Dispatch(() => trayHostService?.SetConnectedState(connected, currentPort)); } public void SetConnected(bool connected) { this.connected = connected; + SyncState(); Dispatch(() => { UpdateSoftwareControlHotkeys(this.connected); @@ -279,6 +295,7 @@ private void SerialDeviceService_ConnectionStatusChanged(object? sender, EventAr { connected = serialDeviceService?.IsConnected() == true; currentPort = serialDeviceService?.CurrentPort ?? "disconnected"; + SyncState(); UpdateSoftwareControlHotkeys(connected); trayHostService?.SetConnectedState(connected, currentPort); uiCoordinator?.SetHardwareStatus(); @@ -302,13 +319,14 @@ private void TrayIcon_MouseUp(object? sender, MouseEventArgs e) private void ShowMainWindowFromTray() { + var state = stateStore.Current; var showDebugOption = (GetAsyncKeyState(VkShift) & 0x8000) != 0; uiCoordinator?.ShowMainWindow(showDebugOption); uiCoordinator?.SetHardwareStatus(); uiCoordinator?.SetStartWithWindowsState(GetStartWithWindowsFromWpf()); - uiCoordinator?.SetNoiseReductionSelection(settingsStore.NoiseReduction); - uiCoordinator?.SetInvertControlState(settingsStore.Invert); - uiCoordinator?.SetArduinoValue(displayVolume); + uiCoordinator?.SetNoiseReductionSelection(state.NoiseReduction); + uiCoordinator?.SetInvertControlState(state.InvertControl); + uiCoordinator?.SetArduinoValue(state.DisplayVolume); uiCoordinator?.RefreshAppAssignments(); uiCoordinator?.RefreshDebugMessages(); } @@ -320,11 +338,15 @@ private void ExitFromTray() private MainWindow CreateMainWindow() { - var window = new MainWindow( - () => (connected, currentPort), - () => settingsStore.NoiseReduction, + var viewModel = new MainWindowViewModel( + () => + { + var state = stateStore.Current; + return (state.HardwareConnected, state.CurrentPort); + }, + () => stateStore.Current.NoiseReduction, ApplyNoiseReductionFromWpf, - () => settingsStore.Invert, + () => stateStore.Current.InvertControl, ApplyInvertControlFromWpf, () => settingsStore.Game, ApplyGameAppsFromWpf, @@ -339,15 +361,16 @@ private MainWindow CreateMainWindow() ApplySoftwareBalanceFromWpf, GetStartWithWindowsFromWpf, ApplyStartWithWindowsFromWpf, - () => settingsStore.OverlayEnabled, + () => stateStore.Current.OverlayEnabled, ApplyOverlayEnabledFromWpf, - () => Math.Clamp((int)Math.Round(settingsStore.OverlayDurationSeconds * 1000.0), 500, 10000), + () => Math.Clamp(stateStore.Current.OverlayDurationSeconds * 1000, 500, 10000), ApplyOverlayDurationFromWpf, - () => settingsStore.OverlayPosition, + () => stateStore.Current.OverlayPosition, ApplyOverlayPositionFromWpf, - () => settingsStore.OverlayOpacity, - ApplyOverlayOpacityFromWpf, - showDebugOption: false); + () => stateStore.Current.OverlayOpacity, + ApplyOverlayOpacityFromWpf); + + var window = new MainWindow(viewModel, showDebugOption: false); window.Closed += (_, _) => wpfMainWindow = null; return window; @@ -355,54 +378,34 @@ private MainWindow CreateMainWindow() private IEnumerable GetAvailableAppsForWpf() { - if (audioSessionService == null) - { - return Enumerable.Empty(); - } - - var gameApps = ParseCsv(settingsStore.Game).ToHashSet(StringComparer.OrdinalIgnoreCase); - var chatApps = ParseCsv(settingsStore.Chat).ToHashSet(StringComparer.OrdinalIgnoreCase); - - return audioSessionService.GetAudioApplications(false) - .Split("\r\n", StringSplitOptions.RemoveEmptyEntries) - .Select(item => item.Trim()) - .Where(item => !string.IsNullOrWhiteSpace(item)) - .Where(item => !item.Equals("Idle", StringComparison.OrdinalIgnoreCase)) - .Where(item => !gameApps.Contains(item) && !chatApps.Contains(item)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + return appAssignmentManager.GetAvailableApps(); } private string? ResolveAssignedAppPathForWpf(string appName) { - return appAssignmentPathStore.GetPathOrResolve(appName); + return appAssignmentManager.ResolveAssignedAppPath(appName); } private void PersistResolvedAssignedAppPathForWpf(string appName, string exePath) { - appAssignmentPathStore.SetPath(appName, exePath); + appAssignmentManager.PersistResolvedAssignedAppPath(appName, exePath); } private void ApplyGameAppsFromWpf(string csv) { - settingsStore.Game = csv ?? string.Empty; - settingsStore.ScheduleSave(); - appAssignmentPathStore.SyncAssignedApps(ParseCsv(settingsStore.Game), ParseCsv(settingsStore.Chat)); - uiCoordinator?.RefreshAppAssignments(); + appAssignmentManager.ApplyGameApps(csv); } private void ApplyChatAppsFromWpf(string csv) { - settingsStore.Chat = csv ?? string.Empty; - settingsStore.ScheduleSave(); - appAssignmentPathStore.SyncAssignedApps(ParseCsv(settingsStore.Game), ParseCsv(settingsStore.Chat)); - uiCoordinator?.RefreshAppAssignments(); + appAssignmentManager.ApplyChatApps(csv); } private void ApplyNoiseReductionFromWpf(string level) { settingsStore.NoiseReduction = string.IsNullOrWhiteSpace(level) ? "High" : level; settingsStore.ScheduleSave(); + SyncState(); uiCoordinator?.SetNoiseReductionSelection(settingsStore.NoiseReduction); uiCoordinator?.SetNoiseReductionApplying(settingsStore.NoiseReduction); @@ -421,6 +424,7 @@ private void ApplyInvertControlFromWpf(bool invert) { settingsStore.Invert = invert; settingsStore.ScheduleSave(); + SyncState(); trayHostService?.SetInvertControlState(invert); uiCoordinator?.SetInvertControlState(invert); ApplyBalance(lastInputVolume); @@ -471,6 +475,7 @@ private void ApplyOverlayEnabledFromWpf(bool enabled) { settingsStore.OverlayEnabled = enabled; settingsStore.ScheduleSave(); + SyncState(); ApplyOverlaySettings(); } @@ -479,6 +484,7 @@ private void ApplyOverlayDurationFromWpf(int durationMs) var seconds = Math.Clamp((int)Math.Round(durationMs / 1000.0), 1, 10); settingsStore.OverlayDurationSeconds = seconds; settingsStore.ScheduleSave(); + SyncState(); ApplyOverlaySettings(); } @@ -486,6 +492,7 @@ private void ApplyOverlayPositionFromWpf(string position) { settingsStore.OverlayPosition = NormalizeOverlayPosition(position); settingsStore.ScheduleSave(); + SyncState(); ApplyOverlaySettings(); } @@ -493,6 +500,7 @@ private void ApplyOverlayOpacityFromWpf(double opacity) { settingsStore.OverlayOpacity = Math.Clamp(opacity, 0.2, 1.0); settingsStore.ScheduleSave(); + SyncState(); ApplyOverlaySettings(); } @@ -521,6 +529,7 @@ private void ApplyBalance(float inputVolume) displayVolume = result.DisplayVolume; gameVolume = result.GameVolume; chatVolume = result.ChatVolume; + SyncState(); trayHostService?.SetBalance(displayVolume, gameVolume, chatVolume); uiCoordinator?.SetArduinoValue(displayVolume); @@ -664,7 +673,7 @@ private void HandleHotkeyMessage(Message message) private void ApplyStep(float delta) { - var current = Math.Clamp(displayVolume, 0f, 100f); + var current = Math.Clamp(stateStore.Current.DisplayVolume, 0f, 100f); var isOnFivePercentGrid = Math.Abs(current % 5f) < 0.01f || Math.Abs((current % 5f) - 5f) < 0.01f; float nextValue; @@ -693,7 +702,7 @@ private void ApplyStep(float delta) private void ApplyCenter() { const float centerValue = 50f; - var current = Math.Clamp(displayVolume, 0f, 100f); + var current = Math.Clamp(stateStore.Current.DisplayVolume, 0f, 100f); if (Math.Abs(current - centerValue) < 0.01f) { return; @@ -710,6 +719,27 @@ private static IEnumerable ParseCsv(string csv) .Where(x => !string.IsNullOrWhiteSpace(x)); } + private void SyncState() + { + stateStore.Update(current => current with + { + HardwareConnected = connected, + CurrentPort = currentPort, + InputVolume = lastInputVolume, + DisplayVolume = displayVolume, + GameVolume = gameVolume, + ChatVolume = chatVolume, + InvertControl = settingsStore.Invert, + NoiseReduction = settingsStore.NoiseReduction, + OverlayEnabled = settingsStore.OverlayEnabled, + OverlayDurationSeconds = settingsStore.OverlayDurationSeconds, + OverlayPosition = NormalizeOverlayPosition(settingsStore.OverlayPosition), + OverlayOpacity = settingsStore.OverlayOpacity, + GameApplications = ParseCsv(settingsStore.Game).ToArray(), + ChatApplications = ParseCsv(settingsStore.Chat).ToArray() + }); + } + private static string MapNoiseReductionToCommand(string noiseReduction) { return noiseReduction switch diff --git a/GameChatBalancer.Wpf/GameChatBalancer.Wpf.csproj b/GameChatBalancer.Wpf/GameChatBalancer.Wpf.csproj index afac7b4..1de0a53 100644 --- a/GameChatBalancer.Wpf/GameChatBalancer.Wpf.csproj +++ b/GameChatBalancer.Wpf/GameChatBalancer.Wpf.csproj @@ -3,6 +3,7 @@ WinExe net10.0-windows + 2.0.1 enable enable true diff --git a/GameChatBalancer.Wpf/MainWindow.xaml.cs b/GameChatBalancer.Wpf/MainWindow.xaml.cs index 191508d..6f9fab3 100644 --- a/GameChatBalancer.Wpf/MainWindow.xaml.cs +++ b/GameChatBalancer.Wpf/MainWindow.xaml.cs @@ -58,6 +58,7 @@ public partial class MainWindow : Window private readonly Action overlayPositionSetter; private readonly Func overlayOpacityProvider; private readonly Action overlayOpacitySetter; + private readonly MainWindowViewModel viewModel; private bool showDebugOption; private readonly IAppIconService appIconService; private bool suppressHardwareOptionEvents; @@ -80,33 +81,12 @@ public MainWindow( Action noiseReductionSetter, Func invertControlProvider, Action invertControlSetter) - : this( - hardwareStatusProvider, - noiseReductionProvider, - noiseReductionSetter, - invertControlProvider, - invertControlSetter, - () => string.Empty, - _ => { }, - () => string.Empty, - _ => { }, - () => Enumerable.Empty(), - _ => null, - (_, _) => { }, - () => Enumerable.Empty(), - _ => { }, - _ => { }, - _ => { }, - () => false, - _ => { }, - () => true, - _ => { }, - () => 3, - _ => { }, - () => "BottomCenter", - _ => { }, - () => 0.9, - _ => { }, + : this(MainWindowViewModel.CreateDefault( + hardwareStatusProvider, + noiseReductionProvider, + noiseReductionSetter, + invertControlProvider, + invertControlSetter), false) { } @@ -139,33 +119,66 @@ public MainWindow( Func overlayOpacityProvider, Action overlayOpacitySetter, bool showDebugOption) + : this(new MainWindowViewModel( + hardwareStatusProvider, + noiseReductionProvider, + noiseReductionSetter, + invertControlProvider, + invertControlSetter, + gameAppsProvider, + gameAppsSetter, + chatAppsProvider, + chatAppsSetter, + availableAppsProvider, + assignedAppPathProvider, + assignedAppPathPersister, + debugMessagesProvider, + debugLogAction, + debugModeSetter, + softwareBalanceSetter, + startWithWindowsProvider, + startWithWindowsSetter, + overlayEnabledProvider, + overlayEnabledSetter, + overlayDurationProvider, + overlayDurationSetter, + overlayPositionProvider, + overlayPositionSetter, + overlayOpacityProvider, + overlayOpacitySetter), + showDebugOption) { - this.hardwareStatusProvider = hardwareStatusProvider; - this.noiseReductionProvider = noiseReductionProvider; - this.noiseReductionSetter = noiseReductionSetter; - this.invertControlProvider = invertControlProvider; - this.invertControlSetter = invertControlSetter; - this.gameAppsProvider = gameAppsProvider; - this.gameAppsSetter = gameAppsSetter; - this.chatAppsProvider = chatAppsProvider; - this.chatAppsSetter = chatAppsSetter; - this.availableAppsProvider = availableAppsProvider; - this.assignedAppPathProvider = assignedAppPathProvider; - this.assignedAppPathPersister = assignedAppPathPersister; - this.debugMessagesProvider = debugMessagesProvider; - this.debugLogAction = debugLogAction; - this.debugModeSetter = debugModeSetter; - this.softwareBalanceSetter = softwareBalanceSetter; - this.startWithWindowsProvider = startWithWindowsProvider; - this.startWithWindowsSetter = startWithWindowsSetter; - this.overlayEnabledProvider = overlayEnabledProvider; - this.overlayEnabledSetter = overlayEnabledSetter; - this.overlayDurationProvider = overlayDurationProvider; - this.overlayDurationSetter = overlayDurationSetter; - this.overlayPositionProvider = overlayPositionProvider; - this.overlayPositionSetter = overlayPositionSetter; - this.overlayOpacityProvider = overlayOpacityProvider; - this.overlayOpacitySetter = overlayOpacitySetter; + } + + public MainWindow(MainWindowViewModel viewModel, bool showDebugOption) + { + this.viewModel = viewModel; + this.hardwareStatusProvider = viewModel.HardwareStatusProvider; + this.noiseReductionProvider = viewModel.NoiseReductionProvider; + this.noiseReductionSetter = viewModel.NoiseReductionSetter; + this.invertControlProvider = viewModel.InvertControlProvider; + this.invertControlSetter = viewModel.InvertControlSetter; + this.gameAppsProvider = viewModel.GameAppsProvider; + this.gameAppsSetter = viewModel.GameAppsSetter; + this.chatAppsProvider = viewModel.ChatAppsProvider; + this.chatAppsSetter = viewModel.ChatAppsSetter; + this.availableAppsProvider = viewModel.AvailableAppsProvider; + this.assignedAppPathProvider = viewModel.AssignedAppPathProvider; + this.assignedAppPathPersister = viewModel.AssignedAppPathPersister; + this.debugMessagesProvider = viewModel.DebugMessagesProvider; + this.debugLogAction = viewModel.DebugLogAction; + this.debugModeSetter = viewModel.DebugModeSetter; + this.softwareBalanceSetter = viewModel.SoftwareBalanceSetter; + this.startWithWindowsProvider = viewModel.StartWithWindowsProvider; + this.startWithWindowsSetter = viewModel.StartWithWindowsSetter; + this.overlayEnabledProvider = viewModel.OverlayEnabledProvider; + this.overlayEnabledSetter = viewModel.OverlayEnabledSetter; + this.overlayDurationProvider = viewModel.OverlayDurationProvider; + this.overlayDurationSetter = viewModel.OverlayDurationSetter; + this.overlayPositionProvider = viewModel.OverlayPositionProvider; + this.overlayPositionSetter = viewModel.OverlayPositionSetter; + this.overlayOpacityProvider = viewModel.OverlayOpacityProvider; + this.overlayOpacitySetter = viewModel.OverlayOpacitySetter; this.showDebugOption = showDebugOption; appIconService = new ProcessAppIconService(); diff --git a/GameChatBalancer.Wpf/MainWindowViewModel.cs b/GameChatBalancer.Wpf/MainWindowViewModel.cs new file mode 100644 index 0000000..3154605 --- /dev/null +++ b/GameChatBalancer.Wpf/MainWindowViewModel.cs @@ -0,0 +1,123 @@ +namespace GameChatBalancer.Wpf; + +public sealed class MainWindowViewModel +{ + public MainWindowViewModel( + Func<(bool Connected, string CurrentPort)> hardwareStatusProvider, + Func noiseReductionProvider, + Action noiseReductionSetter, + Func invertControlProvider, + Action invertControlSetter, + Func gameAppsProvider, + Action gameAppsSetter, + Func chatAppsProvider, + Action chatAppsSetter, + Func> availableAppsProvider, + Func assignedAppPathProvider, + Action assignedAppPathPersister, + Func> debugMessagesProvider, + Action debugLogAction, + Action debugModeSetter, + Action softwareBalanceSetter, + Func startWithWindowsProvider, + Action startWithWindowsSetter, + Func overlayEnabledProvider, + Action overlayEnabledSetter, + Func overlayDurationProvider, + Action overlayDurationSetter, + Func overlayPositionProvider, + Action overlayPositionSetter, + Func overlayOpacityProvider, + Action overlayOpacitySetter) + { + HardwareStatusProvider = hardwareStatusProvider; + NoiseReductionProvider = noiseReductionProvider; + NoiseReductionSetter = noiseReductionSetter; + InvertControlProvider = invertControlProvider; + InvertControlSetter = invertControlSetter; + GameAppsProvider = gameAppsProvider; + GameAppsSetter = gameAppsSetter; + ChatAppsProvider = chatAppsProvider; + ChatAppsSetter = chatAppsSetter; + AvailableAppsProvider = availableAppsProvider; + AssignedAppPathProvider = assignedAppPathProvider; + AssignedAppPathPersister = assignedAppPathPersister; + DebugMessagesProvider = debugMessagesProvider; + DebugLogAction = debugLogAction; + DebugModeSetter = debugModeSetter; + SoftwareBalanceSetter = softwareBalanceSetter; + StartWithWindowsProvider = startWithWindowsProvider; + StartWithWindowsSetter = startWithWindowsSetter; + OverlayEnabledProvider = overlayEnabledProvider; + OverlayEnabledSetter = overlayEnabledSetter; + OverlayDurationProvider = overlayDurationProvider; + OverlayDurationSetter = overlayDurationSetter; + OverlayPositionProvider = overlayPositionProvider; + OverlayPositionSetter = overlayPositionSetter; + OverlayOpacityProvider = overlayOpacityProvider; + OverlayOpacitySetter = overlayOpacitySetter; + } + + public Func<(bool Connected, string CurrentPort)> HardwareStatusProvider { get; } + public Func NoiseReductionProvider { get; } + public Action NoiseReductionSetter { get; } + public Func InvertControlProvider { get; } + public Action InvertControlSetter { get; } + public Func GameAppsProvider { get; } + public Action GameAppsSetter { get; } + public Func ChatAppsProvider { get; } + public Action ChatAppsSetter { get; } + public Func> AvailableAppsProvider { get; } + public Func AssignedAppPathProvider { get; } + public Action AssignedAppPathPersister { get; } + public Func> DebugMessagesProvider { get; } + public Action DebugLogAction { get; } + public Action DebugModeSetter { get; } + public Action SoftwareBalanceSetter { get; } + public Func StartWithWindowsProvider { get; } + public Action StartWithWindowsSetter { get; } + public Func OverlayEnabledProvider { get; } + public Action OverlayEnabledSetter { get; } + public Func OverlayDurationProvider { get; } + public Action OverlayDurationSetter { get; } + public Func OverlayPositionProvider { get; } + public Action OverlayPositionSetter { get; } + public Func OverlayOpacityProvider { get; } + public Action OverlayOpacitySetter { get; } + + public static MainWindowViewModel CreateDefault( + Func<(bool Connected, string CurrentPort)> hardwareStatusProvider, + Func noiseReductionProvider, + Action noiseReductionSetter, + Func invertControlProvider, + Action invertControlSetter) + { + return new MainWindowViewModel( + hardwareStatusProvider, + noiseReductionProvider, + noiseReductionSetter, + invertControlProvider, + invertControlSetter, + () => string.Empty, + _ => { }, + () => string.Empty, + _ => { }, + () => Enumerable.Empty(), + _ => null, + (_, _) => { }, + () => Enumerable.Empty(), + _ => { }, + _ => { }, + _ => { }, + () => false, + _ => { }, + () => true, + _ => { }, + () => 3, + _ => { }, + () => "BottomCenter", + _ => { }, + () => 0.9, + _ => { }); + } +} diff --git a/README.md b/README.md index 61a6a1b..3d0e7c2 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,133 @@ -# GameChatBalancer -GameChatBalancer is an **opensource hardware audio balancer** for Windows PCs. The functionality is inspired by the Chatmix / Gamevoice mix function of some available headsets. +# GameChatBalancer (GCB) -GameChatBalancer lets you use an Arduino (or compatible) based knob or slider to **balance the volume of chat-software and games** so that you can **easily -tune the audio volume while playing** without any interruption by just turning a knob or moving a slider. +GameChatBalancer is a Windows desktop tool that lets you control the **game/chat volume balance** with a physical Arduino-compatible knob or slider. -The project consists of two components. A small **systray application** for your PC and an Arduino based **cheap to build** hardware. +Instead of opening the Windows mixer mid-game, you can instantly shift volume between voice chat and game audio with dedicated hardware input. GCB maps selected processes to **Game** and **Chat** groups and applies volume changes in real time. -# Table of contents -- [Features](https://github.com/TheSoundCoder/GameChatBalancer#Features) -- [Build Hardware](https://github.com/TheSoundCoder/GameChatBalancer#build-hardware) - - [Schematic](https://github.com/TheSoundCoder/GameChatBalancer#schematic) - - [Material](https://github.com/TheSoundCoder/GameChatBalancer#material) - - [Housing](https://github.com/TheSoundCoder/GameChatBalancer#housing) -- [Make your own GameChatBalancer](https://github.com/TheSoundCoder/GameChatBalancer#make-your-own-gamechatbalancer) -- [Licences](https://github.com/TheSoundCoder/GameChatBalancer#licences) -- [Roadmap](https://github.com/TheSoundCoder/GameChatBalancer#roadmap) -- [Special Thanks](https://github.com/TheSoundCoder/GameChatBalancer#special-thanks) -- [Help & Feedback](https://github.com/TheSoundCoder/GameChatBalancer/#help--feedback-wanted) +--- -# Features -1. Hardware - - Only sends data via the serial port if the chose volume level changed -> **no permanent datastream** - - **Noise reduction** in case you are using cheap components - especially the potentiometer - - Levels: Off, Low, Medium, High (default) - - Adjustable at runtime via Systray Application +## What GCB does -2. Systray Application - - Easy configuration via GUI - Assign applications via **drag and drop** - - Assign **multiple applications** to the "Game" and "Chat" category - - **Auto-detect** the Arduino based hardware - - The application detects when the hardware is removed and will automatically reconnect when plugged in again (**USB event based**) - - User settings are stored automatically and restored during application startup - - Invert the values received by the hardware in case your wiring is mixed up +- Reads input from Arduino-compatible hardware over serial. +- Converts the hardware position to a Game/Chat balance value. +- Applies the balance to configured Windows audio sessions. +- Provides a systray-first UX with optional full WPF window. +- Persists user settings and restores them automatically on startup. +The app remains **event-driven** (USB/connectivity state, UI state, audio updates), avoiding unnecessary polling where event-based flows are possible. -See the application in action: +--- -https://user-images.githubusercontent.com/130736237/235218694-252510ab-6273-4d05-bc87-e7d56919cefa.mp4 +## What improved since v1.0 +Compared to the initial public version, GCB now includes major improvements in reliability, UX, and architecture: -This is how it look like in the Windows Audio mixer: +- **Modern WPF UI direction** with Fluent-inspired styling. +- **Improved systray workflow** and clearer connected/disconnected states. +- **Software Control fallback** when no Arduino is connected. +- **Event-based reconnect behavior** for hardware attach/detach. +- **More robust icon pipeline** (app icon, systray icon, in-app assets). +- **Single-file publish compatibility improvements** (including tray icon fallback to embedded EXE icon). +- **Refactored host architecture** to decouple legacy form dependencies. +- **Autostart state consistency fixes** between UI toggle, settings persistence, and Run registry key. -https://user-images.githubusercontent.com/130736237/235218894-d49eed7c-69e4-4d56-9448-dae65230d700.mp4 +--- +## Screenshots (add here) -# Build Hardware -Everything you need to build your own GameChatBalancer ist listed in the [Pricing indication](https://github.com/TheSoundCoder/AudioControl#pricing-indication). -- The potentiometer is connected to an analog pin of your Arduino (or compatible) board. It is powered from the board's 5V (or 3.3V for a Seeed board) output (see schematic) - - GND = black - - 5V / 3.3V = red - - Signal = green -- The board is conected via an USB cable to the PC. **Make sure to check what kind of USB cable is needed for your board.** -- As listed in the Pricing indication I used a **Seeed Xiao SAMD21 Cortex M0+** instead of an Arduino. It comes cheap (5,60€), has a lot of analog connectors and a very small footprint (20mm x 17,5mm x 3,5mm) so that it fits into a small housing. Last but not least it has a USB-C connector. - -## Schematic -In the schematic below, you can find two options: -1. *Left* is the setup with the *Seeed Xiao SAMD21 Cortex M0+*. **Ensure to connect the potentiometer to the 3.3V output** of the board as the 5V will probably kill the board's microcontroller. -2. *Right* is the setup with an *Arduino*. +> The sections below are intentionally prepared as placeholders. +> Replace paths once screenshots are available. -![](https://github.com/TheSoundCoder/AudioControl/blob/master/assets/GameChatBalancer_schematic_2.png) +### 1) Main Window (Home) + +![Main Window - Home](assets/screenshots/main-window-home.png) -## Material -**Please keep in mind that a cheap potentiometer can cause noise**, so that noise reduction needs to be switched on in the Systray application. -I use a ~ 3€ poti with a **low tolerance of +-5%** in my device and noise reduction switched off. Cheap potentiometers usually have a tolerance of +-20%. +### 2) Systray Popup (Connected) + +![Systray Popup - Connected](assets/screenshots/systray-connected.png) -**Important**: make sure to buy a **linear** potentiometer, not a logarithmic one! +### 3) Systray Popup (Disconnected / Software Control) + +![Systray Popup - Disconnected](assets/screenshots/systray-disconnected.png) -Item | Price (€)|Link ------|------|------- -Microcontroller: Seeed Xiao SAMD21 Cortex M0+|5,60|https://www.berrybase.de/en/seeeduino-xiao-arduino-microcontroller-samd21-cortex-m0 -Potentiometer Noname 10k (tolerance: +-20%)|0,45|https://www.berrybase.de/en/drehpotentiometer-10k-937-liegend-mono-6mm-loetoesen?c=164 -*Optional:* Potentiometer Alpha RV16AF20KB10KM (tolerance: +-5%)|2,99|https://www.conrad.de/de/p/alpha-rv16af20kb10km-dreh-potentiometer-mono-200-mw-10-k-1-st-1694304.html#productDownloads -Knob|0,65|https://www.berrybase.de/en/drehknopf-fuer-geraendelte-6-0mm-achse-mit-anzeige-16x16mm-schwarz/grau?c=164 -Copper wire isolated|1,50|https://www.berrybase.de/en/kupferschaltdraht-isoliert-oe0-5mm-10m -**Total**|**8,20 - 10,74**| +### 4) Overlay in-game + +![Overlay](assets/screenshots/overlay.png) -If you use the listed microcontroller you will additionally need a **USB-C cable**. +### 5) Windows Audio Mixer effect + +![Windows Audio Mixer result](assets/screenshots/windows-mixer-result.png) -## Housing -You can use anything as a housing for your GameChatBalancer - even a matchbox if you do not have a 3D-Printer. +--- -I decided to design and 3D-print one by myself. If you like the "design" you can download either the STL files or GCODE files [here](https://github.com/TheSoundCoder/AudioControl/tree/master/assets/3d-model). +## Key Features - +### Hardware side +- Serial values are sent only when the knob/slider value changes (no continuous data spam). +- Runtime noise-reduction levels for analog input stability: `Off`, `Low`, `Medium`, `High`. -# Make your own GameChatBalancer +### Desktop app side +- Drag-and-drop app assignment into Game and Chat groups. +- Multiple processes per group. +- Auto-detect serial device and reconnect on device changes. +- Automatic settings persistence. +- Optional invert control for hardware wiring direction. +- Start-with-Windows support. -If you want to build your own GameChatBalancer or get details on how to handle the systray application. Please have a look at the dedicated [manual](https://github.com/TheSoundCoder/GameChatBalancer/blob/master/Manual.md). +--- +## Single-file build (self-contained) -# Licences -Icons used under [Creativecommons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/legalcode) from https://icon-icons.com: -Designer | Iconpack | Icon Name | Image ----------|----------|---------|--------- -Perpixel | Mobile Basic Vol. 1 | conversation Icon | -Iconicons | Icons - line | Game, controller icon | -CoreUI | CoreUI Free | Equalizer Icon | -Steve Shoger | Zondicons | Bug Icon | -Yannick | General Icons | Refresh Icon | +```powershell +dotnet publish "AudioControl/AudioControl.csproj" -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true /p:IncludeNativeLibrariesForSelfExtract=true /p:PublishTrimmed=false +``` -# Roadmap -Depending on my free time I would like to make the complete setup flexible so that additional potentiometers are supported. Those could be used to (e.g.): -- Control the master volume of your PC -- Assign single or multiple applications to a potentiometer +Output: -I am currently thinking of how the Systray UI and configuration of the Arduino can be done flexible so that no new sketch needs to be uploaded if the overall configuration changes. +`AudioControl/bin/Release/net10.0-windows/win-x64/publish/AudioControl.exe` -# Special Thanks -I would like to thank **@sverrirs for his Gist AudioManager.cs** which accelerated the development and for sure saved me from some pain during the development. I used and modified AudioManager.cs to my needs so that GameChatBalancer is able to adjust the volume of more than one application by application name in one single call. +--- -# Help & Feedback wanted -I created a first issue where help would be very welcome. Maybe it is not an easy one, but if somebody has a good idea or would like to contribute to it, this would be very welcome. This is my first (public) repo, so please be patient with me. +## Settings storage -Feedback is also more than welcome. Please let me know if you (dis-) LIKE something or if you see issues in the code have good hints how to improve. +- User settings are saved by .NET user-scoped settings under `%LOCALAPPDATA%` (`user.config`, framework-managed path). +- App assignment path cache: + +`%LOCALAPPDATA%/GameChatBalancer/app-paths.json` + +--- + +## Hardware build + +If you want to build your own physical controller, see: + +- [Manual](Manual.md) +- Schematic and hardware assets in `/assets` + +Quick note: use a **linear** potentiometer (not logarithmic). + +--- + +## Roadmap + +- Support additional physical controls. +- Extend flexible multi-channel assignment scenarios. +- Continue modernization toward the current .NET desktop stack. + +--- + +## License & attribution + +Icon attributions and license details are retained from the original project and should stay in this repository. + +If you add or replace assets, update the attribution section accordingly. + +--- + +## Feedback + +Issues, bug reports, and focused PRs are welcome. Please include: + +- Reproduction steps +- Expected vs actual behavior +- Hardware details (board, potentiometer type, USB adapter)