Skip to content

audio: fix the ASIO backend listing no drivers and breaking on engine restart - #920

Open
ogauthiersat wants to merge 6 commits into
masterfrom
feature/asiosdk
Open

audio: fix the ASIO backend listing no drivers and breaking on engine restart#920
ogauthiersat wants to merge 6 commits into
masterfrom
feature/asiosdk

Conversation

@ogauthiersat

@ogauthiersat ogauthiersat commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The ASIO backend added in a750490 could not work in practice. Two independent
bugs, both silent. Base is master; no functional change to any other backend.

Net diff is two files — cmake/deps/asio-sdk.cmake and
src/ossia/audio/asio_protocol.hpp. Reviewing the final diff is easier than
going commit by commit: an intermediate commit added a diagnostics header that a
later one removed again, so it cancels out.

1. asioGetNumDev() always returned 0

cmake/deps/asio-sdk.cmake compiled the SDK with UNICODE/_UNICODE defined,
but host/pc/asiolist.cpp is an ANSI-only translation unit. It reads registry
key names with the TCHAR-generic RegEnumKey/RegOpenKeyEx into char
buffers, then parses them with explicit -A calls (CharLowerBuffA, strlen,
strcmp).

With UNICODE defined those resolve to the -W variants and write UTF-16 into
the char buffers, so strlen() stops at the first embedded NUL and
findDrvPath()'s strcmp() of the CLSID can never match. newDrvStruct()
therefore rejects every driver and asioGetNumDev() returns 0 — the device list
comes up empty with no diagnostic. The size arguments are character counts too,
so the -W calls can overflow keyname[128] / databuf[512].

Measured on a machine with three installed ASIO drivers, compiling the vendored
asiolist.cpp both ways against the live registry:

SDK build asioGetNumDev()
-DUNICODE=1 -D_UNICODE=1 0
ANSI 3

Nothing in the three compiled SDK sources needs UNICODE; the TCHAR-generic
calls are confined to asiolist.cpp and are all meant to be ANSI. Removed, with
a short comment so it does not come back.

2. The driver was released too late, so restarting the engine failed

Hosts routinely defer destruction of a stopped engine — ossia score parks it in a
list and reaps it from a timer once the audio thread acknowledges the stop. But
ASIO allows a single loaded driver per process and the driver was only released
by ~asio_engine(), so:

  • constructing the replacement engine while the old one was still alive tripped
    the "only one ASIO engine can be active at a time" guard, and the engine never
    came back;
  • had that succeeded, the old engine's late destructor would then have called
    ASIOExit() and removeCurrentDriver() on the driver the new engine had
    just claimed.

asio_engine::stop() now releases the driver, and cleanup() is idempotent and
guarded on still owning it, so a late destructor cannot tear down another
engine's driver. stop() also sets stop_received: ASIOStop() guarantees no
further bufferSwitch callback, so the audio thread can never acknowledge the
stop itself and a host would otherwise wait forever and never reap the engine.

open_control_panel() had a related bug: with an engine active it called
ASIOControlPanel() unconditionally, which talks to whichever driver is
loaded — not the one asked for — so it always showed the running driver's
panel. It now compares against the active driver and returns
other_driver_active rather than silently showing the wrong panel. It cannot
just load the requested driver instead, because AsioDrivers::loadDriver()
calls removeCurrentDriver() first (asiodrivers.cpp:161) and would release the
streaming driver mid-flight. With no engine running, the temporarily loaded
driver is now released again instead of left stranded, so asking for a different
driver afterwards works.

Logging

Driver discovery and every failure path now go through ossia::logger(),
formatted with fmt::format, at info for discovery and warn for a driver
that will not initialize or load. Previously the SDK dropped unresolvable
drivers without a word, which is what made bug 1 invisible.

API changes

  • asio_engine::active_driver() — name of the driver the running engine holds,
    empty if none. Lets a host avoid disturbing a streaming driver, since probing
    drivers requires loadDriver(), which releases the loaded one.
  • asio_engine::control_panel_resultopen_control_panel() now reports
    ok / other_driver_active / load_failed / init_failed instead of
    returning void, so a host can explain the failure. This is the only
    breaking signature change.

Testing

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

  • Enumeration verified both ways against the live registry, as above.
  • Engine lifecycle covered by a harness linked against the built library and
    driving a real driver, replicating a host's park-then-restart sequence.
    Pre-fix: replacement threw: ASIO error: only one ASIO engine can be active at a time. Post-fix: replacement constructs and runs, the late destructor leaves
    it alone, three consecutive cycles succeed.
  • Verified in ossia score end to end with real hardware: driver list populated,
    playback, repeated settings re-apply, control panel targeting, and hot-plug.

Unrelated finding, not addressed here

asiolist.cpp's newDrvStruct() copies the driver description into
ASIODRVSTRUCT::drvname with an unbounded strcpy from a 256-byte buffer into
a 128-byte field, so a driver whose description registry value exceeds 127
characters corrupts the adjacent asiodrv and next members. It lives in the
3rdparty/asio submodule rather than here, and is latent — no driver I have
triggers it.

