Vyom Sawhney

← all portfolio

AudioSkew

A real-time Windows audio tool that delays one headphone channel by a fraction of a millisecond, sample-accurate and click-free.

Built with
C++20, WASAPI, CMake

AudioSkew is a small Windows 11 desktop tool that takes whatever your PC is playing and delays one stereo channel by a tiny, adjustable amount (0.1 ms steps; the slider covers 0-20 ms by default and can be widened up to 1000 ms) before it reaches your headphones. It is meant for headphone listening to lectures and videos, where a slight interaural timing offset can make the sound feel more spatial and, for some people, easier to focus on.

  • Sample-accurate integer delay on the left or right channel, nothing else (no EQ, no gain change; bypass is bit-identical to the input).
  • Real-time WASAPI engine, ~17-35 ms added latency depending on your drivers, no clicks when you move the slider or toggle bypass (10 ms crossfades).
  • Spacebar / B toggles bypass. Presets, custom presets, system-tray menu, settings saved as JSON.
  • Pure C++20 + CMake, no third-party libraries, no drivers, no installer.
Windows apps --> [sink endpoint] --loopback--> AudioSkew --> [your headphones]

Download: take AudioSkew.exe from the latest release - a single file, no installer and no drivers. Windows SmartScreen will warn on the first launch because the executable is not code-signed: choose More info then Run anyway. To compile it yourself instead, see Building.


Contents

  1. How it works, in one minute
  2. Requirements
  3. Building
  4. First run: choosing the sink and the output
  5. Using it
  6. Settings file
  7. Command-line tools
  8. Latency and quality
  9. Current limitations
  10. Troubleshooting
  11. Project layout
  12. License

1. How it works, in one minute

Windows lets a program loopback-capture a playback endpoint: it receives the mixed audio of every application that plays into that endpoint. AudioSkew captures one endpoint (the sink), delays one channel, and plays the result to another endpoint (your headphones).

The sink must be an endpoint you do not listen to. If AudioSkew captured the headphones themselves it would capture its own output again and again (feedback), and you would hear the original audio plus the delayed copy. Good sinks:

