diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 7a2cdbf..2ff6df7 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -117,6 +117,52 @@ jobs:
-DriverVersion ${{ steps.ver.outputs.numeric }} `
-Describe "${{ steps.ver.outputs.describe }}"
+ - name: Test-sign package
+ shell: pwsh
+ run: |
+ # A second copy of the package signed with a throwaway self-signed
+ # certificate, for test machines with test signing enabled. The
+ # certificate (.cer) ships alongside so the guest can trust it.
+ $plat = "${{ matrix.platform }}"
+ $stage = "artifacts/$plat"
+ $signed = "artifacts/$plat-testsigned"
+ New-Item -ItemType Directory -Force -Path $signed | Out-Null
+ foreach ($ext in 'sys', 'inf', 'cat') {
+ Copy-Item "$stage/SynchronousAudioRouter.$ext" $signed -Force
+ }
+ # Driver symbols, for reading crash dumps from test machines.
+ $pdb = Get-ChildItem -Recurse -Filter 'SynchronousAudioRouter.pdb' |
+ Where-Object { $_.FullName -match "\\$plat\\Release\\" } |
+ Sort-Object LastWriteTime -Descending | Select-Object -First 1
+ if ($pdb) { Copy-Item $pdb.FullName $signed -Force } else { Write-Warning 'SynchronousAudioRouter.pdb not found' }
+ $cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject 'CN=SAR CI test signing' `
+ -CertStoreLocation Cert:\CurrentUser\My -HashAlgorithm SHA256 -NotAfter (Get-Date).AddYears(2)
+ Export-Certificate -Cert $cert -FilePath "$signed/testsign.cer" | Out-Null
+ $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe |
+ Where-Object { $_.FullName -match '\\x64\\' } |
+ Sort-Object FullName -Descending | Select-Object -First 1
+ if (-not $signtool) { throw 'signtool.exe not found' }
+ & $signtool.FullName sign /fd SHA256 /sha1 $cert.Thumbprint /s My `
+ "$signed/SynchronousAudioRouter.sys" "$signed/SynchronousAudioRouter.cat"
+ if ($LASTEXITCODE -ne 0) { throw "signtool failed (exit $LASTEXITCODE)" }
+ Remove-Item "Cert:\CurrentUser\My\$($cert.Thumbprint)" -Force
+ $lines = Get-ChildItem $signed -File | Where-Object { $_.Name -ne 'SHA256SUMS.txt' } |
+ Get-FileHash -Algorithm SHA256 |
+ ForEach-Object { '{0} {1}' -f $_.Hash, (Split-Path $_.Path -Leaf) }
+ $lines | Out-File "$signed/SHA256SUMS.txt" -Encoding ascii
+ @"
+ TEST-SIGNED package: only loads on machines with test signing enabled
+ (bcdedit /set testsigning on) that trust testsign.cer in both the Root and
+ TrustedPublisher stores. Not for distribution.
+ "@ | Out-File "$signed/README.txt" -Encoding ascii
+
+ - name: Upload test-signed package
+ uses: actions/upload-artifact@v4
+ with:
+ name: sar-driver-${{ matrix.platform }}-testsigned
+ path: artifacts/${{ matrix.platform }}-testsigned/
+ if-no-files-found: error
+
- name: Attest build provenance
if: github.event_name == 'push' # OIDC token isn't writable on fork PRs
uses: actions/attest-build-provenance@v1
@@ -149,11 +195,11 @@ jobs:
with:
vs-version: "[17.0,18.0)"
- - name: Build SarAsio
+ - name: Build user-mode projects
shell: pwsh
run: |
& msbuild SynchronousAudioRouter.sln `
- /t:SarAsio `
+ '/t:SarAsio;SarConfigure;SarCtl;SarTest;SarTestClock' `
/p:Configuration=Release `
/p:Platform=${{ matrix.platform }} `
/m /verbosity:minimal /nologo
@@ -165,11 +211,22 @@ jobs:
$plat = "${{ matrix.platform }}"
$stage = "artifacts/$plat"
New-Item -ItemType Directory -Force -Path $stage | Out-Null
- $dll = Get-ChildItem -Recurse -Filter 'SarAsio.dll' |
- Where-Object { $_.FullName -match "\\Release\\" } |
- Sort-Object LastWriteTime -Descending | Select-Object -First 1
- if (-not $dll) { throw "SarAsio.dll not found - did the build succeed?" }
- Copy-Item $dll.FullName $stage -Force
+ foreach ($name in 'SarAsio.dll', 'SarConfigure.exe', 'SarTest.exe', 'SarTestClock.dll') {
+ $file = Get-ChildItem -Recurse -Filter $name |
+ Where-Object { $_.FullName -match "\\Release\\" } |
+ Sort-Object LastWriteTime -Descending | Select-Object -First 1
+ if (-not $file) { throw "$name not found - did the build succeed?" }
+ Copy-Item $file.FullName $stage -Force
+ }
+ # Symbols, for reading crash dumps from test machines.
+ foreach ($name in 'SarAsio.pdb', 'SarTest.pdb', 'SarTestClock.pdb') {
+ $file = Get-ChildItem -Recurse -Filter $name |
+ Where-Object { $_.FullName -match "\\Release\\" } |
+ Sort-Object LastWriteTime -Descending | Select-Object -First 1
+ if ($file) { Copy-Item $file.FullName $stage -Force } else { Write-Warning "$name not found" }
+ }
+ New-Item -ItemType Directory -Force -Path "$stage/test" | Out-Null
+ Copy-Item tools/test/* "$stage/test" -Force
# Materialize the hash lines into a variable BEFORE opening the output
# file, and skip the sums file itself - otherwise the same pipeline both
# reads and writes SHA256SUMS.txt and Get-FileHash hits a file lock.
diff --git a/SarTest/SarTest.cpp b/SarTest/SarTest.cpp
new file mode 100644
index 0000000..cd94627
--- /dev/null
+++ b/SarTest/SarTest.cpp
@@ -0,0 +1,526 @@
+// SynchronousAudioRouter
+// Copyright (C) 2026 Mackenzie Straight
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with SynchronousAudioRouter. If not, see .
+
+// SarTest: a headless test harness for SynchronousAudioRouter. See
+// tools/test/README.md for the workflow it is designed around.
+
+#include "common.h"
+#include "install.h"
+#include "asiohost.h"
+#include "wasapi.h"
+#include "clockstats.h"
+
+using namespace SarTest;
+
+namespace {
+
+int usage()
+{
+ printf(
+ "SarTest - SynchronousAudioRouter test harness\n"
+ "\n"
+ " SarTest install [--inf ] [--clock ] [--endpoints N] [--channels C]\n"
+ " Create the SAR device node, install the driver package from the INF,\n"
+ " register the software clock and write the SarAsio configuration.\n"
+ " SarTest uninstall [--clock ] [--remove-device]\n"
+ " SarTest config [--endpoints N] [--channels C] [--out ]\n"
+ " Write only the SarAsio configuration.\n"
+ " SarTest host [--iterations K] [--duration S] [--sarasio ]\n"
+ " Run the headless ASIO host: create SAR endpoints, tick, stop, repeat.\n"
+ " SarTest wasapi [--duration S] [--wait S] [--expect-invalidation]\n"
+ " Stream the test signal through the endpoints of a running host and\n"
+ " verify the loopback.\n"
+ " SarTest run [--iterations K] [--duration S]\n"
+ " host and wasapi in one process, with end-to-end verification.\n"
+ " SarTest race [--cycles K] [--up S] [--down MS] [--openers T] [--hold MS]\n"
+ " Start and stop the host repeatedly while threads keep opening and\n"
+ " closing streams on its endpoints; report any call that hangs.\n"
+ "\n"
+ "Layout options: --endpoints N (playback/recording pairs, default 2)\n"
+ " --channels C (per endpoint, default 2) --prefix \n"
+ "Other options: --results --phase-timeout S --no-sarasio-log\n"
+ " --keep-config (host/run: do not rewrite default.json)\n"
+ " --registered (use the COM-registered SarAsio instead of a path)\n"
+ " --wait S --wait-gone S --restart-delay MS --rate HZ\n"
+ " --min-valid RATIO --max-discontinuities N\n"
+ " --settle S --max-reopens N --max-transition FRAMES\n"
+ "\n"
+ "Exit codes: 0 pass, 1 setup or usage error, 2 test failure.\n");
+ return 1;
+}
+
+std::wstring sibling(const wchar_t *file)
+{
+ wchar_t path[MAX_PATH] = {};
+
+ GetModuleFileNameW(nullptr, path, MAX_PATH);
+
+ std::wstring s = path;
+ auto slash = s.find_last_of(L"\\/");
+
+ if (slash == std::wstring::npos) {
+ return file;
+ }
+
+ return s.substr(0, slash + 1) + file;
+}
+
+AsioHostOptions hostOptions(const Args& args, const EndpointLayout& layout)
+{
+ AsioHostOptions options;
+
+ options.layout = layout;
+
+ if (!args.has(L"registered")) {
+ options.sarAsioPath = args.get(L"sarasio", sibling(L"SarAsio.dll").c_str());
+ }
+
+ options.sampleRate = args.getDouble(L"rate", 48000.0);
+ options.phaseTimeoutSeconds = args.getInt(L"phase-timeout", 30);
+ options.enableSarAsioLog = !args.has(L"no-sarasio-log");
+ return options;
+}
+
+WasapiOptions wasapiOptions(const Args& args, const EndpointLayout& layout)
+{
+ WasapiOptions options;
+
+ options.layout = layout;
+ options.durationSeconds = args.getDouble(L"duration", 5.0);
+ options.expectInvalidation = args.has(L"expect-invalidation");
+ options.minValidRatio = args.getDouble(L"min-valid", 0.5);
+ options.maxDiscontinuities = args.getInt(L"max-discontinuities", -1);
+ options.settleSeconds = args.getDouble(L"settle", 2.0);
+ options.maxReopens = args.getInt(L"max-reopens", 20);
+ options.maxTransitionFrames = args.getInt(L"max-transition", 4800);
+ return options;
+}
+
+// Writes default.json for the layout unless --keep-config, backing up an
+// existing configuration the first time.
+bool prepareConfig(const Args& args, const EndpointLayout& layout)
+{
+ if (args.has(L"keep-config")) {
+ return true;
+ }
+
+ std::wstring path = configurationPath();
+ std::wstring backup = path + L".sartest-backup";
+
+ if (GetFileAttributesW(path.c_str()) != INVALID_FILE_ATTRIBUTES &&
+ GetFileAttributesW(backup.c_str()) == INVALID_FILE_ATTRIBUTES) {
+ if (CopyFileW(path.c_str(), backup.c_str(), TRUE)) {
+ logf("Backed up the existing configuration to %s", narrow(backup).c_str());
+ }
+ }
+
+ if (!writeDriverConfig(layout, SAR_TEST_CLOCK_CLSID_STR,
+ args.getInt(L"wavert-min-frames", 0), path)) {
+ return false;
+ }
+
+ logf("Wrote configuration for %d endpoint pairs x %d channels to %s",
+ layout.pairs, layout.channels, narrow(path).c_str());
+ return true;
+}
+
+void writeResults(const Args& args, const std::string& json)
+{
+ std::wstring path = args.get(L"results", L"sartest-results.json");
+
+ if (writeTextFile(path, json + "\n")) {
+ logf("Results written to %s", narrow(path).c_str());
+ }
+}
+
+int cmdInstall(const Args& args)
+{
+ EndpointLayout layout = EndpointLayout::fromArgs(args);
+ std::wstring inf = args.get(L"inf");
+ std::wstring clock = args.get(L"clock", sibling(L"SarTestClock.dll").c_str());
+
+ if (!inf.empty()) {
+ if (!deviceNodeExists() && !createDeviceNode()) {
+ return 1;
+ }
+
+ bool reboot = false;
+
+ if (!installDriverPackage(inf, &reboot)) {
+ return 1;
+ }
+ } else if (!deviceNodeExists()) {
+ logf("No SAR device node is present and no --inf was given");
+ return 1;
+ } else {
+ logf("SAR device node present; leaving the installed driver alone (no --inf)");
+ }
+
+ if (!registerComServer(clock, false)) {
+ return 1;
+ }
+
+ return prepareConfig(args, layout) ? 0 : 1;
+}
+
+int cmdUninstall(const Args& args)
+{
+ std::wstring clock = args.get(L"clock", sibling(L"SarTestClock.dll").c_str());
+ int rc = registerComServer(clock, true) ? 0 : 1;
+
+ if (args.has(L"remove-device") && !removeDeviceNode()) {
+ rc = 1;
+ }
+
+ return rc;
+}
+
+int cmdConfig(const Args& args)
+{
+ EndpointLayout layout = EndpointLayout::fromArgs(args);
+ std::wstring out = args.get(L"out", configurationPath().c_str());
+
+ if (!writeDriverConfig(layout, SAR_TEST_CLOCK_CLSID_STR,
+ args.getInt(L"wavert-min-frames", 0), out)) {
+ return 1;
+ }
+
+ logf("Wrote %s", narrow(out).c_str());
+ return 0;
+}
+
+int cmdHost(const Args& args)
+{
+ EndpointLayout layout = EndpointLayout::fromArgs(args);
+ int iterations = args.getInt(L"iterations", 1);
+ double duration = args.getDouble(L"duration", 5.0);
+ int restartDelay = args.getInt(L"restart-delay", 1000);
+ int waitGone = args.getInt(L"wait-gone", 30);
+
+ if (!prepareConfig(args, layout)) {
+ return 1;
+ }
+
+ AsioHost host(hostOptions(args, layout));
+ std::vector iterationJson;
+ int rc = 0;
+
+ if (!host.load() || !host.open()) {
+ rc = 1;
+ }
+
+ for (int i = 0; rc == 0 && i < iterations; ++i) {
+ logf("--- host iteration %d of %d ---", i + 1, iterations);
+
+ if (!host.start()) {
+ rc = 2;
+ break;
+ }
+
+ Sleep((DWORD)(duration * 1000.0));
+
+ if (!host.stop()) {
+ rc = 2;
+ break;
+ }
+
+ JsonObject iteration;
+
+ iteration.setInt("iteration", i + 1)
+ .setDouble("startMs", host.stats().lastStartMs)
+ .setDouble("stopMs", host.stats().lastStopMs)
+ .setInt("ticks", host.stats().ticks);
+ iterationJson.push_back(iteration.str());
+
+ if (i + 1 < iterations) {
+ waitForEndpointsGone(layout, waitGone);
+ Sleep((DWORD)restartDelay);
+ }
+ }
+
+ host.close();
+
+ JsonObject result;
+
+ result.setString("command", "host")
+ .setBool("passed", rc == 0)
+ .setInt("exitCode", rc)
+ .setRaw("host", host.toJson())
+ .setRaw("iterations", jsonArray(iterationJson));
+ writeResults(args, result.str());
+ return rc;
+}
+
+int cmdWasapi(const Args& args)
+{
+ EndpointLayout layout = EndpointLayout::fromArgs(args);
+ WasapiOptions options = wasapiOptions(args, layout);
+ FoundEndpoints endpoints;
+
+ if (!findEndpoints(layout, args.getInt(L"wait", 30), &endpoints)) {
+ JsonObject result;
+
+ result.setString("command", "wasapi")
+ .setBool("passed", false)
+ .setInt("exitCode", 1)
+ .setString("failure", "endpoints not found");
+ writeResults(args, result.str());
+ return 1;
+ }
+
+ WasapiResult result = runWasapi(options, endpoints);
+ JsonObject json;
+ int rc = result.passed ? 0 : 2;
+
+ json.setString("command", "wasapi")
+ .setBool("passed", result.passed)
+ .setInt("exitCode", rc)
+ .setRaw("wasapi", result.toJson());
+ writeResults(args, json.str());
+ return rc;
+}
+
+int cmdRace(const Args& args)
+{
+ EndpointLayout layout = EndpointLayout::fromArgs(args);
+ int cycles = args.getInt(L"cycles", 10);
+ double up = args.getDouble(L"up", 3.0);
+ int down = args.getInt(L"down", 200);
+ int threads = args.getInt(L"openers", 4);
+ int hold = args.getInt(L"hold", 250);
+ int hangTimeout = args.getInt(L"phase-timeout", 30);
+
+ if (threads < 1) {
+ threads = 1;
+ }
+
+ if (threads > 32) {
+ threads = 32;
+ }
+
+ if (!prepareConfig(args, layout)) {
+ return 1;
+ }
+
+ AsioHost host(hostOptions(args, layout));
+ RaceOpeners openers(layout, threads, hold, hangTimeout);
+ std::vector cycleJson;
+ int rc = 0;
+
+ if (!host.load() || !host.open()) {
+ rc = 1;
+ }
+
+ if (rc == 0) {
+ logf("Starting %d stream openers on %d endpoint pairs", threads, layout.pairs);
+ openers.start();
+ }
+
+ for (int i = 0; rc == 0 && i < cycles; ++i) {
+ JsonObject cycle;
+
+ logf("--- race cycle %d of %d ---", i + 1, cycles);
+ cycle.setInt("cycle", i + 1);
+
+ if (!host.start()) {
+ rc = 2;
+ cycle.setString("failure", "start");
+ cycleJson.push_back(cycle.str());
+ break;
+ }
+
+ cycle.setDouble("startMs", host.stats().lastStartMs);
+ Sleep((DWORD)(up * 1000.0));
+
+ if (!host.stop()) {
+ rc = 2;
+ cycle.setString("failure", "stop");
+ }
+
+ cycle.setDouble("stopMs", host.stats().lastStopMs)
+ .setDouble("maxCallbackMs", host.stats().clock.maxCallbackMs)
+ .setInt("slowCallbacks", (long long)host.stats().clock.slowCallbacks);
+ cycleJson.push_back(cycle.str());
+ Sleep((DWORD)down);
+ }
+
+ bool clean = openers.stop();
+ RaceStats race = openers.stats();
+
+ logf("Openers: %lld attempts, %lld streams opened, %lld without an active endpoint, "
+ "slowest call %s (%.0f ms)", race.attempts, race.opened, race.noEndpoint,
+ race.maxCallName.c_str(), race.maxCallMs);
+
+ for (const auto& error : race.errors) {
+ logf(" %s: %lld", error.first.c_str(), error.second);
+ }
+
+ if (!clean || race.hangsReported > 0 || host.stats().hangsReported > 0) {
+ rc = 2;
+ logf("A WASAPI or ASIO call hung");
+ } else if (rc == 0 && race.opened == 0) {
+ rc = 2;
+ logf("No stream was ever opened; the openers never raced the host");
+ }
+
+ // Results first: closing the host can hang if the driver is wedged.
+ JsonObject result;
+
+ result.setString("command", "race")
+ .setBool("passed", rc == 0)
+ .setInt("exitCode", rc)
+ .setInt("endpointPairs", layout.pairs)
+ .setInt("channels", layout.channels)
+ .setRaw("host", host.toJson())
+ .setRaw("openers", race.toJson())
+ .setRaw("cycles", jsonArray(cycleJson));
+ writeResults(args, result.str());
+ logf("%s", rc == 0 ? "PASSED" : "FAILED");
+ host.close();
+ return rc;
+}
+
+int cmdRun(const Args& args)
+{
+ EndpointLayout layout = EndpointLayout::fromArgs(args);
+ int iterations = args.getInt(L"iterations", 1);
+ int restartDelay = args.getInt(L"restart-delay", 1000);
+ int waitGone = args.getInt(L"wait-gone", 30);
+ int waitEndpoints = args.getInt(L"wait", 30);
+ WasapiOptions wasapi = wasapiOptions(args, layout);
+
+ if (!prepareConfig(args, layout)) {
+ return 1;
+ }
+
+ AsioHost host(hostOptions(args, layout));
+ std::vector iterationJson;
+ int rc = 0;
+
+ if (!host.load() || !host.open()) {
+ rc = 1;
+ }
+
+ for (int i = 0; rc == 0 && i < iterations; ++i) {
+ JsonObject iteration;
+
+ logf("--- iteration %d of %d ---", i + 1, iterations);
+ iteration.setInt("iteration", i + 1);
+
+ if (!host.start()) {
+ rc = 2;
+ iteration.setString("failure", "start");
+ iterationJson.push_back(iteration.str());
+ break;
+ }
+
+ iteration.setDouble("startMs", host.stats().lastStartMs);
+
+ FoundEndpoints endpoints;
+
+ if (!findEndpoints(layout, waitEndpoints, &endpoints)) {
+ rc = 2;
+ iteration.setString("failure", "endpoints not found");
+ host.stop();
+ iterationJson.push_back(iteration.str());
+ break;
+ }
+
+ WasapiResult result = runWasapi(wasapi, endpoints);
+
+ endpoints.release();
+ iteration.setRaw("wasapi", result.toJson());
+
+ if (!result.passed) {
+ rc = 2;
+ iteration.setString("failure", "verification");
+ }
+
+ if (!host.stop()) {
+ rc = 2;
+ iteration.setString("failure", "stop");
+ }
+
+ iteration.setDouble("stopMs", host.stats().lastStopMs)
+ .setDouble("maxCallbackMs", host.stats().clock.maxCallbackMs)
+ .setInt("slowCallbacks", (long long)host.stats().clock.slowCallbacks);
+ iterationJson.push_back(iteration.str());
+
+ if (rc == 0 && i + 1 < iterations) {
+ if (!waitForEndpointsGone(layout, waitGone)) {
+ logf("Endpoints lingered after stop");
+ }
+
+ Sleep((DWORD)restartDelay);
+ }
+ }
+
+ host.close();
+
+ JsonObject result;
+
+ result.setString("command", "run")
+ .setBool("passed", rc == 0)
+ .setInt("exitCode", rc)
+ .setInt("endpointPairs", layout.pairs)
+ .setInt("channels", layout.channels)
+ .setRaw("host", host.toJson())
+ .setRaw("iterations", jsonArray(iterationJson));
+ writeResults(args, result.str());
+ logf("%s", rc == 0 ? "PASSED" : "FAILED");
+ return rc;
+}
+
+} // namespace
+
+int wmain(int argc, wchar_t **argv)
+{
+ Args args = parseArgs(argc, argv);
+
+ if (args.command.empty() || args.has(L"help")) {
+ return usage();
+ }
+
+ HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
+
+ if (FAILED(hr)) {
+ logf("CoInitializeEx failed: %s", hresultText(hr).c_str());
+ return 1;
+ }
+
+ int rc;
+
+ if (args.command == L"install") {
+ rc = cmdInstall(args);
+ } else if (args.command == L"uninstall") {
+ rc = cmdUninstall(args);
+ } else if (args.command == L"config") {
+ rc = cmdConfig(args);
+ } else if (args.command == L"host") {
+ rc = cmdHost(args);
+ } else if (args.command == L"wasapi") {
+ rc = cmdWasapi(args);
+ } else if (args.command == L"run") {
+ rc = cmdRun(args);
+ } else if (args.command == L"race") {
+ rc = cmdRace(args);
+ } else {
+ rc = usage();
+ }
+
+ CoUninitialize();
+ return rc;
+}
diff --git a/SarTest/SarTest.vcxproj b/SarTest/SarTest.vcxproj
new file mode 100644
index 0000000..b68c529
--- /dev/null
+++ b/SarTest/SarTest.vcxproj
@@ -0,0 +1,112 @@
+
+
+
+
+ Debug
+ Win32
+
+
+ Release
+ Win32
+
+
+ Debug
+ x64
+
+
+ Release
+ x64
+
+
+
+ {3C8F1E2A-7D4B-4A9E-B5C6-1D2E3F4A5B6C}
+ Win32Proj
+ SarTest
+ 10.0.22621.0
+
+
+
+ Application
+ true
+ v143
+ Unicode
+
+
+ Application
+ false
+ v143
+ true
+ Unicode
+
+
+
+
+
+
+
+
+
+
+
+ $(VC_IncludePath);$(WindowsSDK_IncludePath);..\SynchronousAudioRouter;..\SarAsio;..\SarTestClock
+
+
+ true
+
+
+ false
+
+
+
+ NotUsing
+ Level3
+ true
+ stdcpp17
+ _CONSOLE;%(PreprocessorDefinitions)
+
+
+ Console
+ true
+ setupapi.lib;newdev.lib;shlwapi.lib;shell32.lib;ole32.lib;oleaut32.lib;uuid.lib;advapi32.lib;kernel32.lib;user32.lib;%(AdditionalDependencies)
+
+
+
+
+
+ Disabled
+ _DEBUG;%(PreprocessorDefinitions)
+ MultiThreadedDebug
+
+
+
+
+ MaxSpeed
+ true
+ true
+ NDEBUG;%(PreprocessorDefinitions)
+ MultiThreaded
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SarTest/asiohost.cpp b/SarTest/asiohost.cpp
new file mode 100644
index 0000000..90dfed1
--- /dev/null
+++ b/SarTest/asiohost.cpp
@@ -0,0 +1,478 @@
+// SynchronousAudioRouter
+// Copyright (C) 2026 Mackenzie Straight
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with SynchronousAudioRouter. If not, see .
+
+#include "asiohost.h"
+
+#include
+#include
+
+using namespace Sar;
+
+namespace SarTest {
+
+AsioHost *AsioHost::sInstance = nullptr;
+
+AsioHost::AsioHost(const AsioHostOptions& options)
+ : _options(options)
+{
+ _callbacks.tick = &AsioHost::tickStub;
+ _callbacks.sampleRateDidChange = &AsioHost::sampleRateStub;
+ _callbacks.asioMessage = &AsioHost::messageStub;
+ _callbacks.tickWithTime = nullptr;
+ sInstance = this;
+ _watchdogThread = std::thread([this] { watchdog(); });
+}
+
+AsioHost::~AsioHost()
+{
+ close();
+ _watchdogStop = true;
+
+ if (_watchdogThread.joinable()) {
+ _watchdogThread.join();
+ }
+
+ if (sInstance == this) {
+ sInstance = nullptr;
+ }
+
+ // SarAsio.dll is deliberately left loaded: it owns logging state and
+ // COM objects, and the process is about to exit anyway.
+}
+
+void AsioHost::setPhase(const char *phase)
+{
+ _phaseStartMs = (long long)nowMs();
+ _phase = phase;
+}
+
+void AsioHost::watchdog()
+{
+ long long lastReport = 0;
+
+ while (!_watchdogStop) {
+ Sleep(250);
+
+ const char *phase = _phase.load();
+
+ if (strcmp(phase, "idle") == 0) {
+ lastReport = 0;
+ continue;
+ }
+
+ long long elapsed = (long long)nowMs() - _phaseStartMs.load();
+
+ if (elapsed > (long long)_options.phaseTimeoutSeconds * 1000 &&
+ elapsed - lastReport > 10000) {
+ logf("HANG: IASIO::%s has not returned after %lld ms", phase, elapsed);
+ _stats.hangsReported++;
+ lastReport = elapsed;
+ }
+ }
+}
+
+bool AsioHost::load()
+{
+ const GUID clsid = __uuidof(Sar::IASIO);
+
+ if (_options.enableSarAsioLog) {
+ SetEnvironmentVariableW(L"SAR_ASIO_LOG", L"1");
+ }
+
+ setPhase("load");
+
+ if (!_options.sarAsioPath.empty()) {
+ typedef HRESULT (STDAPICALLTYPE *GetClassObjectFn)(REFCLSID, REFIID, LPVOID *);
+
+ _module = LoadLibraryW(_options.sarAsioPath.c_str());
+
+ if (!_module) {
+ logf("LoadLibrary(%s) failed: %s", narrow(_options.sarAsioPath).c_str(),
+ lastErrorText(GetLastError()).c_str());
+ setPhase("idle");
+ return false;
+ }
+
+ auto getClassObject =
+ (GetClassObjectFn)GetProcAddress(_module, "DllGetClassObject");
+
+ if (!getClassObject) {
+ logf("%s does not export DllGetClassObject", narrow(_options.sarAsioPath).c_str());
+ setPhase("idle");
+ return false;
+ }
+
+ IClassFactory *factory = nullptr;
+ HRESULT hr = getClassObject(clsid, IID_IClassFactory, (void **)&factory);
+
+ if (FAILED(hr) || !factory) {
+ logf("DllGetClassObject failed: %s", hresultText(hr).c_str());
+ setPhase("idle");
+ return false;
+ }
+
+ // ASIO drivers use their CLSID as the interface ID.
+ hr = factory->CreateInstance(nullptr, clsid, (void **)&_asio);
+ factory->Release();
+
+ if (FAILED(hr) || !_asio) {
+ logf("IClassFactory::CreateInstance failed: %s", hresultText(hr).c_str());
+ _asio = nullptr;
+ setPhase("idle");
+ return false;
+ }
+
+ logf("Loaded %s", narrow(_options.sarAsioPath).c_str());
+ } else {
+ HRESULT hr = CoCreateInstance(
+ clsid, nullptr, CLSCTX_INPROC_SERVER, clsid, (void **)&_asio);
+
+ if (FAILED(hr) || !_asio) {
+ logf("CoCreateInstance(SarAsio) failed: %s (is SarAsio registered?)",
+ hresultText(hr).c_str());
+ _asio = nullptr;
+ setPhase("idle");
+ return false;
+ }
+
+ logf("Created the registered SarAsio driver");
+ }
+
+ setPhase("idle");
+ return true;
+}
+
+bool AsioHost::open()
+{
+ if (!_asio) {
+ return false;
+ }
+
+ setPhase("init");
+
+ if (_asio->init(nullptr) != AsioBool::True) {
+ char message[124] = {};
+
+ _asio->getErrorMessage(message);
+ logf("IASIO::init failed: %s", message);
+ setPhase("idle");
+ return false;
+ }
+
+ char name[32] = {};
+
+ _asio->getDriverName(name);
+ logf("Driver: %s (version %ld)", name, _asio->getDriverVersion());
+
+ setPhase("getChannels");
+
+ if (_asio->getChannels(&_totalInputs, &_totalOutputs) != AsioStatus::OK) {
+ logf("IASIO::getChannels failed");
+ setPhase("idle");
+ return false;
+ }
+
+ _stats.virtualInputs = _options.layout.totalChannels();
+ _stats.virtualOutputs = _options.layout.totalChannels();
+ _stats.physicalInputs = _totalInputs - _stats.virtualInputs;
+ _stats.physicalOutputs = _totalOutputs - _stats.virtualOutputs;
+
+ if (_stats.physicalInputs < 0 || _stats.physicalOutputs < 0) {
+ logf("Driver reports %ld inputs and %ld outputs, fewer than the %d "
+ "channels per direction the layout needs. Run 'SarTest install' "
+ "or 'SarTest config' with the same layout first.",
+ _totalInputs, _totalOutputs, _options.layout.totalChannels());
+ setPhase("idle");
+ return false;
+ }
+
+ logf("Channels: %ld inputs (%ld physical), %ld outputs (%ld physical)",
+ _totalInputs, _stats.physicalInputs, _totalOutputs, _stats.physicalOutputs);
+
+ setPhase("getBufferSize");
+
+ long minSize = 0, maxSize = 0, preferredSize = 0, granularity = 0;
+
+ if (_asio->getBufferSize(&minSize, &maxSize, &preferredSize, &granularity) != AsioStatus::OK) {
+ logf("IASIO::getBufferSize failed");
+ setPhase("idle");
+ return false;
+ }
+
+ setPhase("setSampleRate");
+
+ if (_asio->setSampleRate(_options.sampleRate) != AsioStatus::OK) {
+ logf("setSampleRate(%.0f) rejected, continuing with the driver's rate",
+ _options.sampleRate);
+ }
+
+ double sampleRate = 0.0;
+
+ _asio->getSampleRate(&sampleRate);
+ _stats.sampleRate = sampleRate;
+ _stats.bufferFrames = preferredSize;
+ logf("Sample rate %.0f Hz, buffer %ld frames (min %ld, max %ld, granularity %ld)",
+ sampleRate, preferredSize, minSize, maxSize, granularity);
+
+ setPhase("getChannelInfo");
+ _infos.clear();
+
+ for (int direction = 0; direction < 2; ++direction) {
+ bool isInput = direction == 0;
+ long count = isInput ? _totalInputs : _totalOutputs;
+
+ for (long i = 0; i < count; ++i) {
+ AsioChannelInfo info = {};
+
+ info.index = i;
+ info.isInput = isInput ? AsioBool::True : AsioBool::False;
+
+ if (_asio->getChannelInfo(&info) != AsioStatus::OK) {
+ logf("getChannelInfo(%s %ld) failed", isInput ? "input" : "output", i);
+ setPhase("idle");
+ return false;
+ }
+
+ if (info.sampleType != Int32LSB) {
+ logf("Unsupported sample type %ld on %s %ld (%s)",
+ info.sampleType, isInput ? "input" : "output", i, info.name);
+ setPhase("idle");
+ return false;
+ }
+
+ AsioBufferInfo buffer = {};
+
+ buffer.isInput = info.isInput;
+ buffer.index = i;
+ _infos.push_back(buffer);
+ }
+ }
+
+ _sampleSize = 4;
+ setPhase("createBuffers");
+
+ double t0 = nowMs();
+ AsioStatus status = _asio->createBuffers(
+ _infos.data(), (long)_infos.size(), preferredSize, &_callbacks);
+
+ _stats.createBuffersMs = nowMs() - t0;
+ setPhase("idle");
+
+ if (status != AsioStatus::OK) {
+ logf("IASIO::createBuffers failed: %ld", (long)status);
+ return false;
+ }
+
+ _buffersCreated = true;
+ logf("createBuffers OK for %zu channels in %.1f ms", _infos.size(), _stats.createBuffersMs);
+ return true;
+}
+
+bool AsioHost::start()
+{
+ if (!_buffersCreated) {
+ return false;
+ }
+
+ setPhase("start");
+
+ double t0 = nowMs();
+ AsioStatus status = _asio->start();
+ double elapsed = nowMs() - t0;
+
+ setPhase("idle");
+ _stats.lastStartMs = elapsed;
+ _stats.maxStartMs = std::max(_stats.maxStartMs, elapsed);
+
+ if (status != AsioStatus::OK) {
+ logf("IASIO::start failed: %ld after %.1f ms", (long)status, elapsed);
+ return false;
+ }
+
+ _running = true;
+ _stats.starts++;
+ logf("start OK in %.1f ms", elapsed);
+ return true;
+}
+
+bool AsioHost::stop()
+{
+ if (!_running) {
+ return true;
+ }
+
+ readClockStats();
+ setPhase("stop");
+
+ double t0 = nowMs();
+ AsioStatus status = _asio->stop();
+ double elapsed = nowMs() - t0;
+
+ setPhase("idle");
+ _running = false;
+ _stats.lastStopMs = elapsed;
+ _stats.maxStopMs = std::max(_stats.maxStopMs, elapsed);
+ _stats.ticks = _ticks.load();
+
+ if (status != AsioStatus::OK) {
+ logf("IASIO::stop failed: %ld after %.1f ms", (long)status, elapsed);
+ return false;
+ }
+
+ logf("stop OK in %.1f ms (%lld ticks so far, clock late %llu of %llu, "
+ "slowest callback %.1f ms, %llu slow)",
+ elapsed, _stats.ticks,
+ (unsigned long long)_stats.clock.lateTicks,
+ (unsigned long long)_stats.clock.ticks,
+ _stats.clock.maxCallbackMs,
+ (unsigned long long)_stats.clock.slowCallbacks);
+ return true;
+}
+
+void AsioHost::close()
+{
+ if (!_asio) {
+ return;
+ }
+
+ stop();
+
+ if (_buffersCreated) {
+ setPhase("disposeBuffers");
+
+ double t0 = nowMs();
+
+ _asio->disposeBuffers();
+ _stats.disposeBuffersMs = nowMs() - t0;
+ setPhase("idle");
+ _buffersCreated = false;
+ }
+
+ _asio->Release();
+ _asio = nullptr;
+}
+
+void AsioHost::readClockStats()
+{
+ SarTestClock::ClockStats stats = {};
+
+ stats.size = sizeof(stats);
+
+ if (_asio && _asio->future(SarTestClock::kStatsSelector, &stats) == AsioStatus::OK) {
+ _stats.clock = stats;
+ }
+}
+
+void AsioHost::onTick(long bufferIndex)
+{
+ long index = bufferIndex & 1;
+ long count = std::min(_stats.virtualInputs, _stats.virtualOutputs);
+ size_t bytes = (size_t)_stats.bufferFrames * (size_t)_sampleSize;
+
+ for (long k = 0; k < count; ++k) {
+ const AsioBufferInfo& in = _infos[(size_t)(_stats.physicalInputs + k)];
+ const AsioBufferInfo& out = _infos[(size_t)(_totalInputs + _stats.physicalOutputs + k)];
+
+ if (in.asioBuffers[index] && out.asioBuffers[index]) {
+ memcpy(out.asioBuffers[index], in.asioBuffers[index], bytes);
+ }
+ }
+
+ _ticks++;
+}
+
+void AsioHost::tickStub(long bufferIndex, AsioBool)
+{
+ AsioHost *host = sInstance;
+
+ if (host) {
+ host->onTick(bufferIndex);
+ }
+}
+
+void AsioHost::sampleRateStub(double sampleRate)
+{
+ logf("Driver reports sample rate change to %.0f Hz", sampleRate);
+}
+
+long AsioHost::messageStub(AsioMessage selector, long value, void *, double *)
+{
+ switch (selector) {
+ case AsioMessage::SelectorSupported:
+ switch ((AsioMessage)value) {
+ case AsioMessage::EngineVersion:
+ case AsioMessage::ResetRequest:
+ case AsioMessage::BufferSizeChange:
+ case AsioMessage::ResyncRequest:
+ case AsioMessage::LatenciesChanged:
+ return 1;
+ default:
+ return 0;
+ }
+ case AsioMessage::EngineVersion:
+ return 2;
+ case AsioMessage::ResetRequest:
+ logf("Driver requested a reset");
+
+ if (sInstance) {
+ sInstance->_stats.resetRequests++;
+ }
+
+ return 1;
+ case AsioMessage::BufferSizeChange:
+ case AsioMessage::ResyncRequest:
+ case AsioMessage::LatenciesChanged:
+ return 1;
+ default:
+ // Includes SupportsTimeInfo: we only implement the plain callback.
+ return 0;
+ }
+}
+
+std::string AsioHost::toJson() const
+{
+ JsonObject clock;
+
+ clock.setInt("ticks", (long long)_stats.clock.ticks)
+ .setInt("lateTicks", (long long)_stats.clock.lateTicks)
+ .setDouble("maxLatenessMs", _stats.clock.maxLatenessMs)
+ .setDouble("periodMs", _stats.clock.periodMs)
+ .setDouble("maxCallbackMs", _stats.clock.maxCallbackMs)
+ .setInt("slowCallbacks", (long long)_stats.clock.slowCallbacks);
+
+ JsonObject host;
+
+ host.setInt("ticks", _ticks.load())
+ .setInt("starts", _stats.starts)
+ .setInt("resetRequests", _stats.resetRequests)
+ .setInt("hangsReported", _stats.hangsReported)
+ .setDouble("lastStartMs", _stats.lastStartMs)
+ .setDouble("maxStartMs", _stats.maxStartMs)
+ .setDouble("lastStopMs", _stats.lastStopMs)
+ .setDouble("maxStopMs", _stats.maxStopMs)
+ .setDouble("createBuffersMs", _stats.createBuffersMs)
+ .setDouble("disposeBuffersMs", _stats.disposeBuffersMs)
+ .setInt("bufferFrames", _stats.bufferFrames)
+ .setDouble("sampleRate", _stats.sampleRate)
+ .setInt("physicalInputs", _stats.physicalInputs)
+ .setInt("physicalOutputs", _stats.physicalOutputs)
+ .setInt("virtualInputs", _stats.virtualInputs)
+ .setInt("virtualOutputs", _stats.virtualOutputs)
+ .setRaw("clock", clock.str());
+ return host.str();
+}
+
+} // namespace SarTest
diff --git a/SarTest/asiohost.h b/SarTest/asiohost.h
new file mode 100644
index 0000000..5935211
--- /dev/null
+++ b/SarTest/asiohost.h
@@ -0,0 +1,116 @@
+// SynchronousAudioRouter
+// Copyright (C) 2026 Mackenzie Straight
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with SynchronousAudioRouter. If not, see .
+
+#ifndef _SAR_TEST_ASIOHOST_H
+#define _SAR_TEST_ASIOHOST_H
+
+#include "common.h"
+
+#include
+#include
+#include
+
+#include "tinyasio.h"
+#include "clockstats.h"
+
+namespace SarTest {
+
+struct AsioHostOptions
+{
+ // Path of SarAsio.dll to load directly. Empty: CoCreateInstance the
+ // registered driver, the way a DAW does.
+ std::wstring sarAsioPath;
+ EndpointLayout layout;
+ double sampleRate = 48000.0;
+ // Watchdog: report a hang when a single driver call exceeds this.
+ int phaseTimeoutSeconds = 30;
+ bool enableSarAsioLog = true;
+};
+
+struct AsioHostStats
+{
+ long long ticks = 0;
+ long long starts = 0;
+ long long resetRequests = 0;
+ long long hangsReported = 0;
+ double lastStartMs = 0.0;
+ double maxStartMs = 0.0;
+ double lastStopMs = 0.0;
+ double maxStopMs = 0.0;
+ double createBuffersMs = 0.0;
+ double disposeBuffersMs = 0.0;
+ long bufferFrames = 0;
+ double sampleRate = 0.0;
+ long physicalInputs = 0;
+ long physicalOutputs = 0;
+ long virtualInputs = 0;
+ long virtualOutputs = 0;
+ SarTestClock::ClockStats clock = {};
+};
+
+// A headless ASIO host around SarAsio. Every ASIO input (a SAR playback
+// endpoint channel) is copied to the ASIO output with the same index (a SAR
+// recording endpoint channel) on each callback, so the WASAPI side can verify
+// audio end to end.
+class AsioHost
+{
+public:
+ explicit AsioHost(const AsioHostOptions& options);
+ ~AsioHost();
+
+ bool load();
+ bool open();
+ bool start();
+ bool stop();
+ void close();
+
+ bool running() const { return _running; }
+ const AsioHostStats& stats() const { return _stats; }
+ std::string toJson() const;
+
+private:
+ static AsioHost *sInstance;
+ static void tickStub(long bufferIndex, Sar::AsioBool directProcess);
+ static void sampleRateStub(double sampleRate);
+ static long messageStub(
+ Sar::AsioMessage selector, long value, void *message, double *opt);
+
+ void onTick(long bufferIndex);
+ void setPhase(const char *phase);
+ void watchdog();
+ void readClockStats();
+
+ AsioHostOptions _options;
+ AsioHostStats _stats;
+ HMODULE _module = nullptr;
+ Sar::IASIO *_asio = nullptr;
+ std::vector _infos;
+ long _totalInputs = 0;
+ long _totalOutputs = 0;
+ int _sampleSize = 4;
+ Sar::AsioCallbacks _callbacks = {};
+ bool _buffersCreated = false;
+ bool _running = false;
+ std::atomic _ticks{ 0 };
+
+ std::atomic _phase{ "idle" };
+ std::atomic _phaseStartMs{ 0 };
+ std::atomic _watchdogStop{ false };
+ std::thread _watchdogThread;
+};
+
+} // namespace SarTest
+#endif // _SAR_TEST_ASIOHOST_H
diff --git a/SarTest/common.cpp b/SarTest/common.cpp
new file mode 100644
index 0000000..e4ca56c
--- /dev/null
+++ b/SarTest/common.cpp
@@ -0,0 +1,350 @@
+// SynchronousAudioRouter
+// Copyright (C) 2026 Mackenzie Straight
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with SynchronousAudioRouter. If not, see .
+
+#include "common.h"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace SarTest {
+
+bool Args::has(const wchar_t *key) const
+{
+ return options.find(key) != options.end();
+}
+
+std::wstring Args::get(const wchar_t *key, const wchar_t *def) const
+{
+ auto it = options.find(key);
+ return it == options.end() ? std::wstring(def) : it->second;
+}
+
+int Args::getInt(const wchar_t *key, int def) const
+{
+ auto it = options.find(key);
+
+ if (it == options.end() || it->second.empty()) {
+ return def;
+ }
+
+ return _wtoi(it->second.c_str());
+}
+
+double Args::getDouble(const wchar_t *key, double def) const
+{
+ auto it = options.find(key);
+
+ if (it == options.end() || it->second.empty()) {
+ return def;
+ }
+
+ return _wtof(it->second.c_str());
+}
+
+Args parseArgs(int argc, wchar_t **argv)
+{
+ Args args;
+ int i = 1;
+
+ if (i < argc && wcsncmp(argv[i], L"--", 2) != 0) {
+ args.command = argv[i++];
+ }
+
+ while (i < argc) {
+ std::wstring token = argv[i++];
+
+ if (token.size() < 3 || token.compare(0, 2, L"--") != 0) {
+ logf("Ignoring unexpected argument: %s", narrow(token).c_str());
+ continue;
+ }
+
+ std::wstring key = token.substr(2);
+ std::wstring value;
+ auto eq = key.find(L'=');
+
+ if (eq != std::wstring::npos) {
+ value = key.substr(eq + 1);
+ key = key.substr(0, eq);
+ } else if (i < argc && wcsncmp(argv[i], L"--", 2) != 0) {
+ value = argv[i++];
+ }
+
+ args.options[key] = value;
+ }
+
+ return args;
+}
+
+void logf(const char *fmt, ...)
+{
+ SYSTEMTIME st;
+ char buffer[2048];
+ va_list ap;
+
+ GetLocalTime(&st);
+ va_start(ap, fmt);
+ vsnprintf(buffer, sizeof(buffer), fmt, ap);
+ va_end(ap);
+ buffer[sizeof(buffer) - 1] = '\0';
+ printf("[%02d:%02d:%02d.%03d] %s\n",
+ st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, buffer);
+ fflush(stdout);
+}
+
+std::string narrow(const std::wstring& s)
+{
+ if (s.empty()) {
+ return std::string();
+ }
+
+ int len = WideCharToMultiByte(
+ CP_UTF8, 0, s.c_str(), (int)s.size(), nullptr, 0, nullptr, nullptr);
+
+ if (len <= 0) {
+ return std::string();
+ }
+
+ std::string out((size_t)len, '\0');
+ WideCharToMultiByte(
+ CP_UTF8, 0, s.c_str(), (int)s.size(), &out[0], len, nullptr, nullptr);
+ return out;
+}
+
+std::wstring widen(const std::string& s)
+{
+ if (s.empty()) {
+ return std::wstring();
+ }
+
+ int len = MultiByteToWideChar(
+ CP_UTF8, 0, s.c_str(), (int)s.size(), nullptr, 0);
+
+ if (len <= 0) {
+ return std::wstring();
+ }
+
+ std::wstring out((size_t)len, L'\0');
+ MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), &out[0], len);
+ return out;
+}
+
+std::string hresultText(HRESULT hr)
+{
+ char buf[32];
+
+ sprintf_s(buf, "0x%08X", (unsigned int)hr);
+ return buf;
+}
+
+std::string lastErrorText(DWORD error)
+{
+ char *message = nullptr;
+ DWORD len = FormatMessageA(
+ FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM |
+ FORMAT_MESSAGE_IGNORE_INSERTS,
+ nullptr, error, 0, (LPSTR)&message, 0, nullptr);
+ std::string result;
+
+ if (len && message) {
+ result.assign(message, len);
+
+ while (!result.empty() &&
+ (result.back() == '\r' || result.back() == '\n' ||
+ result.back() == ' ')) {
+ result.pop_back();
+ }
+ }
+
+ if (message) {
+ LocalFree(message);
+ }
+
+ char code[32];
+ sprintf_s(code, " (error %lu)", (unsigned long)error);
+ return result + code;
+}
+
+double nowMs()
+{
+ LARGE_INTEGER frequency, counter;
+
+ QueryPerformanceFrequency(&frequency);
+ QueryPerformanceCounter(&counter);
+ return (double)counter.QuadPart * 1000.0 / (double)frequency.QuadPart;
+}
+
+std::wstring EndpointLayout::playbackName(int pair) const
+{
+ return prefix + L" Out " + std::to_wstring(pair + 1);
+}
+
+std::wstring EndpointLayout::recordingName(int pair) const
+{
+ return prefix + L" In " + std::to_wstring(pair + 1);
+}
+
+std::string EndpointLayout::playbackId(int pair) const
+{
+ return narrow(prefix) + "-out-" + std::to_string(pair + 1);
+}
+
+std::string EndpointLayout::recordingId(int pair) const
+{
+ return narrow(prefix) + "-in-" + std::to_string(pair + 1);
+}
+
+EndpointLayout EndpointLayout::fromArgs(const Args& args)
+{
+ EndpointLayout layout;
+
+ layout.pairs = args.getInt(L"endpoints", 2);
+ layout.channels = args.getInt(L"channels", 2);
+ layout.prefix = args.get(L"prefix", L"SarTest");
+
+ if (layout.pairs < 1) {
+ layout.pairs = 1;
+ }
+
+ if (layout.pairs > 64) {
+ layout.pairs = 64;
+ }
+
+ if (layout.channels < 1) {
+ layout.channels = 1;
+ }
+
+ if (layout.channels > 32) {
+ layout.channels = 32;
+ }
+
+ return layout;
+}
+
+std::string jsonString(const std::string& value)
+{
+ std::string out = "\"";
+
+ for (unsigned char c : value) {
+ switch (c) {
+ case '"': out += "\\\""; break;
+ case '\\': out += "\\\\"; break;
+ case '\n': out += "\\n"; break;
+ case '\r': out += "\\r"; break;
+ case '\t': out += "\\t"; break;
+ default:
+ if (c < 0x20) {
+ char buf[8];
+ sprintf_s(buf, "\\u%04x", (unsigned int)c);
+ out += buf;
+ } else {
+ out += (char)c;
+ }
+ }
+ }
+
+ out += "\"";
+ return out;
+}
+
+std::string jsonArray(const std::vector& rawItems)
+{
+ std::string out = "[";
+
+ for (size_t i = 0; i < rawItems.size(); ++i) {
+ if (i) {
+ out += ", ";
+ }
+
+ out += rawItems[i];
+ }
+
+ out += "]";
+ return out;
+}
+
+JsonObject& JsonObject::setString(const std::string& key, const std::string& value)
+{
+ _members.emplace_back(key, jsonString(value));
+ return *this;
+}
+
+JsonObject& JsonObject::setInt(const std::string& key, long long value)
+{
+ _members.emplace_back(key, std::to_string(value));
+ return *this;
+}
+
+JsonObject& JsonObject::setDouble(const std::string& key, double value)
+{
+ char buf[64];
+
+ if (std::isfinite(value)) {
+ sprintf_s(buf, "%.3f", value);
+ } else {
+ sprintf_s(buf, "null");
+ }
+
+ _members.emplace_back(key, buf);
+ return *this;
+}
+
+JsonObject& JsonObject::setBool(const std::string& key, bool value)
+{
+ _members.emplace_back(key, value ? "true" : "false");
+ return *this;
+}
+
+JsonObject& JsonObject::setRaw(const std::string& key, const std::string& rawJson)
+{
+ _members.emplace_back(key, rawJson);
+ return *this;
+}
+
+std::string JsonObject::str() const
+{
+ std::string out = "{";
+
+ for (size_t i = 0; i < _members.size(); ++i) {
+ if (i) {
+ out += ", ";
+ }
+
+ out += jsonString(_members[i].first);
+ out += ": ";
+ out += _members[i].second;
+ }
+
+ out += "}";
+ return out;
+}
+
+bool writeTextFile(const std::wstring& path, const std::string& content)
+{
+ std::ofstream fp(path, std::ios::binary | std::ios::trunc);
+
+ if (!fp) {
+ logf("Couldn't write %s", narrow(path).c_str());
+ return false;
+ }
+
+ fp << content;
+ return true;
+}
+
+} // namespace SarTest
diff --git a/SarTest/common.h b/SarTest/common.h
new file mode 100644
index 0000000..2c161df
--- /dev/null
+++ b/SarTest/common.h
@@ -0,0 +1,127 @@
+// SynchronousAudioRouter
+// Copyright (C) 2026 Mackenzie Straight
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with SynchronousAudioRouter. If not, see .
+
+#ifndef _SAR_TEST_COMMON_H
+#define _SAR_TEST_COMMON_H
+
+#define NOMINMAX
+#define WIN32_LEAN_AND_MEAN
+#include
+
+#include
+#include
+#include
+#include