Add managed Windows remote audio stack

This commit is contained in:
2026-09-20 17:26:36 +01:00
commit baa48c10b6
9 changed files with 650 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.runtime/
.deps/
audio.json
+61
View File
@@ -0,0 +1,61 @@
# Remote audio on Windows
This project links Windows audio to a JackTrip hub on another machine. It runs JACK2 with the dummy driver, VB-Audio Hi-Fi Cable and ASIO Bridge with JACK Router, and a JackTrip hub client. The script connects the first two channels in both directions. No Windows audio hardware is needed for JACK's clock.
The PowerShell supervisor starts the programs in order, waits for their JACK ports, makes the connections, and monitors the child processes. It owns them through a Windows job object: stopping or losing the supervisor closes the programs it started. It refuses to take over or stop unrelated JACK, JackTrip, or ASIO Bridge processes.
## First setup
1. Copy `audio.example.json` to `audio.json`. Set `host` to the JackTrip hub's hostname or IPv4 address. Edit executable paths if your installations use different locations. Relative paths are resolved from this project directory. `audio.json` is ignored by Git.
2. Run `.\audio.ps1 setup` in an interactive PowerShell window. If JACK or JACK Router is absent, the script downloads and verifies the pinned JACK installer, then offers to run it. Choose **Full installation (with JACK-Router)**. If JackTrip is absent, the script downloads a verified copy into `.deps/`. Hi-Fi Cable and ASIO Bridge require manual installation from [VB-Audio](https://vb-audio.com/Cable/), followed by a reboot.
3. In ASIO Bridge, select **JACK Router** and enable **ASIO Direct**. Set both Windows Hi-Fi Cable endpoint formats and the remote audio system to the same sample rate as `rate` (48 kHz in the example). Start the JackTrip hub on the remote machine and ensure its network ports are reachable.
Versions, download URLs, and SHA-256 hashes are pinned in `dependencies.json`. Downloads and runtime files remain in the ignored `.deps/` and `.runtime/` directories.
## Use
Run commands from this directory in PowerShell:
```powershell
.\audio.ps1 toggle # start if stopped; stop if running
.\audio.ps1 start # start in the background
.\audio.ps1 stop # stop the owned stack
.\audio.ps1 status # show owned processes and cable max latency
.\audio.ps1 logs # show recent logs
.\audio.ps1 run # run the supervisor in this terminal; Ctrl-C stops it
.\audio.ps1 setup # check or install dependencies
```
`toggle-audio.cmd` is a shortcut-friendly wrapper for `toggle -NonInteractive`. Its console closes on success and pauses on error so the message stays visible. To pin it to the taskbar, create a shortcut to `cmd.exe` with target arguments `/d /c ""<project-directory>\toggle-audio.cmd""` and set **Start in** to the project directory. Perform the interactive setup first; the shortcut will not prompt to install drivers. No scheduled task or service is required.
If PowerShell blocks direct script execution, use `powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\audio.ps1 toggle`. Run the stack as the signed-in desktop user because ASIO Bridge uses the interactive desktop.
## Configuration
| Setting | Purpose |
| --- | --- |
| `jackd`, `jackTools`, `bridge`, `jacktrip` | Program and JACK tools paths. Missing JACK and JackTrip are handled by `setup`; Hi-Fi Cable requires manual installation. |
| `host` | JackTrip hub hostname or IPv4 address. |
| `forceIPv4` | If `true`, resolve `host` to an IPv4 A record and pass its numeric address to JackTrip. Startup fails if no IPv4 address exists. JackTrip 2.7.2 does not have a dedicated IPv4 switch. |
| `channels` | JackTrip send and receive channel count. The script connects channels 1 and 2. |
| `rate`, `period` | JACK dummy sample rate and frames per period. `period` must be a power of two. |
| `preventPowerThrottling` | Ask Windows to keep the three audio processes out of EcoQoS and to honor their timer-resolution requests. Set to `false` if unsupported or if you want Windows-managed behavior. This may increase power use. |
JACK always starts at **High** process priority. JackTrip's local JACK client has the fixed name `RemoteAudioJackTrip`, so changing or resolving the hub hostname does not change the port names used for connections. The optional `-Period` argument on `start` or `toggle` overrides `period` for that run without editing `audio.json`.
## Hi-Fi Cable max latency
```powershell
.\audio.ps1 latency # read the VBAudioHFVAIO registry value
.\audio.ps1 latency 1024 # set a new value, from an administrator PowerShell
```
This is the cable's **maximum pipe size**, in samples, rather than a measured one-way audio delay. VB-Audio says the largest buffer in use should be below one third of the cable max; ASIO Bridge warns when the max is too small. With a displayed ASIO buffer of 128 samples, **1024 samples is a cautious first value to try**. If there are no warnings or glitches, 512 is another possible trial; if another stream uses a larger buffer, keep the max above three times that larger value. At 48 kHz, 1024 samples span about 21.3 ms, but that does not mean the link adds 21.3 ms of delay. See the [VB-CABLE reference manual](https://vb-audio.com/Cable/VBCABLE_ReferenceManual.pdf).
Changing the registry value requires administrator rights. Reboot Windows after a change so the driver reloads it, then inspect ASIO Bridge for buffer or sample-rate warnings. The script only writes `HKLM\SOFTWARE\VB-Audio\Cable\VBAudioHFVAIO`.
## Troubleshooting
`status` confirms process ownership and readiness, not that audio is audible. Use `logs` if startup fails or JackTrip disconnects. The JACK, bridge, JackTrip, and supervisor logs are in `.runtime/logs/`. If unrelated audio processes are reported, close them yourself before starting this stack.
Keep the Hi-Fi Cable input, output, ASIO Bridge, JACK, and remote sample rates aligned. If ASIO Bridge's centre sample-rate display falls or JACK reports xruns when windows lose focus, check `preventPowerThrottling`; [Windows can ignore timer-resolution requests from invisible applications](https://learn.microsoft.com/en-us/windows/win32/api/timeapi/nf-timeapi-timebeginperiod), and the supervisor uses [per-process power controls](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-setprocessinformation) to request stable timing. `tests/power-policy.ps1` checks that Windows accepts those controls.
+12
View File
@@ -0,0 +1,12 @@
{
"jackd": "C:\\Program Files\\JACK2\\jackd.exe",
"jackTools": "C:\\Program Files\\JACK2\\tools",
"bridge": "C:\\Program Files (x86)\\VB\\ASIOBridge\\VBCABLE_AsioBridge.exe",
"jacktrip": ".deps\\JackTrip-v2.7.2-Windows-x64\\jacktrip.exe",
"host": "audio-host.example.net",
"channels": 4,
"rate": 48000,
"period": 128,
"forceIPv4": true,
"preventPowerThrottling": true
}
+360
View File
@@ -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
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"jack": {
"version": "1.9.22",
"file": "jack2-win64-v1.9.22.exe",
"url": "https://github.com/jackaudio/jack2-releases/releases/download/v1.9.22/jack2-win64-v1.9.22.exe",
"sha256": "c46847404c3cbd01ea3473e53f7221078e34142308e911c0e08a40dc44d26a19"
},
"jacktrip": {
"version": "2.7.2",
"file": "JackTrip-v2.7.2-Windows-x64-binary.zip",
"url": "https://github.com/jacktrip/jacktrip/releases/download/v2.7.2/JackTrip-v2.7.2-Windows-x64-binary.zip",
"sha256": "9a779b1e68ff086c18570e61d7007090594f99e6b9ad04a77145cd53c4f8da28"
},
"hifiPage": "https://vb-audio.com/Cable/"
}
+134
View File
@@ -0,0 +1,134 @@
# Dependency setup is kept separate from the process supervisor. Nothing is
# downloaded or installed when all configured executables already exist.
$script:depsProjectRoot = $PSScriptRoot
$script:depsManifest = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'dependencies.json') -Raw | ConvertFrom-Json
$script:depsDownloadDir = Join-Path $PSScriptRoot '.runtime\downloads'
$script:depsInstallDir = Join-Path $PSScriptRoot '.deps'
function Test-JackPaths($config) {
return (Test-Path -LiteralPath $config.jackd -PathType Leaf) -and
(Test-Path -LiteralPath (Join-Path $config.jackTools 'jack_lsp.exe') -PathType Leaf) -and
(Test-Path -LiteralPath (Join-Path $config.jackTools 'jack_connect.exe') -PathType Leaf)
}
function Test-JackRouter {
return (Test-Path -LiteralPath 'HKLM:\SOFTWARE\ASIO\JackRouter') -and
(Test-Path -LiteralPath 'HKLM:\SOFTWARE\WOW6432Node\ASIO\JackRouter')
}
function Use-DefaultJackPaths($config) {
$candidate = 'C:\Program Files\JACK2'
if (-not (Test-Path -LiteralPath (Join-Path $candidate 'jackd.exe'))) {
$location = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\JACK' -Name Location -ErrorAction SilentlyContinue).Location
if ($location) {
$candidate = if ($location -like '*.exe') { Split-Path -Path $location -Parent } else { $location }
}
}
$config.jackd = Join-Path $candidate 'jackd.exe'
$config.jackTools = Join-Path $candidate 'tools'
}
function Get-PinnedDownload($asset) {
New-Item -ItemType Directory -Force -Path $script:depsDownloadDir | Out-Null
$destination = Join-Path $script:depsDownloadDir $asset.file
if (Test-Path -LiteralPath $destination) {
$existingHash = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash
if ($existingHash -ieq $asset.sha256) { return $destination }
Write-Warning "Cached $($asset.file) failed SHA-256 verification; downloading it again."
Remove-Item -LiteralPath $destination
}
$partial = "$destination.partial"
Remove-Item -LiteralPath $partial -ErrorAction SilentlyContinue
Write-Host "Downloading $($asset.file) from $($asset.url)"
try {
Invoke-WebRequest -Uri $asset.url -OutFile $partial -UseBasicParsing
$actual = (Get-FileHash -LiteralPath $partial -Algorithm SHA256).Hash
if ($actual -ine $asset.sha256) { throw "SHA-256 mismatch for $($asset.file): got $actual" }
Move-Item -LiteralPath $partial -Destination $destination
return $destination
} finally {
Remove-Item -LiteralPath $partial -ErrorAction SilentlyContinue
}
}
function Assert-DependencyPath([string]$path) {
$base = [System.IO.Path]::GetFullPath($script:depsInstallDir).TrimEnd('\') + '\'
$target = [System.IO.Path]::GetFullPath($path)
if (-not $target.StartsWith($base, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to modify directory outside .deps: $target"
}
return $target
}
function Remove-DependencyStaging([string]$path) {
$target = Assert-DependencyPath $path
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Recurse -Force }
}
function Get-ManagedJackTrip {
$folder = Join-Path $script:depsInstallDir "JackTrip-v$($script:depsManifest.jacktrip.version)-Windows-x64"
if (-not (Test-Path -LiteralPath $folder)) { return $null }
$exe = Get-ChildItem -LiteralPath $folder -Recurse -File -Filter 'jacktrip.exe' | Select-Object -First 1
if ($exe) { return $exe.FullName }
return $null
}
function Install-ManagedJackTrip {
$asset = $script:depsManifest.jacktrip
$archive = Get-PinnedDownload $asset
New-Item -ItemType Directory -Force -Path $script:depsInstallDir | Out-Null
$folder = Assert-DependencyPath (Join-Path $script:depsInstallDir "JackTrip-v$($asset.version)-Windows-x64")
$staging = Assert-DependencyPath (Join-Path $script:depsInstallDir ('.staging-' + [guid]::NewGuid().ToString('n')))
try {
Expand-Archive -LiteralPath $archive -DestinationPath $staging
$exe = Get-ChildItem -LiteralPath $staging -Recurse -File -Filter 'jacktrip.exe' | Select-Object -First 1
if (-not $exe) { throw 'Verified JackTrip archive did not contain jacktrip.exe' }
if (Test-Path -LiteralPath $folder) { Remove-DependencyStaging $folder }
Move-Item -LiteralPath $staging -Destination $folder
} finally {
Remove-DependencyStaging $staging
}
return Get-ManagedJackTrip
}
function Resolve-AudioDependencies($config, [bool]$allowDownload, [bool]$interactive) {
if (-not (Test-Path -LiteralPath $config.bridge -PathType Leaf)) {
$defaultBridge = 'C:\Program Files (x86)\VB\ASIOBridge\VBCABLE_AsioBridge.exe'
if (Test-Path -LiteralPath $defaultBridge -PathType Leaf) { $config.bridge = $defaultBridge }
}
if (-not (Test-Path -LiteralPath $config.bridge -PathType Leaf)) {
Write-Warning 'Hi-Fi Cable / ASIO Bridge is missing. Download and install it as administrator, then reboot Windows.'
if ($interactive) {
$answer = Read-Host 'Open the official VB-Audio download page now? [y/N]'
if ($answer -match '^(y|yes)$') { Start-Process $script:depsManifest.hifiPage }
}
throw "Hi-Fi Cable installation required: $($script:depsManifest.hifiPage)"
}
if (-not (Test-JackPaths $config)) { Use-DefaultJackPaths $config }
if (-not (Test-JackPaths $config) -or -not (Test-JackRouter)) {
if (-not $allowDownload) { throw 'JACK or JACK Router is missing. Run .\audio.ps1 setup in an interactive PowerShell window.' }
$installer = Get-PinnedDownload $script:depsManifest.jack
if ($interactive) {
$answer = Read-Host "JACK or JACK Router is missing. Run its installer ($installer) now? [y/N]"
if ($answer -match '^(y|yes)$') {
Start-Process -FilePath $installer -Verb RunAs -Wait
Use-DefaultJackPaths $config
}
}
if (-not (Test-JackPaths $config) -or -not (Test-JackRouter)) {
throw "Install JACK with Full installation (with JACK-Router) from $installer, then run setup again."
}
}
if (-not (Test-Path -LiteralPath $config.jacktrip -PathType Leaf)) {
$managed = Get-ManagedJackTrip
if (-not $managed) {
if (-not $allowDownload) { throw 'JackTrip is missing. Run .\audio.ps1 setup first.' }
$managed = Install-ManagedJackTrip
}
$config.jacktrip = $managed
}
if (-not (Test-Path -LiteralPath $config.jacktrip -PathType Leaf)) { throw 'JackTrip is still missing after setup.' }
return $config
}
+36
View File
@@ -0,0 +1,36 @@
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot '..\deps.ps1')
$originalManifest = $script:depsManifest
$originalDownloadDir = $script:depsDownloadDir
$originalInstallDir = $script:depsInstallDir
$testRoot = Join-Path $script:depsProjectRoot ('.runtime\bootstrap-test-' + [guid]::NewGuid().ToString('n'))
try {
$script:depsDownloadDir = Join-Path $testRoot 'downloads'
$script:depsInstallDir = Join-Path $testRoot 'deps'
$source = Join-Path $testRoot 'source'
New-Item -ItemType Directory -Force -Path $source, $script:depsDownloadDir | Out-Null
Set-Content -LiteralPath (Join-Path $source 'jacktrip.exe') -Value 'test fixture'
$zip = Join-Path $script:depsDownloadDir 'fixture.zip'
Compress-Archive -LiteralPath (Join-Path $source 'jacktrip.exe') -DestinationPath $zip
$script:depsManifest = [pscustomobject]@{
jacktrip = [pscustomobject]@{
version = 'test'
file = 'fixture.zip'
url = 'https://invalid.example/fixture.zip'
sha256 = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
}
}
$resolved = Install-ManagedJackTrip
if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { throw 'JackTrip extraction failed' }
if (-not $resolved.StartsWith($script:depsInstallDir, [StringComparison]::OrdinalIgnoreCase)) { throw 'JackTrip path escaped dependency directory' }
Write-Host 'Bootstrap cache verification and extraction OK'
} finally {
$script:depsManifest = $originalManifest
$script:depsDownloadDir = $originalDownloadDir
$script:depsInstallDir = $originalInstallDir
$runtimeRoot = [System.IO.Path]::GetFullPath((Join-Path $script:depsProjectRoot '.runtime')).TrimEnd('\') + '\'
$testPath = [System.IO.Path]::GetFullPath($testRoot)
if (-not $testPath.StartsWith($runtimeRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Refusing to remove test files outside .runtime' }
if (Test-Path -LiteralPath $testPath) { Remove-Item -LiteralPath $testPath -Recurse -Force }
}
+22
View File
@@ -0,0 +1,22 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
. (Join-Path $root 'audio.ps1') status
Initialize-JobApi
New-Item -ItemType Directory -Force -Path (Join-Path $root '.runtime\logs') | Out-Null
$job = [AudioJob]::CreateKillOnCloseJob()
try {
$powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
$child = Start-ManagedProcess 'power-policy-test' $powershell @('-NoProfile', '-Command', 'Start-Sleep -Seconds 30') $job 'Normal' $false $true
$state = New-Object AudioJob+PowerThrottlingState
$state.Version = 1
if (-not [AudioJob]::GetProcessInformation($child.Handle, 4, [ref]$state, 12)) {
throw "GetProcessInformation failed: $([Runtime.InteropServices.Marshal]::GetLastWin32Error())"
}
if ($state.ControlMask -ne 5 -or $state.StateMask -ne 0) {
throw "Unexpected power policy: ControlMask=$($state.ControlMask), StateMask=$($state.StateMask)"
}
Write-Host 'Per-process power policy applied and verified.'
} finally {
[void][AudioJob]::CloseHandle($job)
}
+7
View File
@@ -0,0 +1,7 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0audio.ps1" toggle -NonInteractive
if errorlevel 1 (
echo.
echo Remote audio did not toggle. Review the error above, or run audio.ps1 logs.
pause
)