Add managed Windows remote audio stack
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[ValidateSet('start', 'run', 'stop', 'toggle', 'status', 'logs', 'latency', 'setup')]
|
||||
[string]$Command = 'status',
|
||||
[Parameter(Position = 1)]
|
||||
[string]$Value,
|
||||
[switch]$Force,
|
||||
[switch]$NonInteractive,
|
||||
[int]$Period = 0
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = $PSScriptRoot
|
||||
$configPath = Join-Path $root 'audio.json'
|
||||
$runtime = Join-Path $root '.runtime'
|
||||
$statePath = Join-Path $runtime 'state.json'
|
||||
$stopPath = Join-Path $runtime 'stop.request'
|
||||
$logDir = Join-Path $runtime 'logs'
|
||||
$registryPath = 'HKLM:\SOFTWARE\VB-Audio\Cable'
|
||||
$registryValue = 'VBAudioHFVAIO'
|
||||
. (Join-Path $root 'deps.ps1')
|
||||
|
||||
function Read-Config {
|
||||
if (-not (Test-Path -LiteralPath $configPath)) { throw "Missing $configPath" }
|
||||
$c = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
|
||||
if ($Period) { $c.period = $Period }
|
||||
foreach ($name in @('jackd', 'bridge', 'jacktrip', 'jackTools')) {
|
||||
if ([string]::IsNullOrWhiteSpace($c.$name)) { throw "Missing $name path in audio.json" }
|
||||
if (-not [IO.Path]::IsPathRooted($c.$name)) { $c.$name = Join-Path $root $c.$name }
|
||||
}
|
||||
if ($c.host -notmatch '^[a-zA-Z0-9][a-zA-Z0-9.-]*$') { throw 'Invalid host in audio.json' }
|
||||
if ($c.rate -lt 8000 -or $c.period -lt 32 -or $c.channels -lt 2) { throw 'Invalid rate, period, or channels in audio.json' }
|
||||
if (($c.period -band ($c.period - 1)) -ne 0) { throw 'JACK period must be a power of two' }
|
||||
if ($c.forceIPv4 -isnot [bool]) { throw 'forceIPv4 must be true or false' }
|
||||
if ($c.preventPowerThrottling -isnot [bool]) { throw 'preventPowerThrottling must be true or false' }
|
||||
return $c
|
||||
}
|
||||
|
||||
function Resolve-JackTripAddress($c) {
|
||||
if (-not $c.forceIPv4) { return $c.host }
|
||||
try {
|
||||
$address = [Net.Dns]::GetHostAddresses($c.host) |
|
||||
Where-Object { $_.AddressFamily -eq [Net.Sockets.AddressFamily]::InterNetwork } |
|
||||
Select-Object -First 1
|
||||
} catch { throw "Could not resolve IPv4 address for $($c.host): $($_.Exception.Message)" }
|
||||
if (-not $address) { throw "No IPv4 address found for $($c.host). Set forceIPv4 to false or use an IPv4-capable host." }
|
||||
return $address.IPAddressToString
|
||||
}
|
||||
|
||||
function Read-State {
|
||||
if (-not (Test-Path -LiteralPath $statePath)) { return $null }
|
||||
try { return Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json } catch { return $null }
|
||||
}
|
||||
|
||||
function Get-LiveSupervisor($state) {
|
||||
if (-not $state) { return $null }
|
||||
$p = Get-Process -Id $state.supervisorPid -ErrorAction SilentlyContinue
|
||||
if (-not $p) { return $null }
|
||||
try {
|
||||
if ($p.StartTime.ToUniversalTime().Ticks -ne [long]$state.supervisorStartTicks) { return $null }
|
||||
return $p
|
||||
} catch { return $null }
|
||||
}
|
||||
|
||||
function Save-State($state) {
|
||||
$tmp = "$statePath.tmp"
|
||||
$state | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $tmp -Encoding utf8
|
||||
Move-Item -LiteralPath $tmp -Destination $statePath -Force
|
||||
}
|
||||
|
||||
function Assert-NoUnmanagedProcesses {
|
||||
$found = @(Get-Process -Name jackd, jacktrip, VBCABLE_AsioBridge -ErrorAction SilentlyContinue)
|
||||
if ($found.Count) {
|
||||
$names = ($found | ForEach-Object { "$($_.ProcessName) PID $($_.Id)" }) -join ', '
|
||||
throw "Existing audio processes detected ($names). Stop those manually before 'start'; this tool will only stop processes it launched."
|
||||
}
|
||||
}
|
||||
|
||||
function Initialize-JobApi {
|
||||
if ('AudioJob' -as [type]) { return }
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class AudioJob {
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct BasicLimits {
|
||||
public long PerProcessUserTimeLimit, PerJobUserTimeLimit;
|
||||
public uint LimitFlags;
|
||||
public UIntPtr MinimumWorkingSetSize, MaximumWorkingSetSize;
|
||||
public uint ActiveProcessLimit;
|
||||
public UIntPtr Affinity;
|
||||
public uint PriorityClass, SchedulingClass;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct IoCounters {
|
||||
public ulong ReadOperationCount, WriteOperationCount, OtherOperationCount;
|
||||
public ulong ReadTransferCount, WriteTransferCount, OtherTransferCount;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct ExtendedLimits {
|
||||
public BasicLimits BasicLimitInformation;
|
||||
public IoCounters IoInfo;
|
||||
public UIntPtr ProcessMemoryLimit, JobMemoryLimit, PeakProcessMemoryUsed, PeakJobMemoryUsed;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct PowerThrottlingState {
|
||||
public uint Version, ControlMask, StateMask;
|
||||
}
|
||||
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
|
||||
public static extern IntPtr CreateJobObject(IntPtr securityAttributes, string name);
|
||||
[DllImport("kernel32.dll", SetLastError=true)]
|
||||
public static extern bool SetInformationJobObject(IntPtr job, int infoClass, IntPtr info, uint length);
|
||||
[DllImport("kernel32.dll", SetLastError=true)]
|
||||
public static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
|
||||
[DllImport("kernel32.dll", SetLastError=true)]
|
||||
public static extern bool CloseHandle(IntPtr handle);
|
||||
[DllImport("kernel32.dll", SetLastError=true)]
|
||||
public static extern bool SetProcessInformation(IntPtr process, int infoClass,
|
||||
ref PowerThrottlingState state, uint size);
|
||||
[DllImport("kernel32.dll", SetLastError=true)]
|
||||
public static extern bool GetProcessInformation(IntPtr process, int infoClass,
|
||||
ref PowerThrottlingState state, uint size);
|
||||
public static void ProtectAudioTiming(IntPtr process) {
|
||||
// ProcessPowerThrottling = 4. ControlMask selects the two policies;
|
||||
// StateMask = 0 disables EcoQoS and timer-resolution suppression.
|
||||
var state = new PowerThrottlingState { Version = 1, ControlMask = 0x5, StateMask = 0 };
|
||||
if (!SetProcessInformation(process, 4, ref state, (uint)Marshal.SizeOf(typeof(PowerThrottlingState))))
|
||||
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
public static IntPtr CreateKillOnCloseJob() {
|
||||
IntPtr job = CreateJobObject(IntPtr.Zero, null);
|
||||
if (job == IntPtr.Zero) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
||||
var limits = new ExtendedLimits();
|
||||
limits.BasicLimitInformation.LimitFlags = 0x2000; // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
int size = Marshal.SizeOf(typeof(ExtendedLimits));
|
||||
IntPtr ptr = Marshal.AllocHGlobal(size);
|
||||
try {
|
||||
Marshal.StructureToPtr(limits, ptr, false);
|
||||
if (!SetInformationJobObject(job, 9, ptr, (uint)size))
|
||||
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
||||
return job;
|
||||
} catch { CloseHandle(job); throw; }
|
||||
finally { Marshal.FreeHGlobal(ptr); }
|
||||
}
|
||||
public static void Add(IntPtr job, IntPtr process) {
|
||||
if (!AssignProcessToJobObject(job, process))
|
||||
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
|
||||
function Start-ManagedProcess([string]$name, [string]$path, [string[]]$arguments, [IntPtr]$job, [string]$priority = 'Normal', [bool]$visible = $false, [bool]$protectTiming = $false) {
|
||||
$out = Join-Path $logDir "$name.out.log"
|
||||
$err = Join-Path $logDir "$name.err.log"
|
||||
$startOptions = @{ FilePath = $path; PassThru = $true; RedirectStandardOutput = $out; RedirectStandardError = $err }
|
||||
if ($arguments.Count) { $startOptions.ArgumentList = $arguments }
|
||||
if (-not $visible) { $startOptions.WindowStyle = 'Hidden' }
|
||||
$p = Start-Process @startOptions
|
||||
try {
|
||||
[AudioJob]::Add($job, $p.Handle)
|
||||
$p.PriorityClass = [System.Enum]::Parse([System.Diagnostics.ProcessPriorityClass], $priority)
|
||||
if ($protectTiming) { [AudioJob]::ProtectAudioTiming($p.Handle) }
|
||||
$p | Add-Member -NotePropertyName ManagedName -NotePropertyValue $name
|
||||
return $p
|
||||
} catch {
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-ForPorts([string[]]$ports, $children, [string]$tool, [int]$seconds = 25) {
|
||||
$until = (Get-Date).AddSeconds($seconds)
|
||||
do {
|
||||
foreach ($child in $children) {
|
||||
$child.Refresh()
|
||||
if ($child.HasExited) { throw "$($child.ManagedName) exited with code $($child.ExitCode). See .runtime/logs." }
|
||||
}
|
||||
# jack_lsp can report a missing server while jackd is still starting.
|
||||
# Windows PowerShell treats native stderr as a terminating error when
|
||||
# ErrorActionPreference is Stop, even though a retry is expected here.
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$result = & $tool 2>$null
|
||||
$toolExitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
}
|
||||
if ($toolExitCode -eq 0 -and @($ports | Where-Object { $_ -notin $result }).Count -eq 0) { return }
|
||||
Start-Sleep -Milliseconds 400
|
||||
} while ((Get-Date) -lt $until)
|
||||
throw "Timed out waiting for JACK ports: $($ports -join ', '). Check ASIO Bridge is in ASIO Direct mode with JACK Router selected. See .runtime/logs."
|
||||
}
|
||||
|
||||
function Run-Supervisor {
|
||||
$c = Resolve-AudioDependencies (Read-Config) $false $false
|
||||
$peerAddress = Resolve-JackTripAddress $c
|
||||
$jackTripClient = 'RemoteAudioJackTrip'
|
||||
New-Item -ItemType Directory -Force -Path $runtime, $logDir | Out-Null
|
||||
$mutex = [System.Threading.Mutex]::new($false, 'Local\RemoteAudioSupervisor')
|
||||
if (-not $mutex.WaitOne(0)) { throw 'Audio supervisor is already running' }
|
||||
$job = [IntPtr]::Zero
|
||||
$children = @()
|
||||
$runId = [guid]::NewGuid().ToString()
|
||||
try {
|
||||
Assert-NoUnmanagedProcesses
|
||||
Remove-Item -LiteralPath $stopPath -ErrorAction SilentlyContinue
|
||||
Initialize-JobApi
|
||||
$job = [AudioJob]::CreateKillOnCloseJob()
|
||||
$self = Get-Process -Id $PID
|
||||
$state = [ordered]@{
|
||||
runId = $runId; phase = 'starting'; supervisorPid = $PID
|
||||
supervisorStartTicks = $self.StartTime.ToUniversalTime().Ticks
|
||||
started = (Get-Date).ToString('o'); host = $c.host; peerAddress = $peerAddress; period = $c.period; children = @{}
|
||||
}
|
||||
Save-State $state
|
||||
|
||||
$jack = Start-ManagedProcess 'jackd' $c.jackd @('-d','dummy','-C','0','-P','0','-r',"$($c.rate)",'-p',"$($c.period)") $job 'High' $false $c.preventPowerThrottling
|
||||
$children += $jack
|
||||
$state.children.jackd = $jack.Id; Save-State $state
|
||||
$lsp = Join-Path $c.jackTools 'jack_lsp.exe'
|
||||
Wait-ForPorts @() $children $lsp 15
|
||||
|
||||
$bridge = Start-ManagedProcess 'bridge' $c.bridge @() $job 'Normal' $true $c.preventPowerThrottling
|
||||
$children += $bridge
|
||||
$state.children.bridge = $bridge.Id; Save-State $state
|
||||
|
||||
$jacktrip = Start-ManagedProcess 'jacktrip' $c.jacktrip @('-C', $peerAddress, '-n', "$($c.channels)", '-J', $jackTripClient) $job 'Normal' $false $c.preventPowerThrottling
|
||||
$children += $jacktrip
|
||||
$state.children.jacktrip = $jacktrip.Id; Save-State $state
|
||||
|
||||
$portPairs = @(
|
||||
[pscustomobject]@{ From = 'VBCABLE_AsioBridge:out1'; To = "${jackTripClient}:send_1" },
|
||||
[pscustomobject]@{ From = 'VBCABLE_AsioBridge:out2'; To = "${jackTripClient}:send_2" },
|
||||
[pscustomobject]@{ From = "${jackTripClient}:receive_1"; To = 'VBCABLE_AsioBridge:in1' },
|
||||
[pscustomobject]@{ From = "${jackTripClient}:receive_2"; To = 'VBCABLE_AsioBridge:in2' }
|
||||
)
|
||||
$ports = foreach ($pair in $portPairs) { $pair.From; $pair.To }
|
||||
Wait-ForPorts $ports $children $lsp 30
|
||||
$connect = Join-Path $c.jackTools 'jack_connect.exe'
|
||||
foreach ($pair in $portPairs) {
|
||||
& $connect $pair.From $pair.To
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not connect $($pair.From) to $($pair.To)" }
|
||||
}
|
||||
$state.phase = 'running'; Save-State $state
|
||||
Write-Host "Audio running. Peer $peerAddress, JACK $($c.rate) Hz / $($c.period) frames."
|
||||
while ($true) {
|
||||
if ((Test-Path -LiteralPath $stopPath) -and ((Get-Content -LiteralPath $stopPath -Raw).Trim() -eq $runId)) { break }
|
||||
foreach ($child in $children) {
|
||||
$child.Refresh()
|
||||
if ($child.HasExited) { throw "$($child.ManagedName) exited with code $($child.ExitCode). See .runtime/logs." }
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
} finally {
|
||||
if ($job -ne [IntPtr]::Zero) { [void][AudioJob]::CloseHandle($job) }
|
||||
Remove-Item -LiteralPath $stopPath -ErrorAction SilentlyContinue
|
||||
$old = Read-State
|
||||
if ($old -and $old.runId -eq $runId) { Remove-Item -LiteralPath $statePath -ErrorAction SilentlyContinue }
|
||||
$mutex.ReleaseMutex()
|
||||
$mutex.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
switch ($Command) {
|
||||
'run' { Run-Supervisor; break }
|
||||
'toggle' {
|
||||
$toggleMutex = [System.Threading.Mutex]::new($false, 'Local\RemoteAudioToggle')
|
||||
if (-not $toggleMutex.WaitOne(0)) { Write-Host 'Another audio toggle is in progress.'; $toggleMutex.Dispose(); break }
|
||||
try {
|
||||
$target = if (Get-LiveSupervisor (Read-State)) { 'stop' } else { 'start' }
|
||||
$invokeArgs = @{ Command = $target; NonInteractive = [bool]$NonInteractive }
|
||||
if ($Force -and $target -eq 'stop') { $invokeArgs.Force = $true }
|
||||
if ($Period -and $target -eq 'start') { $invokeArgs.Period = $Period }
|
||||
& $PSCommandPath @invokeArgs
|
||||
} finally {
|
||||
$toggleMutex.ReleaseMutex()
|
||||
$toggleMutex.Dispose()
|
||||
}
|
||||
break
|
||||
}
|
||||
'setup' {
|
||||
$null = Resolve-AudioDependencies (Read-Config) $true (-not $NonInteractive)
|
||||
Write-Host 'Dependencies ready.'
|
||||
break
|
||||
}
|
||||
'start' {
|
||||
$null = Resolve-AudioDependencies (Read-Config) $true (-not $NonInteractive)
|
||||
New-Item -ItemType Directory -Force -Path $runtime, $logDir | Out-Null
|
||||
if (Get-LiveSupervisor (Read-State)) { throw 'Already running. Use status or stop.' }
|
||||
Assert-NoUnmanagedProcesses
|
||||
Remove-Item -LiteralPath $statePath -ErrorAction SilentlyContinue
|
||||
$shell = (Get-Process -Id $PID).Path
|
||||
$runArgs = @('-NoProfile','-File',"`"$PSCommandPath`"",'run')
|
||||
if ($Period) { $runArgs += @('-Period', "$Period") }
|
||||
$p = Start-Process -FilePath $shell -ArgumentList $runArgs -PassThru -WindowStyle Hidden -RedirectStandardOutput (Join-Path $logDir 'supervisor.out.log') -RedirectStandardError (Join-Path $logDir 'supervisor.err.log')
|
||||
$until = (Get-Date).AddSeconds(60)
|
||||
do {
|
||||
$state = Read-State
|
||||
if ($state -and $state.supervisorPid -eq $p.Id -and $state.phase -eq 'running') { Write-Host "Started (supervisor PID $($p.Id))."; break }
|
||||
$p.Refresh()
|
||||
if ($p.HasExited) { throw "Startup failed. See $logDir\supervisor.err.log and child logs." }
|
||||
Start-Sleep -Milliseconds 300
|
||||
} while ((Get-Date) -lt $until)
|
||||
if (-not $state -or $state.phase -ne 'running') { throw "Startup timed out. See $logDir." }
|
||||
break
|
||||
}
|
||||
'stop' {
|
||||
$state = Read-State
|
||||
$p = Get-LiveSupervisor $state
|
||||
if (-not $p) { Write-Host 'Not running under this tool.'; break }
|
||||
Set-Content -LiteralPath $stopPath -Value $state.runId -NoNewline
|
||||
if (-not $p.WaitForExit(10000)) {
|
||||
if (-not $Force) { throw 'Supervisor did not stop within 10 seconds. Try: .\audio.ps1 stop -Force' }
|
||||
Stop-Process -Id $p.Id -Force
|
||||
$null = $p.WaitForExit(5000)
|
||||
}
|
||||
Write-Host 'Stopped.'
|
||||
break
|
||||
}
|
||||
'status' {
|
||||
$state = Read-State
|
||||
$p = Get-LiveSupervisor $state
|
||||
if ($p) {
|
||||
Write-Host "Managed audio: $($state.phase); host $($state.host); peer $($state.peerAddress); period $($state.period) frames; supervisor PID $($state.supervisorPid)"
|
||||
foreach ($name in @('jackd','bridge','jacktrip')) {
|
||||
$id = $state.children.$name
|
||||
if ($id) { $cp = Get-Process -Id $id -ErrorAction SilentlyContinue; Write-Host " $name PID ${id}: $(if ($cp) { 'running' } else { 'exited' })" }
|
||||
}
|
||||
} else { Write-Host 'Managed audio: stopped' }
|
||||
$other = @(Get-Process -Name jackd, jacktrip, VBCABLE_AsioBridge -ErrorAction SilentlyContinue | Where-Object { -not $p -or $_.Id -notin @($state.children.jackd, $state.children.bridge, $state.children.jacktrip) })
|
||||
if ($other.Count) { Write-Host "Other audio processes: $(($other | ForEach-Object { "$($_.ProcessName) PID $($_.Id)" }) -join ', ')" }
|
||||
try { Write-Host "Hi-Fi Cable registry max latency: $((Get-ItemProperty -LiteralPath $registryPath -Name $registryValue).$registryValue) samples" } catch { Write-Host 'Hi-Fi Cable registry max latency: unavailable' }
|
||||
break
|
||||
}
|
||||
'logs' {
|
||||
if (-not (Test-Path -LiteralPath $logDir)) { Write-Host 'No logs yet.'; break }
|
||||
Get-ChildItem -LiteralPath $logDir -File | ForEach-Object {
|
||||
Write-Host "--- $($_.Name) ---"
|
||||
Get-Content -LiteralPath $_.FullName -Tail 30
|
||||
}
|
||||
break
|
||||
}
|
||||
'latency' {
|
||||
if (-not $Value) {
|
||||
$current = (Get-ItemProperty -LiteralPath $registryPath -Name $registryValue).$registryValue
|
||||
Write-Host "Hi-Fi Cable max latency: $current samples (registry)."
|
||||
break
|
||||
}
|
||||
$samples = 0
|
||||
if (-not [int]::TryParse($Value, [ref]$samples) -or $samples -lt 128 -or $samples -gt 32768) { throw 'Latency must be 128..32768 samples' }
|
||||
$current = (Get-ItemProperty -LiteralPath $registryPath -Name $registryValue).$registryValue
|
||||
if ($current -eq $samples) { Write-Host "Already $samples samples."; break }
|
||||
Set-ItemProperty -LiteralPath $registryPath -Name $registryValue -Value $samples -Type DWord
|
||||
Write-Host "Registry latency changed: $current -> $samples samples. Restart Windows to ensure the driver uses the new value."
|
||||
break
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user