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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
shell: powershell
run: |
Import-Module PSScriptAnalyzer -RequiredVersion 1.24.0
$r=Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error,Warning -ExcludeRule PSAvoidUsingWriteHost,PSAvoidUsingPlainTextForPassword,PSAvoidUsingCmdletAliases,PSAvoidUsingEmptyCatchBlock,PSUseApprovedVerbs,PSUseShouldProcessForStateChangingFunctions
$r=Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error,Warning -ExcludeRule PSAvoidUsingWriteHost,PSAvoidUsingPlainTextForPassword,PSAvoidUsingCmdletAliases,PSAvoidUsingEmptyCatchBlock,PSUseApprovedVerbs,PSUseShouldProcessForStateChangingFunctions,PSUseSingularNouns
if($r){$r|Format-Table -AutoSize|Out-String|Write-Host;exit 1}
- name: Pester
shell: powershell
Expand Down
70 changes: 23 additions & 47 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The LIGHTSPEED receiver stays connected, so you **cannot** use the presence of t
## Detection modes (config `DetectionMode`)

- `WindowsEndpoint` (default for new installs): read the headset endpoint `State` from the
`svcl.exe /scomma` export and map it:
native Core Audio endpoint list and map it:
- `Active` → `Connected` → headset.
- `Unplugged` / `NotPresent` / row absent → `Disconnected` → fallback.
- `Disabled` / `Error` / unparseable → `Unknown` → **never switch**.
Expand Down Expand Up @@ -51,29 +51,23 @@ hardened to tolerate a recreated endpoint whose `Item ID` changes.
- The installer must capture the IDs of the current system.

2. For duplicated endpoints, use `Item ID` for normal runtime targeting. During **Reconfigure only**,
if that ID disappears after a Bluetooth reconnect, re-resolve the endpoint by the two real `svcl`
identity columns (`Device Name` + `Name`) and then persist the newly observed `Item ID`. Never use
if that ID disappears after a Bluetooth reconnect, re-resolve the endpoint by the two native endpoint identity properties
(`PKEY_DeviceInterface_FriendlyName` + `PKEY_Device_DeviceDesc`) and then persist the newly observed `Item ID`. Never use
the combined display label as if it were a raw column value.

3. To set the output:
```powershell
svcl.exe /SetDefault "<Item ID>" all
```
`all` covers Console, Multimedia and Communications.

4. To read a column:
```powershell
svcl.exe /GetColumnValue "DefaultRenderDevice" "Item ID"
```
**Do NOT add `/Stdout`** to `/GetColumnValue`.

5. Verify the switch:
- run `/SetDefault`;
- re-read `DefaultRenderDevice` → `Item ID`;
- compare with the target;
3. To set the output, use the in-process Core Audio bridge in `lib/AutoSwitchCore.psm1`.
`Set-CoreAudioDefaultRenderDevice` applies the target to Console, Multimedia and Communications.

4. To read the current defaults, use `Get-CoreAudioDefaultRenderDeviceIds`.
Do not infer success from the setter alone.

5. Verify every switch:
- set all three roles;
- re-read Console, Multimedia and Communications;
- require every role to match the target;
- allow a single short retry.

6. Unknown state (svcl failure, `Disabled`, garbage):
6. Unknown state (Core Audio failure, `Disabled`, unmapped state):
- do not guess the state;
- do not switch the output;
- write a log entry;
Expand All @@ -95,22 +89,19 @@ hardened to tolerate a recreated endpoint whose `Item ID` changes.
The worker is a single synchronous loop (`Start-Sleep PollMilliseconds`), which is itself the guard
against concurrent polls — it never starts a new poll while the previous one is still running.
The main process communicates via control flags (`control/enabled.flag`, `control/stop.flag`).
Do NOT run svcl/G HUB/Set-AudioOutput on the UI thread. Pass `[System.Windows.Forms.Application]::Run($form)`
Do NOT run Core Audio/G HUB/Set-AudioOutput on the UI thread. Pass `[System.Windows.Forms.Application]::Run($form)`
an invisible `Form`; `Application.Run()` without a Form is not reliably kept alive across all .NET builds.

11. **Audio Enhancements**: toggled only for the configured `HeadsetId` via a temporary elevated
helper (`Toggle-AudioEnhancements.ps1`, `Start-Process -Verb RunAs`) that writes
`PKEY_AudioEndpoint_Disable_SysFx` (1da5d803-d492-4edd-8c23-e0c0ffee7f0e, 5) through
`IPolicyConfig::SetPropertyValue(deviceId, bFxStore=true)`, verifies, and exits. The runtime itself
is never elevated. The menu label updates only after a verified success (UAC cancel → no visual change).
Do **NOT** use `svcl /SetBooleanFxProperty` for this (individual effects only, not the global
"Disable audio enhancements" switch).

