audio: add a native ASIO backend on Windows - #1975
Conversation
74fc006 to
f39f3c1
Compare
|
@ogauthiersat are you down for trying to rebase, test and finish that one? |
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>
f39f3c1 to
a8c35e7
Compare
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). |
|
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. Line 701 in 1926765 |
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) livesin libossia; that PR fixes the driver enumeration and driver-ownership bugs this
backend cannot work without, and adds the
active_driver()/control_panel_resultAPI used here. The submodule currently points atossia/libossia@2b2e45aon thefeature/asiosdkbranch — it needsre-pointing at the merged commit on libossia
masterbefore this is merged.Closes #1422
What it adds
Audio/ASIOInterface.hpp—NativeASIOFactory, listed as "ASIOSDK" alongsidethe existing PortAudio-based "ASIO" backend.
SCORE_AUDIO_BACKEND=asioto force the backend at startup.Audio/AudioApplicationPlugin.{hpp,cpp}—with_engine_stopped(), abackend-agnostic helper described below.
Guarded by
OSSIA_ENABLE_ASIO(on by default on Windows in libossia) andTARGET 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 fromASIOInit's errorcode.
ASIOCard::namestays the verbatim driver name — it is the identity storedin the settings and handed to
asio_engine— and only the combo box label isdecorated.
Rescanning cannot happen behind the engine's back. Probing a driver for its
channel counts means loading it, and
AsioDrivers::loadDriver()releaseswhatever driver is currently loaded, which would cut a streaming engine off from
its hardware. Since
rescan()runs on everysetDriver()/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 — thisfactory's constructor,
Model::initDriverandApplicationPlugin::initialize,all through the shared
AudioFactory::initialize()API. Listing drivers is acheap 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:
Refreshing the list is on the show event, not
make_settings(). score buildsevery 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 stopsthe 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 ismeant to stay visible in the settings regardless.
initialize()auto-selects the first device without checking connectivity — onthe 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.
driver is streaming instead of showing the wrong panel.
selected while another backend still runs, and device unplugged mid-playback.
OSSIA_ASIO_DEBUG=1traces enumeration and per-driver probing to stderr.Note for reviewers
2d4a2eeis the original backend commit rebased onto currentmaster; the sixcommits after it are the bring-up fixes. It no longer bumps the submodule, since
masteralready 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