◀ Back to Theory of operation

Audio backend

What it is for

The audio backend is the bridge between Phonalyser.web 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 desktop application answers that by opening its devices in exclusive mode[1], talking to the converter directly and bypassing the operating system's processing chain completely.

A browser is not allowed to do that, and this is the one place where the web build cannot match the desktop. A page has no exclusive-mode API: it receives capture through getUserMedia and plays through an AudioContext, both of them downstream of the system mixer. Whatever the mixer did - a resample, a volume taper, an "enhancement" - is already in the samples by the time this application sees them. So the two backends this build has divide cleanly by what they can promise:

BackendWhat it isWhat it can promise
Web Audio The browser's own capture and playback path - getUserMedia in, AudioContext out, samples as float32 after the system mixer. The default, and it works with every device the operating system exposes. Not bit-exact, and not exclusive: other applications keep using the device alongside it. Set the rate in the operating system, because the page cannot - see Windows input device.
QA40x A QuantAsylum QA402 / QA403 driven directly over WebUSB (below) - no operating-system audio path in the way at all. Bit-exact and exclusive, because the samples never enter the mixer: the analyzer is claimed as a USB device and read as one. This is the browser's answer to exclusive mode, and it is why a serious measurement in this build is usually made on a QA40x or on a bench.
A bench
<server> -> <backend>
A Phonalyser server's backend, reached over the network. The exclusive-mode open happens on that machine, in the desktop application. Bit-exact, with the desktop's full choice of driver paths - WASAPI, WDM-KS, JavaSound, its own QA40x. The browser never touches a converter here; it asks a machine that can.
Consequence of exclusivity, where there is any: a QA40x is claimed by one application at a time, so it must not be open in the QuantAsylum software or in another tab; and a bench device is locked to one client at a time, which the device list says in as many words. Web Audio has no such rule and needs none - it shares.

The QA40x analyzer backend

Alongside the browser's own audio path, Phonalyser.web 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 browser's WebUSB interface[3], speaking the same vendor protocol 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:

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:

20 log₁₀(2√2) ≈ 9.03 dB(1)

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.

What the browser needs. WebUSB needs Google Chrome - a narrower requirement than the application's own. Phonalyser.web runs on any Chromium browser, but the analyzer has only been verified to work in Chrome: on the same machine, over https:// with a valid certificate, Edge, Opera and Brave did not reach it. Being built on Chromium is not sufficient; the browser must also expose WebUSB and permit a page to use it. Firefox and Safari do not implement it at all, though you will not get that far there: the application itself refuses to start on a non-Chromium engine. The page must be served over https:// or localhost, and the first connection needs a click: the browser opens its own device chooser, which only you can confirm. On Windows the analyzer must be bound to a WinUSB-class driver - a per-machine step a web page cannot perform for you. As with exclusive mode, one application owns the instrument at a time: the QA402 / QA403 must not be open in the QuantAsylum software, or in another tab, while Phonalyser.web uses it.

How it works, roughly

Preferences driver path · device sample rate · bit depth Audio backend Playback lane keeps the DAC's hardware buffer filled a few ms ahead, continuously Capture lane receives blocks of raw samples at the hardware's own rhythm DAC exclusive ADC exclusive Generator ideal samples in Ring buffer normalised samples out

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:

x = (u − 2b−1) / 2b−1  ∈ [−1, +1)(2)

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:

Who uses the backend

ModuleDirectionHow
Oscilloscope, FFT analysercapture 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.
Generatorplayback Owns the playback session while the Play button is lit; tone parameters are applied live into the running stream.
Frequency responseboth, 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 toolsboth 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:

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. The desktop Phonalyser, run as a headless server on the machine that is wired to them, turns it into a bench, and this browser measures through it. A browser cannot itself be a server - it has no sound card of its own to share and nothing to listen on - so the bench half is always the desktop application; see The Phonalyser server. 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.

Workbench generator · scope FFT · sweep (this machine) one TCP port · 8377 commands · audio · info GET /info · peer table asked, not announced Phonalyser server the bench its own backends WASAPI · WDM-KS · QA40x one client per device the holder is named DAC exclusive ADC exclusive

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 a browser cannot hear it. A server announces itself twice a second by multicast, to the group 239.255.83.77 on 8377/udp, and the desktop client joins that group and learns of every bench in the room without being configured at all. A page has no socket that can join a multicast group, so none of that reaches this application: the beacon is still being sent, and there is simply nothing here able to listen for it.

What replaces it is one question asked over HTTP. You type one bench's address; the app fetches that server's /info to learn its identity, and then its peer table - the list of servers it has itself heard beaconing - and remembers every one of them. So the room does appear, but it arrives second-hand, through a bench that can hear the beacon, rather than from the network directly. The same fetch is what liveness means here: a server is "live" because it answered just now, not because an announcement arrived, and asking is done only while the servers window is open. One consequence worth stating plainly - with no address ever typed, the list is empty and stays empty.

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 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. The servers list uses the same philosophy from the other side: a bench that does not answer the window's periodic /info goes grey rather than disappearing, so a bench you know about stays on the list while it is switched off.

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.

No authentication, on purpose. A server accepts any client that reaches it. It is a bench instrument for a trusted network - the room the measurement equipment stands in - and its own documentation says so plainly; it is not something to publish to the open internet. Running one, and restricting it to a single interface, is described in The Phonalyser server.

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:

There is deliberately no central parser of every driver's error text. Each backend reads its own vocabulary - in a browser that is the DOMException names getUserMedia and the AudioContext constructor reject with, which is the only native error vocabulary a page is given: NotFoundError is a device that is not there, NotReadableError one another application holds, OverconstrainedError a rate or channel count the device refuses. NotAllowedError and SecurityError are deliberately not in that table: a permission you declined is not a device failure, and it is reported in the status line rather than as one. The QA40x reads WebUSB's own failures instead, 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

  1. What exclusive-mode streams bypass and guarantee - Microsoft Learn: Exclusive-Mode Streams.
  2. The kernel-streaming layer beneath WDM-KS - Microsoft Learn: Kernel Streaming.
  3. The browser API the QA40x backend uses to reach the analyzer - WebUSB.

◀ Theory of operation · next: Ring buffer & consumers ▶