Skip to content

audio: add a native ASIO backend on Windows - #1975

Open
jcelerier wants to merge 7 commits into
masterfrom
feature/asiosdk
Open

audio: add a native ASIO backend on Windows#1975
jcelerier wants to merge 7 commits into
masterfrom
feature/asiosdk

Conversation

@jcelerier

@jcelerier jcelerier commented Feb 28, 2026

Copy link
Copy Markdown
Member

Adds a native ASIO audio backend on Windows, using the Steinberg SDK directly
rather than going through PortAudio's ASIO host.

Depends on ossia/libossia#920. The engine itself (ossia::asio_engine) lives
in libossia; that PR fixes the driver enumeration and driver-ownership bugs this
backend cannot work without, and adds the active_driver() /
control_panel_result API used here. The submodule currently points at
ossia/libossia@2b2e45a on the feature/asiosdk branch — it needs
re-pointing at the merged commit on libossia master before this is merged.

Closes #1422

What it adds

  • Audio/ASIOInterface.hppNativeASIOFactory, listed as "ASIOSDK" alongside
    the existing PortAudio-based "ASIO" backend.
  • Device list with channel counts, a control-panel button and a rescan button.
  • SCORE_AUDIO_BACKEND=asio to force the backend at startup.
  • Audio/AudioApplicationPlugin.{hpp,cpp}with_engine_stopped(), a
    backend-agnostic helper described below.

Guarded by OSSIA_ENABLE_ASIO (on by default on Windows in libossia) and
TARGET asio::sdk, so nothing changes on other platforms or without the SDK.

Things found during bring-up

Unusable devices are labelled, not hidden. A driver whose hardware is
unplugged still enumerates. Previously selecting it just failed with the generic
"the desired audio settings could not be applied", so the reason is now shown in
the list: Audio 8 DJ (device not connected), mapped from ASIOInit's error
code. ASIOCard::name stays the verbatim driver name — it is the identity stored
in the settings and handed to asio_engine — and only the combo box label is
decorated.

Rescanning cannot happen behind the engine's back. Probing a driver for its
channel counts means loading it, and AsioDrivers::loadDriver() releases
whatever driver is currently loaded, which would cut a streaming engine off from
its hardware. Since rescan() runs on every setDriver()/initDriver(),
reopening the audio settings during playback used to do exactly that. Probing is
now skipped while an ASIO engine streams.

Startup cost. rescan() is reached three times while score starts up — this
factory's constructor, Model::initDriver and ApplicationPlugin::initialize,
all through the shared AudioFactory::initialize() API. Listing drivers is a
cheap registry walk (~11 ms), but each probe loads the driver's DLL through COM:
measured 22–32 ms per driver, before real hardware answers. Doing all of it three
times cost roughly 330 ms before the GUI could paint. Split into a cheap
idempotent listing plus a probe that runs at most once per driver:

Enumerations Driver DLL loads Measured
Before 3 9 ~331 ms
After 1 3 ~111 ms

Refreshing the list is on the show event, not make_settings(). score builds
every settings widget during startup, so refreshing at construction just puts the
probe back on the startup path.

Rescan button. When nothing holds an ASIO driver — no engine, or another
backend — it probes straight away and leaves playback alone. When an ASIO device
is in use it goes through ApplicationPlugin::with_engine_stopped(), which stops
the engine, probes, and brings it back; the tooltip warns that this interrupts
playback. That helper is backend-agnostic and reusable by any driver needing
exclusive hardware access, which is why it lives in the application plugin rather
than here.

Two deliberate behaviours, in case they look like bugs

  • available() returns true even with no ASIO drivers installed — the backend is
    meant to stay visible in the settings regardless.
  • initialize() auto-selects the first device without checking connectivity — on
    the common single-driver system this picks the right one on first run.

Testing

