What it is for
The audio backend is the bridge between Phonalyser and the sound hardware. Its one job is to move samples between the application and the converters bit-exactly: what the generator computes is what the DAC converts, and what the ADC converts is what the analysers see - with nothing in between.
That is not how a PC plays audio by default. The normal Windows audio path runs every stream through a mixer, a volume control, a sample-rate converter and possibly "enhancement" filters - each one harmless for music, each one fatal for a measurement. A resampler alone adds distortion products far above what a good ADC can resolve. The backend therefore opens the devices in exclusive mode[1]: Phonalyser talks to the converter directly, at the exact sample rate and bit depth you chose, and the operating system's processing chain is bypassed completely.
The driver paths
Sound hardware can be reached through several driver interfaces. Phonalyser implements three general-purpose paths - each works with practically any interface - plus one device-specific backend for the QuantAsylum QA402 / QA403 analyzer (below). You pick one in Preferences; all modules work identically on top of whichever is active.
| Path | What it is | When to use it |
|---|---|---|
| WASAPI | The modern Windows audio interface, in exclusive mode. | The default on Windows. Bit-exact, reliable, works with practically every interface. |
| WDM-KS | Windows kernel streaming[2] - the lowest level the driver offers. | Highest capture throughput in practice; worth trying when the oscilloscope should keep its full update rate at very high sample rates (384 kHz and up). |
| JavaSound | The portable runtime audio interface. | The only path on Linux and macOS; a fallback on Windows. |
The QA40x analyzer backend
Alongside the three general-purpose paths, Phonalyser can drive a QuantAsylum QA402 / QA403 audio analyzer directly, over USB, with the vendor software closed. This is not a driver path in front of a sound card - it is a purpose-built backend for one instrument, reaching it through the USB library libusb[3] the same way the manufacturer's own software does.
The QA40x is a self-contained measurement front end: an ADC and a DAC on one board, each with a set of switchable full-scale ranges (input attenuator / output level) and its own factory calibration held in the device. Two consequences for how you use it:
- One clock, so the rates are locked together. A single sample-rate generator on the board feeds both the ADC and the DAC, so the input and output sample rates can never differ - set one in Preferences and the other follows. The list is 48 / 96 / 192 kHz, plus 384 kHz on a QA403: the sample-rate register has a fourth code the QA402 does not have, so the rates offered follow the model that is attached.
- The range is the calibration. Full-scale in volts comes from the selected range and the device's own calibration, not from a crosshair calibration you enter, so the dBV axis and the scope readouts are correct the moment you pick the range. Input and output carry their own independent ranges; switching one rescales that direction's full scale at once, the same range-is-calibration rule holding for both.
The QA40x always runs full duplex - it captures and plays back as one session, never one direction on its own - so the generator and the analysers ride the same open instrument rather than opening it twice. Samples cross the USB link as 32-bit words whose top 24 bits carry the signal (the low byte is zero padding), so the effective resolution is 24-bit.
What a "dBV" input range actually is
The analyzer names its input ranges in dBV - 0 dBV,
+18 dBV, and so on - and taking those names for RMS full-scale
voltages is wrong by about 9 dB. The reason is the front end's
topology, not a labelling slip. A QA "N dBV" input range is an
N dBFS reference measured peak-to-peak across the
differential input pair. Converting that reference to the RMS full
scale the rest of a measurement is computed from costs two factors - a
factor 2 going from peak-to-peak to peak, and √2 going from peak to RMS:
So the range the instrument calls 0 dBV is a full scale of about
−9 dBV RMS, and it is that RMS figure - never the label - that the dBV
axis, the volts readouts and every derived metric use. The ranges table
therefore prints the truth beside the name on every input row, as
display text only: the stored card keys and the range radios keep the plain
N dBV so nothing in the device store changes
meaning.
The output ranges carry no such term - those are genuine dBV, single figures for the level the DAC will produce. The asymmetry is exactly what makes a loopback measurement of a QA40x look wrong at first sight: connect its output to its input and the fundamental measures about √2 - 3 dB - louder than the level the generator was asked for. Neither converter is at fault and nothing is miscalibrated; the balanced output is driving a differential input, so a 1 VRMS output presents ±2 V at the input, and the whole discrepancy belongs to the input side's peak-to-peak reference.
How it works, roughly
Capture. When a view starts recording, the backend opens the input device with exactly the sample rate and bit depth from Preferences. From then on the hardware delivers blocks of raw integer samples on its own clock. Each sample u (an unsigned integer of b bits) is converted once to a dimensionless fraction of full-scale:
and stored in the ring buffer. The whole analysis pipeline works in these normalised units; conversion to volts happens only at display time, using the ADC full-scale calibration. This way a recalibration never touches recorded data - it just changes the ruler.
Playback. The playback lane is a simple, robust producer: it asks the generator for the next batch of ideal samples, passes them through the quantizer with dither, and writes them to the device - which accepts data exactly as fast as it converts it, so the device's own clock paces the loop. Three details matter for measurements:
- Warm-up. Before the line starts, about half a second of signal is generated and thrown away so the runtime reaches full speed before the first audible sample - at 384 kHz a cold start could otherwise stutter. Sweep playback is rewound to zero afterwards, so a frequency-response capture always sees the sweep from its very first sample.
- Pre-fill. The hardware buffer is filled completely before playback is reported "ready" - measurement workers start their capture only after this point, so they never record the silence before the stream truly runs.
- Headroom. In steady state the buffer stays a few milliseconds ahead of the converter, which is what makes the output gap-free even when the rest of the machine is busy.
Who uses the backend
| Module | Direction | How |
|---|---|---|
| Oscilloscope, FFT analyser | capture | Share one capture session and one ring buffer - see capture session. The device opens when the first of them records and closes when the last one stops. |
| Generator | playback | Owns the playback session while the Play button is lit; tone parameters are applied live into the running stream. |
| Frequency response | both, exclusively | Needs the DAC and ADC simultaneously and alone: it first asks every other module to stop (and waits until the devices are really free), then runs the sweep through both converters. |
| Command-line tools | both | The same backend drives the headless measurement modes, so CLI and GUI results are comparable by construction. |
The hardware volume is part of the chain
On Linux and macOS the card's own volume controls sit between the socket and the converter, so they scale every sample INSIDE the calibrated chain - invisibly, because the calibration was measured through wherever they stood at the time. The backends therefore treat the volume as borrowed state around every stream:
- Open pins to the device's own 0 dB, never to "maximum". On
Linux the target comes from the control's dB scale in the
amixerdump (a capture gain whose scale runs 0..+12 dB has its unity at the BOTTOM); on macOS from the HAL's dB-to-scalar translation property. A control publishing no dB information at all is set to full scale. - Addressing is strict. Only the opened device and direction are touched. On ALSA the controls are matched by the opened socket's own terminal word (Line, Mic, Headphone) plus the stream-side PCM control; a stream control the card publishes once per PCM pair under one name is resolved by its control index, which the kernel assigns in PCM device order - and a PORT-named name published twice is refused outright, because moving a stranger's socket is worse than leaving one control unpinned. A macOS aggregate device publishes no controls of its own, so its ACTIVE member devices are pinned instead.
- Close restores. Every write the pin made is remembered with the value it replaced and written back when the stream ends - however it ends. Only what the pin itself moved is restored, so an operator's mid-stream adjustment survives the stop.
- Nothing here may stop a measurement. A control that cannot be read, mapped or written is named in the log and left alone; the open proceeds at whatever level the hardware kept.
Windows is excluded by decision, not omission: the system volume there is part of the calibration - set once, calibrated through, left alone - and the exclusive-mode path bypasses the mixer entirely.
A bench across the network
Everything above assumes the converters are wired to the machine you are sitting at. They need not be. A second Phonalyser installation, run as a headless server, turns the machine that is wired to them into a bench, and your workbench measures through it. The arrangement changes nothing about the reasoning on this page - the bench still opens its devices exclusively, still at the exact rate and depth chosen, still bit-exact. What crosses the network is not audio hardware but the same two lanes, stretched.
The server owns the machine's backends. It is not a driver of its own: it starts every backend present on its host - WASAPI, WDM-KS, CoreAudio, JavaSound, a QA40x - and offers them outward. A backend that is in its build but cannot run on that host is not offered at all rather than offered and refused. From your side each one appears in the backend list as a further entry, so the modules above the backend cannot tell the difference and do not have to.
One port carries everything. The control channel, the audio stream and the informational endpoints share a single TCP port - 8377 by default. That is a deliberate simplification with a practical payoff: one number to type, one hole to open in a firewall, and no second connection that can be up while the first is down.
Discovery is a beacon, and it is link-local. A server announces
itself twice a second by multicast, to the group 239.255.83.77 on
8377/udp - that announcement port is fixed and is not the
--port the server serves on, so a bench moved to another port
still announces there and carries its real port inside the announcement -
so a client that joins the group learns of every bench in the
room without being configured at all - and learns of one going away just as
quickly, since a row that stops being announced ages out. The announcement
deliberately carries a time-to-live of one hop: it is a bench instrument on
a local network, not a service advertised across a site. That is exactly
why a bench on another subnet has to be added by its address - the
announcement cannot reach you, though the session itself routes perfectly
well.
Exclusivity survives the trip. The rule at the top of this page - one application at a time owns a device - becomes, across the network, one client at a time: the server holds a lock per device and per direction, and it publishes the name of the client holding it with every device it lists. So two operators sharing one bench see each other's occupancy before they try to open something, instead of finding out through a refusal. A lock is taken when a measurement opens the device and given back when it stops - and a client that disappears releases everything it held.
The generator runs on the bench, commanded rather than streamed. This matters more than it sounds. The workbench does not push audio across the network for the bench to play; it tells the bench what to emit, and the bench's own generator renders it into its own DAC, at the device's own clock. Two consequences follow, and the second is the measurement one:
- The network never has to carry a gap-free output stream in real time - a jitter budget nobody would want a measurement to depend on.
- A frequency-response sweep is deconvolved against what the bench actually rendered. The sweep's own first sample is marked in the capture stream itself, in band, so the analysis anchors on the bench's timing rather than on a network arrival time. Deconvolution divides the captured response by the reference sweep, and an anchor drifting by a packet's worth of latency would smear the impulse response the whole measurement rests on.
The granted rate is the truth. When a device grants a different sample rate than the one asked for - the driver's decision, not the server's - the two directions of the same question get different answers. A live capture re-pins to the granted rate and runs: the samples are perfectly good and the frequency axis simply follows the rate they were taken at. A sweep in the same position is refused: its reference was computed for the rate that was asked for, and deconvolving against a reference of the wrong rate produces a curve that looks plausible and is wrong, which is worse than no curve at all.
The audio stream confesses its gaps. Captured samples cross as binary messages of three kinds, each carrying a counter that increments on every message of any kind. Sample messages carry the bench's native PCM exactly as its device produced it, so the workbench decodes them with the same code a local card goes through. A marker carries no audio at all: it announces that the next sample byte after it is the sweep's own first sample, which is what anchors a deconvolution to the bench's timing. And a gap message says, in as many words, that the server itself lost n stereo frames - a stalled consumer, a device overrun - before they ever reached the network.
That third kind is the one that matters for a measurement. The alternative is to say nothing and let the two sides of the hole be concatenated, which produces a record whose samples are not adjacent in time and whose average therefore reports a noise floor and a phase that never existed. So the loss is stated, the analyser resets its average rather than splicing, and the FFT raises its bench lost audio banner. A jump in the counter, by contrast, would mean loss in the transport - impossible over an intact ordered connection - and is treated as a protocol fault: surfaced and stopped, exactly like a device error.
Both ends watch the session, twice a second. A measurement session is not request-response: once a capture is running, a healthy connection looks exactly like an idle one, and a dead one looks the same again. So each side sends a keepalive every 500 ms and counts the answers; four unanswered in a row - two seconds - declares the connection dead. Everything then unwinds in the direction it must: on the workbench every module measuring through that bench is stopped and the reason is shown, rather than leaving a scope drawing the last frame it received; on the bench the vanished client's locks are released and its hardware is parked, so the next operator finds a free device rather than one held by a machine that has gone home. Discovery uses the same threshold philosophy - a server that stops announcing ages out of the list after four missed announcements.
A file played on the bench travels once, and belongs to the session. The protocol carries no audio from the workbench to the bench, so Play from... cannot stream: instead the chosen file is uploaded whole, over the same port, and the bench's own generator lane decodes and plays it out of the bench's DAC. The upload is capped at 50 MB and the store as a whole is bounded too, so one client cannot fill a bench's memory; the workbench checks the size before a byte leaves, so an over-size file is refused immediately rather than after the transfer. The bytes live in the server's memory only, are owned by the session that referenced them, and are dropped when that connection closes - nothing accumulates on a bench, and no file outlives the operator who sent it.
How to reach a bench from the workbench - the server list, the
<server> -> <backend> entries, the per-bench cards
and calibration - is described in
Preferences ▸ Measuring
through a Phonalyser server.
When a device won't open - and when it stops
Exclusive mode means open attempts can fail for everyday reasons. A native return code says nothing an operator can act on, so the backend that owns the error classifies it into one of six reasons, and the dialog is written from the reason and translated like every other string:
- The device was disconnected - unplugged, or removed by the driver while the application still had it in its list.
- The device does not answer - it is there and does not respond: the open timed out, or the driver took the call and never came back.
- The device is in use by another application - it (or another exclusive stream) holds the device.
- The device was not found - it is not on this host at all: a card remembered from another machine, or one that has been renamed.
- The device does not support this format - it is present and free but refuses the chosen sample rate, bit depth or channel count; pick another in Preferences (16/24 bit at 48/96/192/384 kHz are almost universally accepted).
- Reason unknown - the backend could not tell. This is the mandatory fallback and never an error in itself; the log line beside it carries the raw detail.
There is deliberately no central parser of every driver's error text. Each backend reads its own vocabulary - PortAudio's table serves the WDM-KS and CoreAudio paths as well, JavaSound reads the exclusive provider's messages, the QA40x backend reads libusb's and the WASAPI path reads HRESULTs - so a backend added later brings its own vocabulary and nothing above it has to learn it. The raw driver text always goes to the log, every time, and only there: it is what a developer needs and the one place it belongs. A Phonalyser server refusing a device across the network reports through the same six reasons, so a remote failure reads like a local one.
A stream that dies after it started
An open that fails is the easy case: something threw, and there is a caller to tell. The hard case is a stream that was running and stops. Above the driver there is nothing to notice: a delivery callback that is never called again looks exactly like a very quiet input, so the oscilloscope goes on drawing a flat line, the analyser goes on averaging silence, and the capture-rate readout goes on ticking - a measurement of nothing, presented as a measurement.
The way out is a contract rather than a notification. A stream that has been started owes a block every audio period. Silence is not an absence of blocks; silence is blocks full of zeroes. So nothing at all for two seconds is not a quiet signal, it is a stopped stream, and a backend whose driver never tells it anything watches its own delivery and says so. A driver that does report the loss is believed at once, which is why the dialog distinguishes the two: the device was lost, or it stopped delivering.
The playback side needs the mirror image of that, with one extra precaution. A render loop that blocks inside the driver waiting for room cannot notice anything, and on an unplug it may not even be able to leave. So the loop is paced on the line's own admission instead - it writes only what the device says will fit and waits outside the driver when nothing does - which keeps the thread free to end, and turns two distinct symptoms into two clear verdicts: nothing admitted for the deadline, or a write the line accepted only part of, which is the driver itself saying the lane is dead.
However the end is detected, it travels the path the samples did - up through the ring buffer and its readers to the panes - rather than being announced sideways. That is what makes the consequences complete without anybody coordinating them: the scope and the analyser stop, one dialog names the backend and the reason, a streaming save to disk is aborted instead of quietly recording nothing, and a sweep whose capture lost samples or stalled mid-run is refused with the reason rather than deconvolved across the hole.
The device that leaves without a word (macOS)
CoreAudio adds two twists the contract above cannot see. A pulled device
can leave its stream nominally active: the playback callback goes
on being invoked into the void, so neither the frame count nor
Pa_IsStreamActive ever reports the loss - and an aggregate
device whose members all left even goes on delivering capture blocks full
of zeroes, which defeats the delivery deadline too, because silence keeps
arriving on schedule. Both sides therefore ask a question no stream can
answer: once a second, the live HAL is asked whether the device is still
on the machine at all - and for an aggregate, whether anyone is left on
its active sub-device list, since the aggregate's own name survives its
members. "Cannot answer" counts as present: a failed read must never
kill a healthy measurement.
The second twist is the teardown itself. CoreAudio's stop waits on a semaphore that a vanished device never signals, so a thread that politely drains would hang for ever - and a close abandoned by its watchdog leaves the stream open and COUNTED, which matters beyond the leak: PortAudio can only re-enumerate devices by tearing the whole library down, which is forbidden while any stream is open. One hung close would therefore veto every future device-list rebuild until the process dies. Both CoreAudio directions abort instead of draining - a measurement stream has nothing worth draining - and the capture grants a freshly started stream a longer first-block deadline, because an aggregate assembles its IO for several seconds before it speaks at all.
The device list itself follows the same discipline: PortAudio's enumeration is a snapshot taken once, so a poll compares the live HAL's device set against the set the snapshot was built from, and only a real difference spends the rebuild - which then waits until the last stream is closed. A replugged device is a new audio object under an old name, and that identity change is exactly what the comparison sees.
References
- What exclusive-mode streams bypass and guarantee - Microsoft Learn: Exclusive-Mode Streams.
- The kernel-streaming layer beneath WDM-KS - Microsoft Learn: Kernel Streaming.
- The portable USB library the QA40x backend uses to reach the analyzer - libusb.