Vendored SDK: upstream instead of a fork

3rdparty/asio pointed at jcelerier/asio-2, which is upstream audiosdk/asio
2.3.4 plus one patch. This switches to upstream and drops the need for it.

The patch added windows.h/unknwn.h to common/iasiodrv.h, which declares
IASIO : public IUnknown but includes neither. The SDK's own translation units
never noticed, since asiolist.cpp includes plain windows.h and so gets
ole2.h; only asio_protocol.hpp tripped over it, because it defines
WIN32_LEAN_AND_MEAN. Including the two headers there fixes it with no change to
the SDK. The patch's #pragma once is not needed — iasiodrv.h is included once
per translation unit in every path here.

The patch also rewrote seven registry calls in host/pc/asiolist.cpp to the -A
variants but left RegEnumKey and RegOpenKeyEx generic. That mix is exactly
what made a UNICODE build fail so obscurely: the generic calls resolved to -W
and wrote UTF-16 into the char buffers the -A ones then parsed. Upstream is
uniformly generic, so with UNICODE undefined every call resolves to -A and the
file is self-consistent — strictly better than the fork.

Verified by dumping the bytes asioGetDriverName() returns from both versions
against the same registry: byte-identical, including a driver whose registry name
contains non-ASCII characters.

Follow-up

A score-side PR depends on this one; it bumps the submodule and uses
active_driver() and control_panel_result.

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

ogauthiersat and others added 2 commits July 31, 2026 16:26
asio_sdk was compiled with UNICODE/_UNICODE defined, but the SDK's
host/pc/asiolist.cpp is an ANSI-only translation unit: it reads registry
key names with the TCHAR-generic RegEnumKey/RegOpenKeyEx into char
buffers, then parses them with explicit -A calls (CharLowerBuffA,
strlen, strcmp).

With UNICODE defined those resolve to the -W variants and write UTF-16
into the char buffers, so strlen() stops at the first embedded NUL and
findDrvPath()'s strcmp() of the CLSID can never match. newDrvStruct()
therefore rejects every driver and asioGetNumDev() returns 0, leaving
the device list empty with no diagnostic. The size arguments are also
character counts, so the -W calls could overflow the buffers.

Verified against a machine with 3 installed ASIO drivers: the SDK
enumeration returns 0 with UNICODE defined and 3 without it.

Also add ossia/audio/asio_diagnostics.hpp, which reads HKLM\SOFTWARE\ASIO
directly (explicit -A calls only, so it stays correct whatever UNICODE
is) and reports when the SDK returns fewer drivers than are actually
installed and loadable. enumerate_drivers() now traces each driver under
OSSIA_ASIO_DEBUG=1 and always reports such a mismatch, so this class of
failure is no longer silent.

Diagnostics go to stderr rather than ossia::logger(): enumeration runs
while the audio factories are constructed, before the log sinks are
usable.

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

Both problems come from the same gap: nothing tracked *which* driver the
active engine owns, and the driver was only released by the destructor.

1. Restarting the engine failed with "The desired audio settings could
   not be applied".

   Hosts routinely defer destruction of a stopped engine: score moves it
   into a pending list and reaps it from a timer once the audio thread
   has acknowledged the stop. But ASIO allows a single loaded driver per
   process, so constructing the replacement while the old engine was
   still alive tripped the "only one ASIO engine can be active at a
   time" guard, and the engine never came back. Had that succeeded, the
   old engine's late destructor would then have called ASIOExit() and
   removeCurrentDriver() on the driver the *new* engine just claimed.

   Release in stop() instead, and make cleanup() idempotent and guarded
   on still owning the driver, so a late destructor cannot tear down
   another engine's driver. stop() now also sets stop_received:
   ASIOStop() guarantees no further bufferSwitch callback, so the audio
   thread can never acknowledge the stop itself and the host would
   otherwise wait forever and never reap the engine.

2. open_control_panel() always showed the running driver's panel.

   With an engine active it called ASIOControlPanel() unconditionally,
   which talks to whichever driver is loaded -- ignoring the driver that
   was actually asked for. Compare against the active driver's name and
   report other_driver_active instead of silently showing the wrong
   panel; we cannot load the requested one, because
   AsioDrivers::loadDriver() calls removeCurrentDriver() first and would
   release the streaming driver mid-flight.

   When no engine is running, the temporarily loaded driver is now
   released again (ASIOExit + removeCurrentDriver) rather than left
   stranded, so asking for a different driver afterwards works.

Record the driver name to make both possible, and expose active_driver()
so hosts can avoid disturbing a streaming driver.