12. **COM interop lives in C#**: PowerShell 5.1 cannot cast a COM RCW to a custom `[ComImport]` interface
(`New-Object`, `Activator` or `GetTypeFromCLSID` all fail with "the COM interface cast fails at runtime"). The cast is
native in C#, so both the helper and `lib/AutoSwitchCore.psm1` compile the whole COM block with
`Add-Type` and expose a static method (`AutoSwitch.AudioEnhancements.SetSysFx`,
`AutoSwitch.EndpointFx.ReadSysFx`). Read the SysFx state with `IPolicyConfig::GetPropertyValue` on the
`Add-Type` and expose static methods for endpoint enumeration/default switching and enhancements. Read the SysFx state with `IPolicyConfig::GetPropertyValue` on the
**FxStore** (`bFxStore=true`) — the endpoint `IPropertyStore` does **not** contain
`PKEY_AudioEndpoint_Disable_SysFx`, so reading it there always reports "enabled".

Expand Down Expand Up @@ -186,27 +177,13 @@ SUBSCRIBE /battery/state/changed

To evolve the runtime, one option is to use WebSocket events and keep polling as a fallback. Do not change it without testing power-on, power-off, G HUB restart, sleep and a clean login.

## SoundVolumeCommandLine

Provider: NirSoft.

Current x64 URL in this package's version:

```text
https://www.nirsoft.net/utils/svcl-x64.zip
```

SHA-256 verified on 2026-08-07:

```text
7ba008e9ece8b3eda323ef01711e4647eb7f40b28dc25f98b2ed6a738810bfcd
```
## Native Core Audio backend

Before changing the hash:
1. check the official hashes page;
2. confirm it matches `svcl-x64.zip` exactly;
3. update the comment/date;
4. do not disable the check.
- `IMMDeviceEnumerator` / `IMMDevice` enumerate render endpoints, states and endpoint IDs.
- `IPropertyStore` reads `PKEY_Device_DeviceDesc`, `PKEY_DeviceInterface_FriendlyName` and `PKEY_Device_FriendlyName`.
- `IPolicyConfig::SetDefaultEndpoint` is used for Console, Multimedia and Communications, then all three roles are re-read and verified.
- `IPolicyConfig` is not a documented public Windows API. This project already depended on the same COM family for Audio Enhancements; keep the boundary isolated in embedded C# and fail safely on HRESULT errors.
- Clean install no longer downloads or hashes a third-party audio-control executable.

## Required tests after any change

Expand Down Expand Up @@ -261,9 +238,8 @@ Before changing the hash:

- G HUB closed (Logitech mode): do not switch output.
- Endpoint removed/recreated during Reconfigure: re-resolve by `Device Name` + `Name`, persist the newest ID, and fail without changing config if identity is ambiguous/missing. A runtime endpoint that disappears outside Reconfigure remains a diagnostic/reconfigure case; never guess a target.
- NirSoft hash differs: abort install.
- `/devices/list` does not contain the PRO X 2: fall back to WindowsEndpoint path; do not invent a `deviceId`.
- svcl `State` read fails or is `Disabled`/garbage: `Unknown`, do not switch.
- Core Audio enumeration/state read fails or is `Disabled`/unmapped: `Unknown`, do not switch.

## Possible future improvements

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]
### Changed

- Replaced the downloaded SoundVolumeCommandLine dependency with an in-process Windows Core Audio COM backend for endpoint enumeration, state reads, default-device reads and output switching.
- Default-output changes are now verified across Console, Multimedia and Communications roles before being accepted.
- Clean installs remove a stale legacy `svcl.exe` when present and no longer require a third-party audio-control download.


## [1.2.5] - 2026-08-13

Expand Down
124 changes: 42 additions & 82 deletions Instalar-PROX2-AutoSwitch.ps1
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
#requires -Version 5.1
$ErrorActionPreference = "Stop"

# PowerShell 5.1 on old .NET can negotiate TLS 1.0/1.1 and fail against
# GitHub/NirSoft. Force TLS 1.2.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

$PackageDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$InstallDir = Join-Path $env:LOCALAPPDATA "PROX2AutoSwitch"
$RuntimeSrc = Join-Path $PackageDir "Runtime-PROX2-AutoSwitch.ps1"
Expand All @@ -16,19 +12,13 @@ $IconSrc = Join-Path $PackageDir "assets\icon.ico"