Sink Notes
VB-CABLE (free virtual audio cable, https://vb-audio.com/Cable/) Recommended on laptops. Install once; "CABLE Input" appears as a playback device.
An unused digital/HDMI output (S/PDIF, a monitor without speakers) Free and common on desktops.
Any other virtual playback device you already have (Steam Streaming Speakers, Voicemeeter, ...) Works the same way.

While the engine runs, AudioSkew's auto-route option makes the sink the Windows default playback device so every app plays into it automatically, and restores your previous default when you stop or exit. You do not have to touch the Sound control panel after the first setup.

The full reasoning, including a comparison with APO / virtual-device / Equalizer APO approaches, is in docs/ARCHITECTURE.md.

2. Requirements

  • Windows 10 2004 or later (developed and tested on Windows 11), x64.
  • An inaudible playback endpoint to use as the sink (see above).
  • To build from source (not needed if you downloaded the release): Visual Studio 2022 (Community or Build Tools) with the "Desktop development with C++" workload, which includes CMake and Ninja.

3. Building

Standard CMake (from a "x64 Native Tools Command Prompt for VS 2022", or any shell where cl.exe is on the PATH):

mkdir build
cd build
cmake ..
cmake --build . --config Release
ctest -C Release --output-on-failure

If you only have the Build Tools and nothing on the PATH, the helper script sets up the environment and uses the bundled Ninja:

scripts\build.bat Release test

Outputs (in build\ or build\Release\ with the Visual Studio generator):

File Purpose
AudioSkew.exe the application (statically linked, single file)
audioskew_devices.exe list playback endpoints and their IDs
audioskew_tone.exe play a test tone / noise into a device
audioskew_headless.exe run the engine without a GUI
audioskew_measure.exe measure the left/right delay actually rendered
test_*.exe unit tests

CMake options: -DAUDIOSKEW_BUILD_TESTS=OFF, -DAUDIOSKEW_BUILD_TOOLS=OFF.

4. First run: choosing the sink and the output

  1. Start AudioSkew.exe.
  2. System audio source (sink): pick the inaudible endpoint (e.g. CABLE Input). AudioSkew pre-selects a device whose name contains "CABLE Input", "Virtual" or "Voicemeeter" if it finds one.
  3. Output device (headphones): pick your headphones. The current Windows default is pre-selected.
  4. Leave auto-route checked (recommended) and press Start.

Windows now sends all application audio to the sink; AudioSkew plays it, skewed, on your headphones. The status line shows Running, the estimated latency and the underrun counter. The "In" meter must move when something plays; if it stays empty, see Troubleshooting.

The choices are remembered. On later launches AudioSkew starts automatically (auto_start), and it can start hidden in the tray (start_minimized or AudioSkew.exe --minimized).

5. Using it

Control Effect
Delayed channel Left / Right which ear gets the delayed signal; switches with a 10 ms crossfade
Delay slider 0.1 ms steps; the box next to it shows the value and is editable: type e.g. 35.5 and press Enter (Esc cancels). The sample count at the engine's rate is shown underneath
Range slider span: 0-20 ms (default), 50, 100, 200, 500 or 1000 ms. Typing or loading a value beyond the span widens it automatically; the engine always has the full 1000 ms available, so no restart
Preset Normal 0 ms, Subtle 0.5, Light 1, Medium 2, Strong 5, plus your own (Save... / Delete)
Bypass button, Space or B output equals input exactly; toggles instantly and click-free
Start / Stop opens/closes the devices; changing a device while running restarts the engine
Show log device events, sample rates, periods, underruns, drift corrections
Window close / minimise hides to the system tray; audio keeps running
Tray icon: double-click open the window
Tray icon: right-click Enabled (toggles bypass), Delay presets, Delay Left/Right, Open, Exit

The delay is defined as D = round(sampleRate * ms / 1000) samples. At 48 kHz: 0.5 ms = 24, 1 ms = 48, 2 ms = 96, 5 ms = 240, 10 ms = 480. The "samples @ Hz" line always uses the real rate of the running engine (the sink's mix rate; 44.1, 48 and 96 kHz are all supported).

6. Settings file

%APPDATA%\AudioSkew\settings.json, written automatically:

{
  "version": 1,
  "delay_ms": 2,
  "max_delay_ms": 20,
  "channel": "right",
  "bypass": false,
  "output_device": "{0.0.0.00000000}.{...}",
  "sink_device": "{0.0.0.00000000}.{...}",
  "auto_route": true,
  "auto_start": true,
  "start_minimized": false,
  "crossfade_ms": 10,
  "buffer_margin_ms": 10,
  "prefer_low_latency": true,
  "pending_restore_device": "",
  "window_x": 100,
  "window_y": 100,
  "custom_presets": [ { "name": "Lecture", "delay_ms": 1.7 } ]
}

Advanced keys: buffer_margin_ms trades latency for robustness (see below); crossfade_ms is the parameter-change ramp; prefer_low_latency requests the driver's minimum period via IAudioClient3. pending_restore_device is AudioSkew's crash-safety note: if it is non-empty at start-up, the previous default device is restored before anything else.

7. Command-line tools

audioskew_devices                       # list endpoints: index, name, ID, mix format
audioskew_devices --watch               # ... and print again whenever devices change

audioskew_tone --device 2 --seconds 3   # 440 Hz sine on device index 2
audioskew_tone --device "CABLE" --noise --amp 0.05   # white noise, name substring match

audioskew_headless --sink 2 --out 1 --delay 2 --channel right --seconds 30
                                        # engine without GUI; prints diagnostics every second
                                        # (--bypass, --margin MS, --no-lowlatency, --auto-route)

audioskew_measure --device 1 --seconds 3 --expect-ms 2
                                        # loopback-captures device 1 and cross-correlates L vs R

audioskew_measure is how the delay is verified programmatically: play noise into the sink, run the engine, measure the output endpoint. A typical run:

Measured inter-channel delay: +96 samples = +2.000 ms  (right relative to left)
Correlation at peak: 0.9994, next best elsewhere: 0.0075
Expected +96 samples (+2.0 ms): MATCH

8. Latency and quality

Added latency is roughly 0.5 x sink period + 2 x output period + margin:

Driver periods (sink / output) Margin Added latency
10 ms / 10 ms (typical HDMI, many onboard codecs) 10 ms ~35 ms
10 ms / 3 ms 10 ms ~21 ms
3 ms / 3 ms (drivers that support small periods) 10 ms ~17 ms
3 ms / 3 ms 5 ms ~12 ms

The status line shows the live estimate. The margin (buffer_margin_ms) exists because Windows briefly stalls the loopback whenever any application starts or stops a stream on the sink; 10 ms covered that in testing, 5 ms occasionally did not. After an underrun the engine grows the margin automatically (one gap instead of a burst of glitches).

The sink and the headphones run on different clocks. AudioSkew watches the buffer level and, when it drifts, drops or repeats a few frames with a 1 ms crossfade. This is inaudible on speech and the log shows every correction. Bluetooth headphones add their own latency but the left/right offset is still exact, because only the relative timing matters.

Quality: processing is 32-bit float, the only operation is an integer-sample delay, and bypass returns the input bit-for-bit. Nothing changes volume.

9. Current limitations

  • Needs an inaudible sink endpoint. No way around it with the loopback architecture; VB-CABLE is a two-minute fix. An Audio Processing Object would remove the requirement and is the long-term plan (see docs/ARCHITECTURE.md).
  • Auto-route uses an undocumented Windows interface (IPolicyConfig, the same one SoundSwitch and EarTrumpet use). If it fails, set the default playback device to the sink yourself in Sound settings.
  • Volume keys act on the sink while auto-routed. With VB-CABLE that still behaves like a volume control (the captured signal scales); with a hardware digital output the keys may do nothing. Adjust the headphones' own volume in the Sound settings or the volume mixer instead.
  • Muting the sink may mute the capture. On many devices the loopback tap is after the volume control. Keep the sink at 100 %.
  • Integer-sample delay only (0.1 ms slider steps are rounded to the nearest sample, e.g. 0.1 ms = 5 samples at 48 kHz).
  • Communications-role apps (Teams, Discord "communications device") are not re-routed on purpose; they keep playing directly to your headphones.
  • Device removal stops the engine; it does not reconnect automatically.
  • Exclusive-mode applications (ASIO, WASAPI exclusive) bypass the Windows mixer and are not captured.
  • No "start with Windows" option yet (create a shortcut to AudioSkew.exe --minimized in shell:startup if you want it).

10. Troubleshooting

Symptom Cause / fix
"Windows protected your PC" when starting the downloaded exe The build is not code-signed. Click More info then Run anyway, or right-click AudioSkew.exe > Properties > tick Unblock > OK.
"In" meter stays empty, status says (sink is silent) Applications are not playing into the sink. Check that the sink is the Windows default device (auto-route), or that the app is not pinned to another device in Volume Mixer. Some apps only follow the default device when restarted.
No sound at all but the meters move Wrong output device selected, or headphones muted in Windows.
Everything is heard twice / echoes Sink and output are the same physical path (e.g. sink = laptop speakers with headphones plugged into the same jack). Use a virtual cable.
"The sink and the output must be different" Exactly that.
Windows still points at the sink after a crash Start AudioSkew once; it restores the previous default device on launch. Or pick your headphones in Sound settings.
Sample rates differ (log warning) Windows resamples; fine, but set both endpoints to the same rate in Sound settings > device > Advanced for the lowest latency.
Underruns keep increasing Raise buffer_margin_ms in settings.json, or set prefer_low_latency to false. Check for CPU-hungry drivers (DPC latency).
Clicks when a video starts That is the sink stall described above; the margin grows automatically after the first one.

11. Project layout

src/
  main.cpp                   entry point, single-instance, crash recovery
  audio/
    ChannelDelay.*           integer delay line (framework-independent)
    StereoDelayProcessor.*   L/R taps + click-free crossfades (framework-independent)
    RingBuffer.*             capture->render FIFO with crossfaded drift corrections
    AudioFormat.*            WAVEFORMATEX <-> float stereo conversion
    DeviceEnumerator.*       MMDevice enumeration + change notifications
    WasapiCapture.*          loopback capture + silent keep-alive stream
    WasapiRenderer.*         event-driven output (IAudioClient3 when possible)
    DefaultDeviceSwitcher.*  IPolicyConfig (auto-route)
    AudioEngine.*            the real-time thread, priming, drift control, diagnostics
    ComHelpers.h             RAII for COM/handles, HRESULT text
  config/Settings.*          JSON settings + presets
  core/Json.*                minimal JSON parser/serialiser
  diag/Log.*                 thread-safe logger (never used on the audio thread)
  ui/                        Win32 GUI: MainWindow, TrayIcon, InputDialog
tools/                       devices / tone / headless / measure
tests/                       unit tests (ctest)
docs/ARCHITECTURE.md         design notes and the routing analysis
scripts/build.bat            build helper for Build-Tools-only machines
scripts/make_icon.py         regenerates res/AudioSkew.ico

The delay DSP (ChannelDelay, StereoDelayProcessor, RingBuffer) has no Windows dependencies and is fully covered by unit tests, so it can be reused in an APO or a plug-in later without changes.

12. License

MIT - see LICENSE. Use it, fork it, ship it; no warranty.

The delay DSP is deliberately free of Windows dependencies, so if you want to reuse ChannelDelay / StereoDelayProcessor / RingBuffer in a plug-in or an APO, take them.