Two upstream changes landed in ya-webadb on 2026-10-30, and they point in the
same direction: making the library viable as a real-time media client in a
browser tab. The first gives @yume-chan/pcm-player a pool of sharedAudioContexts and an auto-resume path that survives the browser’s
autoplay policy (93fce383).
The second bumps the zstd compression adapter’s WASM codec from 0.0.58 to0.2.6, a range that includes a simd128 WebAssembly build
(59e95a0e).
Neither change is a user-visible feature release. Both are the kind of
change that determines whether a feature can exist, though, and both are
directly relevant to webadb.online: the audio-context work removes a
browser-policy failure mode that any future audio panel would hit, and the
zstd bump is the prerequisite for the compressed ADB sync transport that
would make pushing and pulling large files over WebUSB practical.
This post walks through what the shared-context change actually does, why
the stop() semantics had to change with it, and what the zstd version
jump means for a browser-based ADB client.
The problem with one AudioContext per player
The pre-commit PcmPlayer constructor looked like this:
1 | constructor(sampleRate: number, channelCount: number) { |
Every player instance allocated its own AudioContext, and stop() closed
it:
1 | async stop() { |
That works, but it has two costs that only show up once you have more than
one player, or once you stop and restart.
The first cost is resource duplication. An AudioContext is not a
lightweight object. It owns an audio rendering thread, its own clock, and —
once you touch audioWorklet — a separate rendering graph with its own
allocation. Two players at 48 kHz stereo means two rendering threads, two
clocks, and two independent latency measurements. The outputLatency getter
on PcmPlayer reports whichever context that particular player happens to
own, so with N players you get N different latency numbers that do not
agree with each other.
The second cost is the stop() → restart → dead context problem, and
this is the one that actually breaks. AudioContext.close() is
irreversible. The README documents the consequence directly:
1 | var player = Int16PcmPlayer(44100); |
So the old API made a player a one-shot object. There was exactly one
lifecycle: construct → start → feed → stop. If you wanted to play a second
clip after the first, you had to construct a new player, which meant a
new AudioContext, which meant paying the full construction cost again.
The commit changes this in three coordinated moves.
Move one: a context pool keyed by sample rate
1 | const SharedContexts = new Map<number, AudioContext>(); |
The pool is keyed by sampleRate and nothing else. That is the right
granularity: an AudioContext has a single fixed sample rate for its
entire lifetime — you cannot change it after construction — so two players
can only share a context if they agree on the rate. ThelatencyHint: "interactive" setting is preserved from the old
per-player construction, so pooling does not change the latency class.
The constructor now takes an optional context and falls back to the pool:
1 | constructor( |
The optional third parameter is the escape hatch. A caller that wants
complete control over the context — an OfflineAudioContext for
rendering to a buffer, or a context with a specific latencyHint — can
pass one in. A caller that does not care gets the shared one. Note the
parameter is positional-optional rather than an options bag, which is
slightly unusual for the codebase but keeps the diff minimal.
Channel count is not part of the pool key, and it does not need to be.AudioWorkletNode takes outputChannelCount per node, so two players
sharing one context can output different channel counts:
1 | this.#worklet = new AudioWorkletNode(this.#context, this.sourceName, { |
The context is the clock and the rendering thread; the node is the channel
configuration. That separation is what makes the pool sound.
Move two: memoized worklet module loading
1 | const AddModulePromises = new WeakMap<AudioContext, Promise<void>>(); |
audioWorklet.addModule() compiles and instantiates the processor’s
JavaScript. It is not free, and it is per-context: each AudioContext
has its own audioWorklet namespace and needs its own module registration.
So the natural optimization is to not re-add the module for the same
context twice.
The WeakMap is the interesting part. It keys on the context object, and
weak keys mean the entry disappears when the context is garbage collected —
no leak if a caller manages their own context lifecycle outside the pool.
It stores the promise, not the resolved value, so that two concurrentstart() calls on the same context share one in-flight addModule() rather
than issuing two compilations. The old code called addModule() on everystart(), which meant every player start triggered a fresh worklet
compilation even when the module was already registered in that context.
Move three: the context is no longer closed on stop
1 | stop() { |
This is the change that requires the other two. Because the context may be
shared, stop() cannot close it — closing it would kill every other player
using the same context. Instead it disconnects the worklet node and drops
the buffered samples.
Two details are easy to miss:
stop() is no longer async. The old signature wasasync stop() because it awaited this.#context.close(). With the close
removed there is nothing to await, and the follow-up commit
(59e95a0e)
drops the async keyword entirely, which is what the lint fix in that
commit was actually about. This is a breaking API change for anyone who
was awaiting stop() — await on a non-promise is harmless in JS, so
existing call sites keep working, but the type signature changes fromPromise<void> to void and TypeScript will notice.
this.#buffers.length = 0 is new. Previously the buffers were left
alone on stop(). Now they are explicitly cleared, which is correct: the
buffer holds queued samples that were fed before start() was called, and
after stop() they are dead weight that would be replayed if the player
were somehow restarted.
The #stopped flag still makes the player one-shot in the same sense as
before — feed() throws new Error("PcmPlayer is stopped") after a stop,
and start() early-returns if #stopped is set. What changed is that
stopping a player no longer destroys the shared context, so a new player
created afterwards can immediately use it.
The auto-resume path and the autoplay policy
The other half of the feature is #tryResuming:
1 | /** |
This is the part that matters most in practice, because it deals with
Chrome’s autoplay policy.
An AudioContext created before any user gesture starts in thesuspended state. The audio graph exists, process() calls still fire on
the rendering thread, but the output is not connected to the destination —
or, more precisely, the context’s output produces silence. This is
Chrome’s way of saying “you may not make noise until the user has
interacted with the page.” The context transitions to running on a user
gesture, and context.resume() can also do it from a gesture handler.
The problem for a streaming audio client is timing. Consider: the user
clicks “Connect device”, which is a user gesture. The ADB session comes up,
the shell starts, and the PCM stream begins flowing a few seconds later.
By the time the first samples arrive, is the context still running?
In the good case, yes. In the bad case, no — and the reasons are subtle:
The gesture was in a different task. Chrome’s autoplay policy
tracks whether the document has ever had a user activation, not
whether the current call stack descends from a gesture.navigator.userActivation.hasBeenActive
captures exactly that broader question, and the check in#tryResuming
uses it.Something suspended the context. Chrome suspends an
AudioContext
when the page goes to the background, when the tab is hidden, or when
the device’s audio session is interrupted (a phone call on the host
machine, another app taking exclusive audio). The context comes backsuspendedafter the tab is foregrounded again, with no user gesture
involved.The context was created lazily inside the pool. If the first player
for a given sample rate is constructed after the gesture, its context is
born post-activation and startsrunning. But if the pool already had a
context from before the gesture, the new player inherits asuspended
one.
In all three cases, the symptom is identical: the worklet is running, the
samples are being consumed, the buffer counters are moving, and the user
hears nothing. There is no error, no exception, no console warning. The
panel would just sit there mute, and the natural debugging instinct —
“maybe the stream is broken” — points in exactly the wrong direction.
The fix is to attempt a resume opportunistically at every point where
samples enter the system:
1 | feed(source: T) { |
and again in start(), after the worklet is connected:
1 | this.#worklet.connect(this.#context.destination); |
Note the feed() placement — the resume attempt happens after the#worklet === undefined early return. Before start(), there is no
worklet to feed, so the samples are queued and no resume is attempted.
That is correct: resuming a context with no connected output graph would
just start the rendering thread producing silence.
Two defensive details are worth calling out:
navigator.userActivation.hasBeenActiveis checked beforeresume()is called. Callingresume()without an activation is
rejected by the browser, and an unhandled rejection in avoid-ed
promise is exactly the kind of thing that shows up as an uncaught
error in the console and hides the real problem. Checking first means
the common case (user has interacted at some point) attempts the resume,
and the rare case (no activation ever) silently does nothing.The
.catch(() => {})swallows the rejection.resume()can fail
for reasons other than the autoplay policy — the context might have been
closed, or the browser might refuse for a reason that is not worth
interrupting the audio pipeline over. Swallowing it keeps the audio path
from throwing, and the user-visible symptom (still muted) is the same
as before, so there is no information lost.
navigator.userActivation is a fairly recent addition to the platform
(Chrome 77+), which is well within the baseline for anything that supports
WebUSB and Web Audio anyway.
What the worklet actually does with the samples
The worker/worker.ts file is unchanged by this commit, but it explains
why the buffering behaviour in PcmPlayer is shaped the way it is.
The processor is a resampler with an overlap-add (OLA) window. SCALE = 0.9
means the input is consumed at 90% of real time — 3000 input samples per
hop produce 2700 output samples. That 10% headroom is what lets the
processor speed up when it falls behind:
1 | if (!this.#speedUp && this.#totalSampleCount > 0.25 * 48000) { |
Once the backlog exceeds a quarter second of samples, the processor switches
into #speedUp mode and runs the Hann-weighted OLA window to burn through
the excess rather than playing it all in real time. There is a hysteresis
band — it drops back out of speed-up mode only when the backlog falls below
0.15 seconds.
The practical implication for the PcmPlayer API is that feed() beforestart() is not an error, it is queued.
1 | if (this.#worklet === undefined) { |
Samples fed before the worklet exists are held and replayed oncestart() connects the node. And start() is where the context finally
becomes eligible to run:
1 | async start() { |
The if (this.#stopped) return; check after the await is a
race-condition guard: addModule() is asynchronous, so stop() could
have been called while the module was compiling. Without the check, the
player would create a worklet node and connect it to a context that the
caller already stopped using.
The zstd jump: 0.0.58 → 0.2.6
The second change is a dependency bump that crosses the 0.1.0 boundary,
and it is easy to miss because it is buried in a commit whose subject line
is “fix lint error”.
The diff in libraries/adb-compression-zstd/package.json:
1 | "dependencies": { |
The upstream package’s publish timeline makes the significance clear:0.0.58 (2026-10-01) → 0.0.59 (10-08) → 0.1.0 (10-10) → 0.1.1
(10-10) → 0.2.0 (10-12) → 0.2.1 (10-16) → 0.2.2 (10-17) → 0.2.3
(10-19) → 0.2.4 (10-21) → 0.2.5 (10-22) → 0.2.6 (10-26).
Eleven releases in twenty-six days, and the ya-webadb bump landed on
2026-10-30 — four days after 0.2.6 shipped. The package is a pure-Rust
Zstandard implementation compiled to WebAssembly, and the 0.1.0 release is
where it stabilized its public API:
1 | import { |
What the codec actually does for an ADB client
Zstandard in the ADB protocol is not a cosmetic feature. The ADB sync
protocol (sendrecv_v2) supports multiple compression algorithms for file
transfer, and the device advertises which ones it accepts in its feature
string:
1 | shell_v2,cmd,stat_v2,ls_v2,fixed_push_mkdir,apex,abb,fixed_push_symlink_timestamp, |
cmd and shell_v2 are the shell protocol versions. sendrecv_v2_zstd
means the device will accept zstd-compressed sync traffic. Modern Android
devices advertise all three of brotli, lz4, and zstd.
Without a compression adapter, a browser-based client falls back to the
uncompressed sync path. For webadb.online’s File Manager, that means everyadb push of an APK or an APK split — files that are already compressed
and therefore barely compressible — goes over WebUSB uncompressed, and
files that are compressible (text, images, resources) pay full size.
The SIMD part
The upstream README describes the two-payload design:
Automatic SIMD. Ships two payloads — one built with the WebAssembly
simd128SIMD tier, one scalar — and picks the fast one at runtime via
engine feature detection. You import one package; the right.wasmloads.
And the selection mechanism:
WebAssembly has no runtime CPU detection, so SIMD selection is an engine
capability probe (wasm-feature-detectsimd()): when the host
supports wasm SIMD thesimd128payload loads, otherwise the scalar one.
In Node.js the.wasmbytes are read from disk; in the browser/Deno
they are fetched relative to the module.
This matters for a browser ADB client in a specific way: the codec runs in
a Web Worker, off the main thread, so the SIMD gain translates directly
into sync throughput rather than into UI jank. The ya-webadb adapter is
already worker-aware — registerZstdCompression accepts a worker option
that defaults to "auto", meaning “use a worker if we are on the main
thread”:
1 | export function registerZstdCompression( |
The worker wrapper uses Comlink for the proxy and terminates the worker on
error so a failed compression attempt does not leave a dangling thread:
1 | function cleanupOnError<T>(worker: Worker | undefined, callback: () => T): T { |
Combined with a simd128 build, this is the difference between a zstd
adapter that keeps up with a USB 3 connection and one that does not.
The streaming API is what the sync protocol needs
The 0.1.0 API added incremental streaming, which is what a sync transfer
actually needs — you cannot buffer a 200 MB APK in memory to compress it
in one shot:
1 | const stream = await createCompressStream(19); |
The key property, from the upstream README: push(chunk) returns the
compressed bytes complete so far, and “the frame omitsFrame_Content_Size (unknown while streaming) yet decodes in any compliant
zstd decoder, including the native C library.” That omission matters for
interop — a normal zstd frame header declares its decompressed size so the
decoder can allocate the output buffer in one shot, but a streaming frame
cannot know that up front, so the decoder has to grow its buffer as it
goes. The Android side has to accept that, and it does, sincesendrecv_v2_zstd is advertised by the device’s own adbd.
Upstream housekeeping: PR #849 closed as inactive
The same day, ya-webadb’s maintainer closed
PR #849 — “use one-shot
format in getDeviceFeatures for multi-device support” — with the note
“Close as inactive”.
The PR is worth reading even though it was not merged, because it
documents a genuine protocol subtlety in the ADB server.
The bug: AdbServerClient.getDeviceFeatures() fails with"more than one device/emulator" when more than one Android device is
connected to the host running the ADB server. The reporter’s trace against
a real ADB server (version 37.0.0, protocol 41) is the useful part:
1 | # Two-step protocol (FAILS with multiple devices): |
The explanation is that a transport switch sets the device context for
device-level services (shell:, sync:, reverse:) but the ADB server
does not inherit that context for host-level services likehost:features.
But ya-webadb’s current implementation does not use the plain two-step
form. It goes through createDeviceConnection(device, "host:features"),
and the code carries a long comment explaining why:
1 | // On paper, `host:features` is a host service (device features are cached in host), |
So ya-webadb’s two-step flow is intentional and correct: because the
switch already selected a device, the unqualified host:features is
executed against that device. This works — the maintainer verified it
against two real devices on WSL2, Ubuntu 22.04, and Ubuntu 24.04, with
output matching adb -s <serial> features exactly. The reporter’s issue
was specific to an Ubuntu 22.04 environment that the maintainer could not
reproduce, and the thread went inactive in July when the reporter stopped
responding.
The maintainer’s stated preference frames the trade-off correctly: he would
accept the PR’s workaround as a fallback rather than a replacement, since
“the alternative might not return correct transport ID like the current
method.” The proposed one-shot formatDeviceService() path does not return
a transport ID, which getDeviceFeatures() needs to construct anAdbBanner and to key the transport. A fallback would therefore have to be
a second code path with different return semantics — a reasonable thing to
avoid in a library where the current path is correct in every reproducible
environment.
For webadb.online this is a non-issue in practice: the project talks toadbd directly over WebUSB, not through an ADB server, soAdbServerClient is not on our path at all. It matters to Node.js-based
tooling that connects to a local adb server with several devices
attached.
What this means for webadb.online
The @yume-chan/pcm-player changes are the interesting part, and they
change our roadmap rather than our code.
webadb.online currently ships thirteen panels — Terminal, File Manager,
Logcat, App Manager, Screencast, Clipboard, Settings, and others — and
none of them play audio from the device. Android’s scrcpy server supports
audio forwarding (since scrcpy 2.0), and ya-webadb has a scrcpy client
library, so an audio panel is a plausible next feature: connect a device,
run the scrcpy server with --audio-source=output, forward the PCM stream,
and play it.
Every one of the failure modes the 93fce383 commit addresses would have
hit that panel on day one:
The muting-with-no-error problem. A device that produces audio
perfectly well, a scrcpy server that forwards it correctly, and a panel
that shows a live meter with moving numbers but produces no sound. With
the old code, that is a genuinely hard bug to diagnose from the UI
because nothing throws. With#tryResuming, the context recovers from a
tab-switch suspend the next time a sample arrives.The multi-player cost. A panel with an audio stream and a
screencast running simultaneously is two audio contexts plus one video
pipeline. Sharing the context keeps the audio rendering thread count at
one.The restart problem. A device disconnects and reconnects, and the
panel wants to resume audio. With the old semantics the context was
closed and a new one had to be constructed from scratch — including
re-running the autoplay gesture check, which may no longer be satisfiable
because the user’s original click was many seconds ago. With the shared
pool, the reconnect reuses a context that has already been activated.
The zstd bump matters for a different panel: File Manager. OurpushBytes / pullBytes helpers go through the ADB sync service over
WebUSB, and WebUSB bulk transfers are noticeably slower than they look.
Compression is the lever. Right now webadb.online does not register a
compression adapter at all — we are on @yume-chan/[email protected] and do
not depend on @yume-chan/adb-compression-zstd — so every file transfer is
uncompressed. Pulling in the zstd adapter is now a much smaller job than it
was, because the codec’s streaming API is stable at 0.2.x and thesimd128 build means the compression happens fast enough to keep up with
the USB pipe.
Both of these are upstream-only changes this week — webadb.online’s own
code did not change — but they remove two specific blockers from our
backlog rather than adding generic capability. The next local commit that
touches package.json should probably be the one that moves us from3.0.0-beta.1 to 3.0.0-beta.4 and adds the zstd compression adapter.
The Chrome Direct Sockets check was skipped this week — that source is
only scanned on Mondays — so this post covers upstream ya-webadb activity
only.