Co-Authored-By: Claude Opus 5.0 <noreply@anthropic.com>
Comment thread cmake/deps/asio-sdk.cmake
Comment thread src/ossia/audio/asio_diagnostics.hpp Outdated
Comment thread src/ossia/audio/asio_diagnostics.hpp Outdated
Comment thread src/ossia/audio/asio_diagnostics.hpp Outdated
Comment thread src/ossia/audio/asio_diagnostics.hpp Outdated
Comment thread src/ossia/audio/asio_diagnostics.hpp Outdated
Comment thread src/ossia/audio/asio_diagnostics.hpp Outdated
Comment thread src/ossia/audio/asio_protocol.hpp Outdated
Comment thread src/ossia/audio/asio_protocol.hpp Outdated
ogauthiersat and others added 3 commits August 4, 2026 10:43
The reasoning belongs in the pull request description rather than in the
source. Comment-only change, no code touched.

Kept short notes where the code alone would mislead: that asio_sdk must
not be built with UNICODE, that cleanup() is idempotent and ownership
guarded, and that the driver is released on stop() rather than on
destruction.

Co-Authored-By: Claude Opus 5.0 <noreply@anthropic.com>
Removes asio_diagnostics.hpp along with its registry cross-check and the
OSSIA_ASIO_DEBUG flag. Driver discovery and failures now go through
ossia::logger() formatted with fmt::format, so they reach the usual sinks
at the usual levels instead of being gated behind an environment variable.

Also drops the <iostream> include, unused now that nothing writes to
std::cerr.

Co-Authored-By: Claude Opus 5.0 <noreply@anthropic.com>
128 was already the right size -- it is what ASIODRVSTRUCT::drvname holds,
so asioGetDriverName() can never return more -- but naming the SDK's
constant ties the two together. A smaller buffer would not overflow, since
asioGetDriverName() truncates, but the truncated name would no longer match
in loadAsioDriver() and the driver would silently fail to load.

Co-Authored-By: Claude Opus 5.0 <noreply@anthropic.com>
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 4, 2026 16:15 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 4, 2026 16:15 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 4, 2026 16:15 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 4, 2026 16:15 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 4, 2026 16:15 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 4, 2026 16:15 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 4, 2026 16:15 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 4, 2026 16:15 — with GitHub Actions Inactive
ogauthiersat added a commit to ossia/score that referenced this pull request Aug 4, 2026
Picks up the logger migration, the comment trim and the MAXDRVNAMELEN
change from ossia/libossia#920, so this builds against exactly the libossia
code under review rather than an earlier commit on that branch.

Still needs re-pointing at the merged commit on libossia master before this
can be merged.

Co-Authored-By: Claude Opus 5.0 <noreply@anthropic.com>
The submodule pointed at jcelerier/asio-2, which is upstream 2.3.4 plus a
single patch doing two things. Neither is needed any more.

The patch added windows.h and unknwn.h to common/iasiodrv.h, which declares
IASIO as deriving from IUnknown but includes neither. The SDK's own
translation units are unaffected, since asiolist.cpp includes plain
windows.h first and so gets ole2.h; only asio_protocol.hpp tripped over it,
because it defines WIN32_LEAN_AND_MEAN. Including the two headers there
fixes it without touching the SDK. The patch's #pragma once turned out to be
unnecessary: iasiodrv.h is included once per translation unit everywhere.

The patch also rewrote seven registry calls in host/pc/asiolist.cpp to the
-A variants, but left RegEnumKey and RegOpenKeyEx generic. That mix is what
made a UNICODE build fail so obscurely: the generic calls resolved to -W and
wrote UTF-16 into the char buffers the -A calls then parsed. Upstream is
uniformly generic, so with UNICODE undefined every call resolves to -A and
the file is self-consistent. Verified by dumping the bytes asioGetDriverName
returns from both versions against the same registry: identical.

Co-Authored-By: Claude Opus 5.0 <noreply@anthropic.com>
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 5, 2026 20:40 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 5, 2026 20:40 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 5, 2026 20:40 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 5, 2026 20:40 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 5, 2026 20:40 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 5, 2026 20:40 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 5, 2026 20:40 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 5, 2026 20:40 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 5, 2026 20:40 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 5, 2026 20:40 — with GitHub Actions Inactive
@ogauthiersat
ogauthiersat temporarily deployed to Apple Certificate August 5, 2026 20:40 — with GitHub Actions Inactive
ogauthiersat added a commit to ossia/score that referenced this pull request Aug 5, 2026
Picks up the switch from the jcelerier/asio-2 fork to upstream audiosdk/asio,
along with the logger migration and the MAXDRVNAMELEN change, from
ossia/libossia#920.

Note this moves the nested 3rdparty/asio submodule URL as well, so a checkout
needs `git submodule sync --recursive` rather than just `update`.

Still needs re-pointing at the merged commit on libossia master before this
can be merged.

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

Copy link
Copy Markdown
Contributor Author

@jcelerier I worked around the patch you did so upstream now work.

The -A variant of the registry calls are not needed anymore as the translation unit is now configured to build as ANSI translation unit.

The missing includes have been moved to our client code.

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.

2 participants