$MainScript = Join-Path $InstallDir "PROX2AutoSwitch.ps1"
$ConfigPath = Join-Path $InstallDir "config.json"
$SvclPath = Join-Path $InstallDir "svcl.exe"
$LauncherVbs = Join-Path $InstallDir "Iniciar-Oculto.vbs"
$LogPath = Join-Path $InstallDir "autoswitch.log"
$HelperPath = Join-Path $InstallDir "Toggle-AudioEnhancements.ps1"

$StartupDir = [Environment]::GetFolderPath("Startup")
$ShortcutPath = Join-Path $StartupDir "PRO X 2 AutoSwitch.lnk"

$SvclUrl = "https://www.nirsoft.net/utils/svcl-x64.zip"
# Verified on the official NirSoft hashes page on 2026-08-07.
# If NirSoft updates svcl this hash will change: DO NOT disable the check.
$ExpectedSha256 = "7ba008e9ece8b3eda323ef01711e4647eb7f40b28dc25f98b2ed6a738810bfcd"
$ZipPath = Join-Path $env:TEMP "svcl-x64.zip"

foreach ($required in @($RuntimeSrc, $UninstallSrc, $VerifySrc, $ModuleSrc, $HelperSrc, $IconSrc)) {
if (-not (Test-Path $required)) {
Expand Down Expand Up @@ -66,39 +56,24 @@ Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |

Start-Sleep -Milliseconds 500

# SoundVolumeCommandLine: skip the download if svcl.exe is already installed.
if (Test-Path $SvclPath) {
Write-Host "[1/7] SoundVolumeCommandLine already installed. Skipping download." -ForegroundColor Green
}
else {
Write-Host "[1/7] Downloading SoundVolumeCommandLine from NirSoft..." -ForegroundColor Yellow
Invoke-WebRequest -UseBasicParsing -Uri $SvclUrl -OutFile $ZipPath

$ActualSha256 = (Get-FileHash -Algorithm SHA256 -Path $ZipPath).Hash.ToLowerInvariant()
if ($ActualSha256 -ne $ExpectedSha256) {
Remove-Item $ZipPath -Force -ErrorAction SilentlyContinue
throw @"
The SHA-256 of svcl-x64.zip does not match the one verified when this package was created.

Expected: $ExpectedSha256
Got: $ActualSha256

This can mean NirSoft published a new version.
Do not continue by disabling the check. Verify the current SHA-256 at:
https://www.nirsoft.net/hash_check/?software=svcl
and update ExpectedSha256 in this installer.
"@
# Native Core Audio backend: no third-party audio executable is downloaded.
Write-Host "[1/6] Checking native Windows Core Audio..." -ForegroundColor Yellow
try {
$nativeDevices = @(Get-CoreAudioRenderDevices)
if ($nativeDevices.Count -eq 0) {
throw "Windows returned no render endpoints."
}
[void](Get-CoreAudioDefaultRenderDeviceId)
}
catch {
throw "Native Windows Core Audio is unavailable: $($_.Exception.Message)"
}
Write-Host " Core Audio OK ($($nativeDevices.Count) render endpoint(s))." -ForegroundColor Green

Write-Host " SHA-256 OK." -ForegroundColor Green

Write-Host "[2/7] Installing SoundVolumeCommandLine..." -ForegroundColor Yellow
Expand-Archive -Path $ZipPath -DestinationPath $InstallDir -Force
Remove-Item $ZipPath -Force -ErrorAction SilentlyContinue

if (-not (Test-Path $SvclPath)) {
throw "svcl.exe was not found after extracting the ZIP."
}
# Remove a stale dependency left by installations older than the native backend.
$legacySvclPath = Join-Path $InstallDir "svcl.exe"
if (Test-Path $legacySvclPath) {
Remove-Item $legacySvclPath -Force -ErrorAction SilentlyContinue
}

# Copy the source version of the runtime and utilities.
Expand Down Expand Up @@ -287,23 +262,8 @@ function Invoke-GHubGet {
}

# --- Audio functions ---
function Get-DefaultColumn {
param([Parameter(Mandatory=$true)][string]$Column)

# IMPORTANT: do not use /Stdout with /GetColumnValue.
$raw = & $SvclPath /GetColumnValue "DefaultRenderDevice" $Column 2>&1
return (($raw | Out-String).Trim())
}

function Get-DefaultRenderItemId {
$text = Get-DefaultColumn "Item ID"

$id = Get-RenderItemIdFromText -Text $text
if (-not $id) {
throw "Could not extract the Item ID of the default device. Output: $text"
}

return $id
return Get-CoreAudioDefaultRenderDeviceId
}

function Test-SetDefault {
Expand All @@ -315,23 +275,23 @@ function Test-SetDefault {
Write-Host ""
Write-Host "Testing real switch -> $Label" -ForegroundColor Yellow

$out = & $SvclPath /Stdout /SetDefault $Id all 2>&1
$text = ($out | Out-String).Trim()

if ($text -match "No items found") {
Write-Host $text -ForegroundColor Red
try {
Set-CoreAudioDefaultRenderDevice -DeviceId $Id
}
catch {
Write-Host (" TEST FAILED. Core Audio error: {0}" -f $_.Exception.Message) -ForegroundColor Red
return $false
}

Start-Sleep -Milliseconds 800
$actual = Get-DefaultRenderItemId

if ($actual -ieq $Id) {
Write-Host " TEST OK" -ForegroundColor Green
if (Test-CoreAudioDefaultRenderDevice -DeviceId $Id) {
Write-Host " TEST OK (Console/Multimedia/Communications)" -ForegroundColor Green
return $true
}

Write-Host " TEST FAILED. Actual: $actual" -ForegroundColor Red
$actual = $null
try { $actual = Get-CoreAudioDefaultRenderDeviceIds } catch { }
Write-Host (" TEST FAILED. Actual roles: {0}" -f ($actual | ConvertTo-Json -Compress)) -ForegroundColor Red
return $false
}

Expand All @@ -342,14 +302,14 @@ try {
$DetectionMode = $null
$ghubHeadset = $null

Write-Host "[3/7] Selecting headset and fallback..." -ForegroundColor Yellow
Write-Host "[2/6] Selecting headset and fallback..." -ForegroundColor Yellow

$csvText = (& $SvclPath /scomma "" 2>&1 | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace($csvText)) {
throw "Could not read the Windows audio device list (svcl /scomma)."
try {
$renderRows = @(Get-CoreAudioRenderDevices)
}
catch {
throw "Could not read the Windows audio device list through Core Audio: $($_.Exception.Message)"
}

$renderRows = @(Get-SvclRenderDevice -CsvText $csvText)
if ($renderRows.Count -eq 0) {
throw "No render (output) devices found in the Windows list."
}
Expand Down Expand Up @@ -410,19 +370,19 @@ try {
[string]$EndpointName
)

$txt = (& $SvclPath /scomma "" 2>&1 | Out-String).Trim()
if (-not (Test-SvclExportValid -CsvText $txt)) {
try {
$rows = @(Get-CoreAudioRenderDevices)
}
catch {
return [pscustomobject]@{ State = 'Unknown'; FoundId = $null }
}

$rows = @(ConvertFrom-SvclCsv -Text $txt)
$row = $rows | Where-Object {
$id = Get-CsvColumn -Row $_ -Names @('Item ID')
$null -ne $id -and $id.Trim() -ieq $ItemId.Trim()
} | Select-Object -First 1

# Bluetooth can recreate an endpoint with a new Item ID after reconnect.
# Resolve the same Render endpoint by its real svcl identity rather than
# Resolve the same Render endpoint by its native Core Audio identity rather than
# treating the user-facing "Device Name — Name" label as one column.
if (-not $row -and
(-not [string]::IsNullOrWhiteSpace($DeviceName) -or
Expand Down Expand Up @@ -564,7 +524,7 @@ try {
}

Write-Host ""
Write-Host "[4/7] Calibrating Windows outputs..." -ForegroundColor Yellow
Write-Host "[3/6] Calibrating Windows outputs..." -ForegroundColor Yellow
Write-Host "No old IDs are kept: the current Windows ones are captured." -ForegroundColor DarkGray

# In both modes we already have the IDs captured from the Windows list.
Expand All @@ -578,7 +538,7 @@ try {
}

Write-Host ""
Write-Host "[5/7] Validating audio switches before installing..." -ForegroundColor Yellow
Write-Host "[4/6] Validating audio switches before installing..." -ForegroundColor Yellow

$okHeadset = Test-SetDefault `
-Id $headsetOutput.ItemId `
Expand Down Expand Up @@ -647,7 +607,7 @@ try {
}
}

Write-Host "[6/7] Setting up invisible startup..." -ForegroundColor Yellow
Write-Host "[5/6] Setting up invisible startup..." -ForegroundColor Yellow

$PowerShellExe = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
$WScriptExe = Join-Path $env:SystemRoot "System32\wscript.exe"
Expand Down Expand Up @@ -684,7 +644,7 @@ Set shell = Nothing
Where-Object { $_.CommandLine -match $escapedMain } |
Select-Object -First 1

Write-Host "[7/7] Finalizing..." -ForegroundColor Yellow
Write-Host "[6/6] Finalizing..." -ForegroundColor Yellow
Write-Host ""

if ($running) {
Expand Down
Loading