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:
| Backend | What it is | What 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. |
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.
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.
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.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:
| 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. |
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:
amixer dump (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.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.
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.
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.
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.
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.
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.
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.