On Windows (clang64) with three installed ASIO drivers: ASIO4ALL v2, NI Audio 8
DJ, NI Traktor Kontrol S4 MK2.

  • Playback verified with real hardware through the Traktor Kontrol S4 MK2.
  • Repeated Settings > Audio > OK cycles keep the engine alive.
  • Control panel opens the selected driver, and explains itself when another
    driver is streaming instead of showing the wrong panel.
  • Disconnected devices labelled; hot-plug picked up on reopening the settings.
  • Rescan covered in all four states: engine stopped, ASIO streaming, ASIO
    selected while another backend still runs, and device unplugged mid-playback.
  • OSSIA_ASIO_DEBUG=1 traces enumeration and per-driver probing to stderr.

Note for reviewers

2d4a2ee is the original backend commit rebased onto current master; the six
commits after it are the bring-up fixes. It no longer bumps the submodule, since
master already pointed at the libossia commit it needed.

Disclosure

The implementation in this PR was produced with an AI coding
agent (Anthropic's Claude Opus 5) using omp.
I directed the work, reviewed every change, and ran the verification
described above; I take responsibility for its correctness and licensing.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com

@jcelerier
jcelerier force-pushed the feature/asiosdk branch 3 times, most recently from 74fc006 to f39f3c1 Compare March 1, 2026 22:50
@jcelerier

Copy link
Copy Markdown
Member Author

@ogauthiersat are you down for trying to rebase, test and finish that one?

jcelerier and others added 7 commits July 31, 2026 15:34
Co-Authored-By: Claude Opus 5.0 <noreply@anthropic.com>
NativeASIOFactory::rescan() swallowed every failure: loadAsioDriver(),
ASIOInit() and ASIOGetChannels() errors were all discarded, and the
catch(...) was empty, so a driver that failed to load was indistinguishable
from one that was never installed.

Log each step to stderr instead, naming the driver and the failure:
CoCreateInstance refusing the DLL, ASIOInit reporting hardware that is
absent or already claimed by another process (including the driver's own
errorMessage), and ASIOGetChannels failing. Per-driver detail, including
the resolved driver name and version, is behind OSSIA_ASIO_DEBUG=1.

stderr rather than qDebug(): score's Qt message handler writes qDebug()
to score.log on MSVC builds, so it never reaches a console.

Picks up the libossia fix for asio_sdk being compiled with UNICODE
defined, which made the SDK enumerate zero drivers.

Co-Authored-By: Claude Opus 5.0 <noreply@anthropic.com>
…ilures

rescan() probes channel counts by loading each driver in turn, and
AsioDrivers::loadDriver() releases whatever driver is already loaded.
Since rescan() runs on every setDriver()/initDriver(), reopening the
audio settings while ASIO was streaming released the running engine's
driver underneath it. Skip the probe when an engine is active and reuse
the counts from the previous scan; they only change with the hardware.

The Show Control Panel button now reacts to the result instead of
discarding it: when another driver is streaming, ASIO cannot reach the
requested driver's panel, so say so and point at applying the device
first, rather than silently showing the wrong driver's panel. Load and
init failures are reported too.

Picks up the libossia fix releasing the ASIO driver on stop rather than
on destruction, which is what broke the engine on a settings re-apply.

Co-Authored-By: Claude Opus 5.0 <noreply@anthropic.com>
A driver whose hardware is unplugged still enumerates and is still
listed, but nothing said so: picking it just failed with the generic
"the desired audio settings could not be applied". Show the reason in
the list instead, e.g. "Audio 8 DJ (device not connected)".

ASIOInit's error code is mapped to a short label -- ASE_NotPresent, by
far the common case, reads "device not connected" -- and a driver that
will not load at all is tagged too.

ASIOCard::name stays the verbatim driver name, since it is the identity
stored in the settings and handed to asio_engine; the decoration lives
in displayName() and is only used for the combo box label. The item data
remains the bare name so setCard() keeps matching saved settings.

The driver currently streaming is never tagged, even if an earlier scan
saw its hardware as absent.

Co-Authored-By: Claude Opus 5.0 <noreply@anthropic.com>
rescan() is reached three times while score starts up: this factory's
constructor, Model::initDriver and ApplicationPlugin::initialize all call
it through the shared AudioFactory::initialize() API. Listing the drivers
is a cheap registry walk (~11 ms), but each one was then probed for its
channel counts, and a probe loads the driver's DLL through COM: measured
22-32 ms per driver here, before real hardware even answers. Doing all of
it three times cost roughly 330 ms before the GUI could paint.

Split the two halves. rescan() lists the drivers and returns early once
that has succeeded; probe() does the expensive load and runs at most once
per driver, tracked by ASIOCard::probed so "unknown" is no longer
indistinguishable from "0 channels, connected". Startup is now one
enumeration and one probe pass, ~110 ms.

The probe still refuses to run while an engine streams, since
AsioDrivers::loadDriver() releases the loaded driver. A forced rescan
therefore keeps the values it already had and only clears `probed`:
otherwise forcing one during playback would blank the channel counts and
the availability labels with no way to refill them.

Refreshing the list when the user opens the audio settings has to happen
on the page's show event, not in make_settings(): score builds every
settings widget during startup, so probing there just puts the cost back
where it was. Combo population is factored out so the refresh can rebuild
it, behind a QSignalBlocker so repopulating cannot look like the user
picking a device.

Co-Authored-By: Claude Opus 5.0 <noreply@anthropic.com>
Some backends need exclusive access to the hardware just to enumerate it.
ASIO in particular allows a single loaded driver per process, so probing
the installed drivers is impossible while one of them is streaming --
AsioDrivers::loadDriver() releases whatever driver is currently loaded.
Rescanning from the settings therefore has to take the engine down for
the duration, and stop_engine()/start_engine() are private.

Add with_engine_stopped(), which stops the engine, runs the callback and
brings it back.

It deliberately does not start an engine that was not running: a user who
stopped audio should not have it started behind their back. And it
restarts even when the callback throws, since leaving audio down would be
worse than whatever the callback failed at.

Co-Authored-By: Claude Opus 5.0 <noreply@anthropic.com>
The device list is refreshed when the settings page becomes visible, but
that is skipped while an ASIO device streams: probing loads drivers, and
AsioDrivers::loadDriver() releases whatever is currently loaded. So there
was no way to notice hardware connected mid-session short of restarting
score.

Add a Rescan button beside the device list. When nothing holds an ASIO
driver -- no engine at all, or another backend -- it probes straight away
and leaves playback alone. When an ASIO device is in use it goes through
ApplicationPlugin::with_engine_stopped(), which briefly interrupts
playback to free the driver; the tooltip warns about that.

QFormLayout takes a single widget per row, so the combo box and the button
share a zero-margin QHBoxLayout, the combo taking the spare width.

Co-Authored-By: Claude Opus 5.0 <noreply@anthropic.com>
@ogauthiersat
ogauthiersat self-requested a review August 3, 2026 15:19
@ogauthiersat ogauthiersat changed the title audio: implement an ASIO-based audio backend. Fixes #1422 audio: add a native ASIO backend on Windows Aug 3, 2026
@ogauthiersat

Copy link
Copy Markdown
Contributor

@ogauthiersat are you down for trying to rebase, test and finish that one?

I rebased the work you did on latest master.

I had to bring another PR to libossia as ASIO did not list any drivers because of an UNICODE/ANSI issue and fix a an driver release issue on audio engine restart. ossia/libossia#920

I tested this using ASIO4ALL and an 6 in / 4 out NI audio interface.

Devices are scanned at startup, re-scanned on audio setting window if the ASIO driver is not yet running. I added a "Rescan" button to the device list that trigger an audio engine restart if an driver is currently streaming (ASIOSDK cannot probe driver status (device connected and channel count) if a driver is already loaded).

@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 15.264%. remained the same — feature/asiosdk into master

@jcelerier

jcelerier commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

works great on my two ASIO devices (FAS Audio Axe-Fx III through its own asio driver, and B&W MM-1 through asio4all) here! I'll try with more later on, at the SAT.
Could you add the ASIO GPL license file here:

AddLicenseFile(license_text ossia "${CMAKE_SOURCE_DIR}/3rdparty/libossia/LICENSE")
so that it show up with the other licenses in the About dialog ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use ASIO directly instead of PortAudio on Windows

3 participants