Files
win-remote-audio/audio.ps1
T

361 lines
18 KiB
PowerShell

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
}
}