本文へ移動
cccskills
無料GitHub で公開

web-plugins

Pulp in the browser — the WAM v2 and WebCLAP adapters, the wasm runtime, the Skia/WebGL2 browser window host, and the WebGPU (emdawnwebgpu) GPU-audio lane. Covers what does and does not compile to wasm, the worklet-thread constraints (no std::thread, no fetch, no navigator.gpu), the non-realtime tick, memory growth, how to PROVE a GPU lane actually ran rather than silently falling back to CPU, and the silent-failure traps that make a wasm build "work" while producing no audio or no pixels.

インストール方法を見る

含まれるファイル(1)

  • SKILL.md87.3 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

Pulp on the web (WAM v2 / WebCLAP / browser UI)

Pulp targets the browser through two audio ABIs and one UI host:

PieceWhereWhat it is
WAM v2 adaptercore/format/src/wasm/wam_adapter.cpp, wam-runtime.mjsThe whole module lives inside one AudioWorklet
WebCLAP adaptercore/format/src/wasm/ + examples/web-demos/wclap-build/Real CLAP, wasm-hosted; needs COOP/COEP for threaded shared memory
Browser window hostcore/view/platform/web/core/view's widget tree painted by Skia Ganesh on WebGL2

The audio side and the UI side are independent. The browser UI module is DSP-free and talks to audio only through the web player's HostAdapter seam, so the same wasm UI module mounts against both a WAM and a WebCLAP demo. Build it once; if it looks different across the two ABIs, that is a shared-player bug, not a per-demo tweak.

Governed CI builds

Every Web-Plugins workflow build must use tools/ci/governed-build.sh cmake --build ... so Emscripten and wasi-sdk jobs share the host governor. The WAMv2 and WebCLAP steps run from nested working directories, so those two calls must use bash "$GITHUB_WORKSPACE/tools/ci/governed-build.sh"; a relative tools/ci/ path resolves inside the demo tree and fails before compilation.

wasm DSP builds get pulp::simd as inline scalar code

Signal headers (fir_filter.hpp, oversampling*.hpp, zero_latency_convolver.hpp, and everything that includes them) include <pulp/simd/simd.hpp>. A build that lists include directories by hand must list core/simd/include next to core/signal/include, or it fails with a missing header. It needs no library: without pulp-simd's compile definitions the header supplies the scalar kernels inline (pulp::simd::active_backend_name == "inline-scalar"). PulpWam.cmake, PulpWclap.cmake, PulpWebUi.cmake, PulpGpuWasm.cmake and PulpLiveKernel.cmake list it. The wasm DSP is therefore scalar until the Highway wasm target lands with -msimd128; do not add core/simd/src sources to a wasm source list to "fix" speed without that plan.

What is NOT in the browser

Be precise about this; it is easy to overclaim and the claims get quoted.

  • The UI is not Graphite/Dawn. The published Skia wasm slice is Ganesh on WebGL2 and ships zero wgpu symbols (see the skia-gpu-build skill's wasm section). WebGL2 has no compute shaders, so the UI's render path cannot carry GPU DSP — the two lanes are unrelated (see below).
  • File-backed loaders and native editors are compiled out (PULP_WASM / PULP_HEADLESS). A plugin that needs an asset on the web must carry it in the binary or fetch it on the main thread — an AudioWorkletGlobalScope cannot fetch, which is also why the WAM worklet build must be SINGLE_FILE (the wasm embedded in the .js).

GPU audio in the browser: it exists, and it is a THIRD lane

CI capacity isolation. The real-GPU macOS proof is advisory and must not consume a required merge-gate runner. .github/workflows/web-plugins.yml therefore reads PULP_ADVISORY_GPU_MACOS_RUNS_ON_JSON, not PULP_LOCAL_MACOS_RUNS_ON_JSON, and passes it through tools/scripts/resolve_advisory_macos_runner.py. The proof skips when the advisory selector is unset; configured selectors must be self-hosted and cannot contain pulp-build* or pulp-preamble*, and must carry a pulp-advisory-* identity. Give the proof its own governed tartci supervisor and pulp-advisory-gpu label. Do not add that advisory label to a required runner, and do not use Orchard.

That isolation was being defeated by the workflow's trigger list, not by its runner selector: web-plugins.yml also ran on merge_group, so GPU audio proof (macOS, real WebGPU) claimed a macOS runner on every merge- queue entry while gating nothing — GitHub validates one merge group at a time, so that competed directly with the required macos gate for the same Mac pool. The workflow now runs on pull_request only. Capacity isolation is a claim about when a job runs as much as where: an advisory job must not appear on merge_group, because nothing there is advisory in effect — it either gates the merge or it just slows the queue down.

examples/web-demos/gpu-audio/ runs SuperConvolver's convolution as a WGSL compute shader on the browser's real WebGPU device. Do not repeat the old line that this is impossible or unstarted. But be equally precise about its shape, because three separate constraints force it and each one is a trap:

  • The DSP links emdawnwebgpu, not Skia. Dawn's Emscripten port implements webgpu.h over navigator.gpu and is completely independent of Skia — the UI's Ganesh/WebGL2 slice is irrelevant here and must not be dragged in. tools/cmake/PulpGpuWasm.cmake (--use-port=emdawnwebgpu) is deliberately Skia-free.
  • An AudioWorklet cannot touch navigator.gpu. At all. So the compute CANNOT live where the audio lives. It runs in a DedicatedWorker, and hands finished blocks to the worklet over SharedArrayBuffer rings — the same shape as native GpuAudioTransport: fixed latency primed as N blocks, lock-free ring, a MissPolicy the audio thread applies when a block is late. (WebCLAP already needs COOP/COEP, so the SAB is free; a WAM-only page would have to earn it.)
  • The native blocking readback is FATAL in a browser. MapAsync + a spin on ProcessEvents works natively and deadlocks on the web: the spin starves the very JS event loop that would resolve the map. The browser arm must be genuinely async — submit block N+1 while N's map is still in flight, bound the in-flight depth, and route a blown deadline to MissPolicy instead of hanging.

Never claim it is faster than the CPU. It is not, and the plugin does not default to it. The 2026-06-29 spike measured a competent real-FFT CPU convolver beating or tying the GPU at every musically plausible setting. This is a capability result — convolution reverb running as a compute shader in a browser tab — and the CPU remains the default and the always-available fallback.

Proving a GPU lane ran is the whole problem

A GPU path that silently falls back to the CPU is indistinguishable from one that works. "It sounds right", "the device and pipelines exist", and "no errors" all pass falsely when nothing ever dispatched. Two assertions carry real weight, and browser-test/validate-gpu.mjs is the worked example:

  • Kill the safety net. Run with the CPU fallback disabled: a GPU that never produced a block then yields silence, so the run cannot be quietly covered.
  • Tamper with the shader and watch the samples move. Read the actual WGSL text back out of the shipped module, rewrite it (scale the output store by 0.5), push it back before init, and require the audible samples to come out exactly 0.5× — bit-for-bit. A JS or wasm impostor doing the arithmetic elsewhere is completely unaffected by editing a shader it never runs. Reading the source out of the module rather than pasting a copy into the test also makes it drift-proof.

Never assert on timestamp-query. Chrome quantizes it: a 512-point FFT block measures 0 ns. A zero there is not evidence the GPU idled, and asserting either way manufactures false evidence. Report it; never gate on it.

Proving the ENGINE is not proving the PAGE. The engine fixture is allowed to cheat — validate-gpu.mjs measures the impulse response on the CPU engine in one run and hands it to the worker in the next. A visitor gets no such favour, so everything between the plugin's data and the worker's kernel is UNCOVERED by it, and each link fails silently: the page loads, the CPU path plays, the GPU engine simply never appears, and nothing anywhere errors. Drive the ASSEMBLED page in a real browser too (page-gpu.mjs), and assert the worker's counters ADVANCE — not that a control exists (it can be inert), not that there are no errors (a silent fallback is errorless by design), and not a cumulative produced > 0 (that latches on the first block ever made and then reads "GPU" forever, including while a lost device misses every deadline).

Getting the plugin's data OUT to the page (and the trap in it)

A plugin whose DSP can also run OUTSIDE the worklet — a GPU worker, which an AudioWorkletGlobalScope cannot even reach — has to tell that somewhere what to work with. For a convolver that means the IR, and it must be the IR the plugin ACTUALLY ENDED UP WITH, after its own normalize/window pass. Handing the page a raw IR to give to both sides is not equivalent: the plugin transforms what it is given, so the two engines would convolve with different kernels and the CPU fallback would quietly stop being a substitute for a missed GPU block.

The seam that works: plain optional wasm exports (pulp_ir_generation / pulp_ir_snapshot / pulp_ir_data) → the worklet polls → postMessage → the adapter latches and re-emits (onIrChanged) → the page forwards it. Not a new CLAP extension: an extension is permanent ABI invented for one demo, carried forever by every host that is not this page. An export is opt-in and invisible — the shared worklet checks whether the module has it and does nothing when it does not.

Two things that WILL bite:

  • Do not gate the poll on the non-realtime tick. It looks obviously right — the tick is what rebuilds the IR — and it silently never fires for the FIRST one. The plugin builds its initial IR while ACTIVATING, so by the time audio is running it has nothing pending, never asks the host for a callback, and a tick-gated poll waits forever for a rebuild that already happened. Poll the generation counter every quantum; it is one wasm call returning a uint32.
  • Latch it in the adapter. The first value is published while the plugin activates — before the page's handler exists. Without a latch (and a replay on subscribe) that first publish is lost, and the consumer waits for a change that already happened.

A Worker and a worklet share no heap — a program crosses as bytes

A Worker's wasm and a worklet's wasm are separate linear memories, so a pointer either one writes is meaningless in the other. That is why a compiled playback::PlaybackProgram — a graph of shared_ptr and std::vector — can never be published from a compiler Worker to a realtime worklet directly, and why pulp/playback/program_wire.hpp exists: it is the flat, pointer-free, versioned byte range that crossing form takes, with a validating decoder that allocates nothing and borrows typed spans straight out of the buffer.

Consequences for anything on the web lane that publishes program state:

  • Size the destination with program_wire_encoded_size and write into a caller-owned span. Both the encoder and the decoder allocate nothing, which is what lets a producer write straight into a SharedArrayBuffer ring and a worklet adopt without touching the allocator.
  • The buffer's base must be eight-byte aligned or the decoder rejects it — a ring's slot stride has to be a multiple of eight, not merely large enough.
  • Payloads crossing this boundary are untrusted input. Never hand a worklet bytes it has not run through decode_program_wire; the decoder is where truncation, a spliced length, an unknown section, and an out-of-range index all become typed rejections instead of an out-of-bounds read in the render callback.
  • A rejected generation must leave the previously adopted program playing. Adoption is decode then swap, never swap then validate.
  • Adopt on (producer_epoch, generation), never on generation alone. A differing epoch means a different producer: reset carried cursor state and adopt unconditionally, because generations from two producers are not comparable. This is the case a page hits whenever it rebuilds its Worker while the AudioContext survives — the new Worker republishes generation 1, and a consumer comparing generations alone refuses every publish from then on and renders a stale program with no diagnostic to distinguish it from silence.
  • Decide a lane Unchanged on its instance_token too, not on (lane_id, generation). The epoch separates producers; it does not separate two programs from one producer, and that is the common case — a worklet's Worker recompiling. generation is supplied by the caller rather than minted per compile, so everything else about the lane can be identical. Each ProgramWireAutomationLaneRecord carries the producer's own token for exactly this; compare it for equality only, and only against a token from the same producer_epoch. Keeping cursor state on a lane whose token moved renders the stale curve with nothing malformed for the decoder to reject.

Capability tiers are shared with mobile — do not mint a browser-local enum

When a browser lane needs to say "this page can only do the degraded thing", the tier vocabulary already exists and is not browser-specific: core/platform/include/pulp/platform/device_capability.hpp declares one DeviceCapabilityTier (Constrained / Standard / Full) that the browser lane's Tier A/B/C and the mobile lane's M-A/M-B/M-C both name. Use it. A second, browser-local TierA | TierB | TierC looks harmless in isolation and then permanently forks the ladder, because mobile and playback quotas will be reading the shared one.

The seam is DeviceCapabilityInputs::realtime_render_available. The header deliberately never names crossOriginIsolated, SharedArrayBuffer, or worklet module support: the browser probe collapses those three observations into that one boolean and hands over neutral inputs, because a type consumed by iOS and Android must not carry web-platform spellings. Keep the probe's vocabulary in the probe.

Two consequences worth knowing before you tune anything:

  • No realtime render path caps the page at Constrained, whatever its memory and core count — the rungs above it are defined by rendering locally. That is the controller-mode shape, and it is why a Tier-A page should refuse to construct the worklet path with a typed diagnostic rather than build it and catch a throw.
  • A lane with no thermal API tops out at Standard. Browsers expose no thermal signal, so a browser page cannot currently reach Full. This is a deliberate policy in project_device_capability_tier — the top rung's quotas assume the platform will report heat so the consumer can step down — not an oversight. Revisit it when the browser lane actually needs the top rung, and change it in the shared header rather than routing around it.

The tier is in platform; the quota table is not

pulp::format::device_quotas(tier, thermal) (core/format/include/pulp/format/device_quotas.hpp) is the matching table (voices, nodes, simultaneous editors, preview quality). It deliberately does not sit next to the tier enum, and reaching for pulp::platform::DeviceQuotas will not compile.

core/platform is named in every row of the engine's declared dependency floors (tools/scripts/timeline_engine_dependency_floor_check.py), so anything placed there is reachable by every module and no floor row can object to it. That is right for a shared vocabulary and wrong for a budget over render-graph nodes and editing surfaces. core/format appears in no floor row, so an engine module that reaches for the quota header is rejected by the gate.

Consumption runs one way: every enforcement point the table names already takes its ceilings as injected configuration (playback::AudioRendererLimits, timeline::SessionLimits, graph::GraphRuntimeLimits, format::PrepareResourceLimits), so the table's consumer is the shell that constructs those — which a browser lane already is. No engine module needs to see it.

It is a declaration today: nothing enforces it yet, so reading it is safe and relying on someone else having enforced it is not.

Sample-region web profile: native exceptions and RTTI

The canonical graph uses typed exception refusal and RTTI-based callback identity. Build it with -DPULP_SAMPLE_REGION_WEB=ON in a separate build directory. The WAM helper uses Emscripten 6.0.2 with standard native Wasm EH; WebCLAP uses WASI SDK 33.0, wasm32-wasip1-threads, -fwasm-exceptions -frtti, the standard EH dialect, and -lunwind. The entire C++ object closure uses matching flags. LTO is refused for this profile. Do not suppress catches or change refusal into an abort to make a no-exceptions build compile.

SDK33 is installed side by side for this headless profile. Keep SDK25 and the Skia determinism.web_toolchain tuple unchanged. Official release artifacts:

  • wasi-sdk-33.0-x86_64-linux.tar.gz: SHA-256 0ba8b5bfaeb2adf3f29bab5841d76cf5318ab8e1642ea195f88baba1abd47bce.
  • wasi-sdk-33.0-arm64-macos.tar.gz: SHA-256 85c997a2665ead91673b5bb88b7d0df3fc8900df3bfa244f720d478187bbdc78.

Both are under https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-33/. Verify the archive before extracting it, then pass its directory as -DWASI_SDK_PREFIX=... only to the isolated WebCLAP configure.

Standard Wasm EH requires Chrome 137, Firefox 131, or Safari 18.4 according to Emscripten's feature matrix. Node 22 probes need --experimental-wasm-exnref; apply that flag only to the new profile's module probes. Node 26.7 supports the profile without the flag. These minimums do not change the default no-region module requirements.

The worker pool keeps its existing inline executor for one participant and refuses additional workers on Wasm. Merely avoiding the spawn branch at runtime still leaves a wasi.thread-spawn import, so native-only thread code is excluded at compile time. No second executor or replacement allpass is used.

Build SampleRegionAllpassWorklet-wam and SampleRegionAllpass-wclap, then run node test/sample_region_web/validate.mjs --wam-dir <region-wam-build> --wclap-dir <region-wclap-build> --baseline-wam-dir <default-wam-build> --baseline-wclap-dir <default-wclap-build> --report <receipt.json>. This drives real Chrome AudioWorklet and WebCLAP hosts, checks the canonical coefficient catalog, audio oracle, state/reset/reload, and default PulpGain compatibility. The report includes browser version and artifact hashes. A standalone Node exception probe is early runtime evidence, not browser acceptance.

The worklet has no second thread

A WAM module runs entirely inside the audio worklet: there is no std::thread and no control thread. A processor whose control changes need work process() must never do (decode, resample, FFT-plan, allocate) therefore has nowhere to do it — which is what Processor::on_non_realtime_tick() / non_realtime_tick_pending() exist for.

pulp::audio::decode_wav(span, limits) is linked into both WAM and WebCLAP so code that already owns raw RIFF/WAVE bytes can share the native decoder. That is an off-render import API: never call it from process() or the worklet render turn. For browser file/drop imports, prefer the page's AudioContext.decodeAudioData when session-rate resampling is wanted, then transfer the resulting PCM through plugin state or another explicitly bounded handoff.

  • The WAM adapter marks the processor dirty on a control write and services it once per render turn, right after the render call (WamStage::service_non_realtime / mark_non_realtime_dirty). A knob drag delivers many control messages in one turn; they collapse into a single pass over the latest values. Do not rely on one tick per parameter write — rely on "eventually, with the latest values."
  • It is not an audio-thread callback and never runs inside process() — but in a worklet-only host it does run on the same OS thread, just outside the render call. A long tick still makes the next quantum late. Keep the work bounded and proportional to what actually changed.
  • CLAP reaches the same hook a different way (request_callback → on_main_thread), and native CLAP gets it too. See the clap skill.

"Keep the work bounded" is a hard requirement, not advice

Coalescing an expensive rebuild to once-per-render-turn lowers how often it fires; it does not make it safe. SuperConvolver's IR rebuild (synthesis + a peak-response FFT over the whole IR + a partitioned FFT re-plan for both channels) measured 15.0 ms in one render callback against a 2.667 ms quantum budget (128 frames @ 48 kHz) — a dropout on every Size change, even coalesced.

The fix is to time-slice the work, not to shrink it. The pattern (examples/super-convolver/sliced_ir_rebuild.hpp) generalizes:

  • Express each phase as a stream of fixed-cost items; the tick consumes at most budget of them and returns. Budget is a constant, never a function of the input size — that is the whole point.
  • Keep the OLD state rendering audio until the new one is complete, then publish it through the existing lock-free handoff (signal::ConvolverIrSwapper / runtime::Handoff). The audio thread's only job stays try_swap_ir().
  • A new request mid-job must supersede (restart) it, not queue behind it — a knob drag delivers a stream of values and only the last one is ever heard.
  • non_realtime_tick_pending() must stay true while a job is in flight, not just when one is needed. Both web hosts stop pumping the tick the moment it says false — WAM skips service_non_realtime, and WebCLAP stops calling request_callback — so a job that only advertises "work needed" strands itself half-built.
  • Pick the budget by measuring the slowest phase on the scalar path the browser actually runs (no vDSP/Accelerate — that is Apple-native only, and it makes a large FFT ~3× faster than what wasm will do). SuperConvolver uses 32768 items ⇒ worst render callback 0.78 ms across a full Size drag, vs 15.0 ms before.
  • The cost you pay is latency, not glitches: a 4 s IR needs ~120 render turns (~300 ms) to rebuild. Crossfade the swap (PartitionedConvolver::set_crossfade) so the change is heard as continuous rather than as a switch being thrown, and remember that any test which measures an IR right after a swap will otherwise measure the blend.
  • An FFT over the whole input is the one thing that will not chunk. Decompose it: a truncated Gentleman–Sande (DIF) split down to 1024-point leaves turns one indivisible N-point transform into a stream of butterflies plus a stream of leaf FFTs, exact to float precision. See superconvolver::PeakResponseScan.

Landmine: emscripten_resize_heap stubbed to false = a bare abort past 16 MB

The WAM runtime used to hard-stub emscripten_resize_heap to return false while every module links -sALLOW_MEMORY_GROWTH=1. The two together mean: the first allocation past the 16 MB initial heap fails, and the module aborts with no diagnostic — no OOM message, no exception, just a dead plugin. It reproduces only on inputs large enough to grow the heap, so a small demo passes and a real one dies.

wam-runtime.mjs now implements real geometric growth, capped at the wasm32 ceiling. If you write or vendor another JS runtime shim for a Pulp wasm module, emscripten_resize_heap must actually grow the memory — a false stub is only correct if the module is also linked with ALLOW_MEMORY_GROWTH=0, and none of them are.

Landmines that make a rendering build fail silently

Both live in the skia-gpu-build skill's wasm section; know they exist:

  • No SK_TRIVIAL_ABI → wasm-ld links a trapping stub for cross-boundary sk_sp calls and the first frame dies with a bare RuntimeError: unreachable.
  • Emscripten's SkFontMgr_New_Custom_Empty returns a non-null, glyph-less fontmgr reporting 1 family / 1 face, so null-checks and family-count guards both pass while every string measures at zero width. Probe font usability by drawing a glyph (unicharToGlyph('A') != 0), never by counting families.

The core/view source list exists TWICE, and the web copy is the one that breaks

The wasm build does not reuse core/view/cmake/PulpViewSources.cmake. It has its own hand-maintained set, _PULP_WEBUI_VIEW_SOURCES in tools/cmake/PulpWebUi.cmake, which names view.cpp and its dependencies explicitly — the comment there says so outright: "the wasm build needs the same set the native build compiles."

So adding a translation unit under core/view/src/ that view.cpp depends on means editing BOTH files. Register it only in PulpViewSources.cmake and the build is perfect on macOS and Linux, then fails on the web lane at LINK time with undefined symbols — far from the file you touched, and only after the required native gate has already gone green. view_lifecycle.cpp (the DispatchLease / View::retire mutation gate every View hook call site now takes) is listed in both for exactly this reason.

A sharper form of the same trap: a View:: MEMBER function whose definition lives outside view.cpp. The declaration sits in view.hpp, which the web target already compiles, so nothing looks missing — but the definition is in a TU the web source list may not name, and wasm-ld reports it only when something calls it. press_reach.cpp defines View::simulate_context_click and is listed in both files for this reason. The rule: if the symbol is View::something, grep the web list before assuming view.cpp carries it.

Same shape as the font list below: whenever a list is duplicated for the web target, the web copy is the one that silently rots.

The bundled-font list exists TWICE, and the web copy is the one that breaks

There is no platform font manager in a browser, so embedded blobs ARE the font stack. Two files declare which faces get embedded:

  • core/canvas/CMakeLists.txt for desktop
  • tools/cmake/PulpWebUi.cmake for web / WASM

core/canvas/src/bundled_fonts.cpp names every blob symbol directly and is compiled into BOTH. So adding a face to the desktop list and not the web one is not a missing glyph at runtime, it is a compile error:

error: no member named 'Jost_Regular_ttf' in namespace 'pulp_bundled_fonts'

in a lane nobody runs locally, discovered by CI on a PR about something else. The desktop list carries a comment telling you to keep it aligned with bundled_fonts.cpp; the web copy had no comment and no way to know it existed.

Guarded now by the bundled-font-lists-agree ctest (tools/scripts/check_bundled_font_lists.py), which compares the two lists and names the file to fix. If you add a font, add it in both places and the lint will tell you when you have not.

Browser-host rules

Local demo HTTP servers are loopback-only, canonical, and non-reflecting

Browser fixtures need real HTTP headers, but they are still test harnesses rather than general-purpose file servers. Use examples/web-demos/tools/local-http-security.mjs for local demo servers:

  • bind explicitly to 127.0.0.1; an omitted host exposes Node's listener on all interfaces and turns a private fixture into a LAN service;
  • validate and decode the raw origin-form request path before URL normalization, reject malformed encodings, separators, dot segments, and non-allowlisted path characters, then resolve the real path and prove it remains under the canonical root (including after following symlinks);
  • return fixed plain-text error bodies. Never append req.url, a decoded path, or another request-controlled value to a response body;
  • keep route aliases as explicit canonical roots. Do not fall back to a lexical startsWith(root) check: sibling-prefix paths and symlinks bypass it.

The helper's Node tests include traversal, encoded separators, malformed URLs, symlink escapes, reflected markup, and the loopback bind contract. Add a route- specific test when a fixture needs semantics beyond those shared invariants.

  • Probe for WebGL2; a browser without it is a shipping configuration. pulp::view::web::browser_host_gpu_available() is the web analogue of decide_gpu_host. The mount path must fail loudly and asynchronously so the host page can fall back (the demo pages restore the player's generated parameter grid). A module that fails asynchronously and reports nothing leaves an empty panel, which reads as a render bug.
  • WebGL context loss is a normal event. Lost → the surface reports unavailable and the rAF loop keeps pumping; restored → Ganesh is rebuilt and repaints. Ganesh cacheable LayerHandles are retired on loss because their textures belong to the dead context; retained-layer callers must recheck layer_valid() and record a replacement. Any GPU resource cached above the surface must either survive that cycle independently or follow the same explicit invalidation/rebuild rule.
  • The render loop is requestAnimationFrame-driven (core/render/src/render_loop_emscripten.cpp); DOM pointer/key events are translated in core/view/include/pulp/view/web/web_event_translate.hpp.
  • WebInputRouter must not re-derive overlay dismissal. The browser host is a platform host like the macOS and Windows ones, and the policy for a <View overlay> popover — route a press inside it into its own subtree, honour an overlay that consumes the dismissing press, dismiss on Escape — lives once in pulp::view::route_press_to_active_overlay / route_escape_to_active_overlay. This host previously hand-rolled a reduced copy against the process-global View::active_overlay_ shim mirror and lost all three behaviours; tools/scripts/overlay_dismissal_wiring_guard.py now rejects any host that reaches past the shared verbs to the slot. Read the root-owned slot (View::interaction().active_overlay), never the mirror: the mirror names the most recent claim anywhere in the process.

Web-player metadata is text, and links are web-only

@danielraffel/web-player is a library boundary: demo titles, subtitles, parameter choice labels, source links, gallery links, and host labels can come from downstream package manifests. Build those fields with createElement, textContent, and setAttribute; innerHTML is reserved for package-owned static markup. Property assignment alone is not enough for links because a javascript: or data: URL remains executable when clicked, so resolve the URL and accept only http: / https: (relative links resolve through the page's base URL). Invalid optional links are omitted; an invalid gallery link falls back to the package gallery default.

The dependency-free DOM shim in packages/pulp-web-player/test/dom-shim.mjs also receives library strings. Keep its HTML/selector scanners linear and cover long repeated whitespace/bracket inputs in test/dom-security.test.mjs; a test helper must not turn an adversarial label into a regular-expression denial of service.

Landmine: CLAP has no parameter unit — only value_to_text

The WAM ABI reports a parameter's display unit directly (wam_adapter.cpp emits {"unit":"%"}). CLAP deliberately does not: clap_param_info has no unit field, and a host is expected to display whatever clap_plugin_params.value_to_text() renders ("35.00 %"). A WebCLAP host that marshals only the info struct therefore produces unitless parameters, and the shared player — same page code, same plugin — renders "1.50" on the WebCLAP demo and "1.50 s" on the WAM one. That was a real, shipped divergence.

Both WebCLAP hosts (packages/…/vendor/pulp-wasm/wclap-processor.js worklet and core/format/src/wasm/wclap-host.mjs offline) therefore call value_to_text at two probe values per parameter and report the raw strings as textProbes; deriveDisplayUnit() in wclap-abi.mjs recovers the suffix (and returns "" rather than inventing one when a plugin uses a custom to_string, e.g. enum labels). If you add a field the Pulp web UI needs and CLAP has no struct slot for it, this is the pattern — go through the plugin's own display call, don't hardcode a default in the adapter.

Handing a plugin BINARY data from the page (samples, IRs, wavetables)

A browser has no filesystem, so a plugin whose native build loads a file (vw::FileChooser → set_ir_path) needs a web equivalent. Do not add a per-ABI entry point for it. Go through the plugin's own state:

  • Pulp's bounded in-memory WAV decoder is portable core code, not a native file-loader. tools/cmake/PulpPortableWav.cmake owns its shared sources and include paths; both PulpWam.cmake and PulpWclap.cmake consume those lists. Keep that shared manifest aligned with core/audio/CMakeLists.txt so native and browser builds cannot silently diverge.

  • The timebase, timeline, and playback engines are part of both production DSP builds. PulpWam.cmake and PulpWclap.cmake own their curated source lists, compile them with PULP_COMPILE_EXECUTOR_DISABLE_THREADS=1, and must stay in lockstep with the native module lists. web-timeline-source-closure enforces that source closure, and this workflow must trigger on changes to any engine module or the bounded WAV decoder. Even an unattached authoring-only value extraction that adds one engine .cpp must update both curated lists in the same commit; passing the native dependency-floor check does not prove the web closure. The playback automation program and cursor are portable engine sources. TrackAutomationProgram is part of the same closure: when adding or splitting a playback aggregate source, mirror it into both curated ABI lists and keep web-timeline-source-closure green. Timeline schema-migration sources are in the same closure even though they are document-persistence code rather than playback: a Track schema migration TU counts as an engine module, so it lands in core/timeline/CMakeLists.txt and both curated ABI lists together. Keep these modules threadless and independent of state, host, and format code; compiling them into wasm does not by itself create a JavaScript-facing timeline API or Host delivery path. Track-owned automation lanes, their commands, schema migrations, and identity helpers follow this same portable-document rule: every new internal .cpp belongs in the native timeline target, the no-exceptions target, and both web ABI lists. This proves that snapshots containing automation can compile into the browser runtimes; it does not prove scheduling or parameter delivery until the playback/host binding consumes those lanes. Take-lane editing follows the same rule: a dedicated reducer such as transaction_take_internal.cpp is not pulled in transitively just because transaction.cpp is already listed, so it must be mirrored into both web ABI source lists with the native and no-exceptions targets. Track-freeze document support has the same closure: decoder helpers and the SetTrackFreeze reducer are portable timeline units, not native render jobs, so list them in the native timeline target, no-exceptions target, WAM, and WebCLAP together. Browser replay consumes the persisted artifact reference; it must never attempt to rerender a freeze. Track mixer state (gain/pan plus its automation targets) is the counter-case worth knowing: it added no translation unit at all. The document field and the new variant alternative land in existing timeline units, and the render-side track_mixer_program.hpp is deliberately header-only, so the WAM and WebCLAP lists are untouched and web-timeline-source-closure stays green without an edit. Check whether a change adds a .cpp rather than assuming a feature-sized change must touch the web lists — and keep new small render-path helpers header-only when they have no state to define, so the closure surface does not grow for free. Musical TimeConform::Resample follows the same existing portable playback units: its stateless source-phase mapping and analytic tempo-ramp inverse add no translation unit, allocation, thread, or web-specific adapter. TimeConform::Stretch adds the finite artifact compiler and the prepared realtime stream behind the audio-domain boundary; both audio translation units belong in the WAM and WebCLAP portable dependency inventories. This keeps persisted projects and playback compilation portable, but does not by itself create a JavaScript authoring surface or a browser timeline host. These builds also share Timeline's persistent indexes: initial Track/Project construction and identity restoration bulk-build sorted balanced trees, while ordinary edits path-copy only the changed search paths. Do not replace bulk construction with repeated persistent insertion; wasm's tighter memory ceiling makes the transient allocation growth especially costly.

  • A new core/timeline translation unit belongs in exactly one place. core/timeline/CMakeLists.txt, PulpWam.cmake, PulpWclap.cmake, and the pulp-test-timeline-no-exceptions OBJECT library in test/cmake/timeline_tests.cmake all resolve their sources through pulp_resolve_timeline_sources() in core/timeline/PulpTimelineSources.cmake, so one edit to that function covers the native target, both web lanes, and the no-exceptions proof together. Hand-editing any of the four consumer lists for a timeline unit is not just unnecessary, it is wrong.

  • The shared source manifest does not propagate target usage requirements. If an existing timeline source starts including a header from another module, every raw-source consumer must receive that module's include surface too. For the header-only pulp::music module, add core/music/include to both _PULP_WAM_INCLUDES and _PULP_WCLAP_INCLUDES, add it to the standalone fixture-runner WASM root, and link pulp::music into the no-exceptions OBJECT target. Linking it only to native pulp::timeline leaves native builds green while WAM, WebCLAP, the WASM fixture corpus, and the no-exceptions proof fail on <pulp/music/...> includes.

  • This skill owns the engines' web-ABI source closure, not the engines. skill_path_map.json maps web-plugins to core/timeline/PulpTimelineSources.cmake, core/playback/PulpPlaybackSources.cmake, PulpWam.cmake (which carries the literal core/timebase/src list) and PulpWclap.cmake — the surfaces that decide what the browser lanes compile. It does not claim core/timebase/**, core/timeline/**, or core/playback/** wholesale. Adding or splitting an engine TU still lands on this skill, because it edits one of those lists; editing an existing engine TU does not, because nothing here goes stale. Whole-subsystem claims made the gate fire on every engine edit, which trains reflexive Skill-Update: skip trailers — and that reflex is how a genuinely missed skill update gets waved through. web-timeline- source-closure (a ctest, not this gate) is what proves the closure itself. tools/scripts/test_skill_sync.py::RealSkillPathMapOwnershipTests asserts the boundary from both sides, so widening it back fails a test.

  • Sequence-level document state (markers, regions) is portable engine data, so its migration and reducer units — sequence_schema_migrations.cpp, transaction_marker_internal.cpp — belong in the shared timeline source function and the no-exceptions library like any other timeline TU, even though they never run in a render path.

  • Only translation units under core/timeline/src join those lists. A public header added under core/timeline/include for a foreign-format interop target — dawproject_import.hpp, smf.hpp — stays out of every web source list, because its implementation lives in its own target (core/dawproject, core/smf) that the browser lanes deliberately do not build. The closure checker scans sources, so adding such a header is correct even though it touches core/timeline.

  • The editor rung is not in the browser lanes at all. core/timeline_editor (SequencerUiHost, EditIntent, lower_edit_intent) is its own target with its own source list; the WAM/WebCLAP lanes compile core/timeline/src directly and never link it, so a browser plugin has commands and transactions but no gesture verbs. If a browser editor needs to lower intents, link pulp::timeline-editor — do not add edit_intent.cpp back to PulpTimelineSources.cmake. That would put the editor's vocabulary into the document model's source list, which is the exact placement timeline-engine-dependency-floor exists to prevent, and the closure checker would not object because it only asks whether every core/timeline/src TU is listed, never whether a listed TU belongs there.

  • Timeline is the exception in list shape: engine units from other modules are still hand-listed per lane, so check which shape the module uses before assuming the resolver covers it. web-timeline-source-closure only asks whether every TU under core/timeline/src reaches the WAM and WebCLAP lanes — which the resolver satisfies automatically — so it can neither catch a hand-listed module's omission nor object to a TU that is listed but does not belong in the document model's list at all.

  • The browser lanes inherit transport behavior for free, including behavior that did not exist when the ABI lists were written. Playhead scrubbing is the worked example: MasterTransport::begin_scrub() emits repeated windows whose restarts are ordinary range discontinuities, so the WAM/WebCLAP builds got it purely because transport.cpp was already in both lists — no source-list edit, no worklet change, no JS surface. Prefer that shape when adding an engine feature in the browser: express it as ranges the existing portable units already publish. It also means a browser-visible behavior change can land with an unchanged web source closure, so a green web-timeline-source-closure is not evidence that the wasm lanes are unaffected — check what the transport now publishes per block, not just which files moved.

  • A new portable engine TU is one edit, not three. PulpWam.cmake and PulpWclap.cmake both resolve their timeline/playback sources through pulp_resolve_timeline_sources() / pulp_resolve_playback_sources(), so adding a file to core/timeline/PulpTimelineSources.cmake (or the playback equivalent) puts it in both wasm lanes automatically. Do not hand-add it to the WAM/WebCLAP lists — web-timeline-source-closure counts production TUs per lane and a duplicate is drift, not belt-and-braces.

  • A new bounded document array needs a web ceiling of its own. Every unbounded array the decoder walks is quota-governed by a DecodeLimits field, and DecodeLimits::web_defaults() deliberately tightens the ones that can grow large (notes, automation points, takes, comp segments) because a browser tab has far less headroom than a desktop host. Adding a field and forgetting web_defaults() is silent: the desktop ceiling applies, nothing fails, and the tab is one hostile document away from an OOM the native tests will never see. The decoder is not the only place to wire it either — schema_json_preflight.cpp governs the same array independently, before any model object exists, and that is the check a hostile input hits first.

  • Timeline schema references are also a generated web API contract. Nested objects and arrays must carry $ref metadata through the canonical schema; arrays need the reference under items.$ref, not only on the container. The TypeScript facade projection consumes those references to emit concrete nested types. If a timeline field such as Sequence::groove() or GrooveTemplate::steps is added without the reference, native persistence can remain green while the generated browser API silently degrades the value to unknown. Regenerate the JSON Schema and TypeScript facade together and keep the schema/codegen drift gates in the same change.

  • Per-note probability, pass conditions, and ratchets are portable playback behavior too. WAM and WebCLAP compile the same bounded note program and use the authored modifier seed with note identity and loop-pass index, so decisions are replayable without mutable RNG state in the worklet. Scrub windows deliberately stay on pass zero; seeks, play starts, and loop-boundary changes re-anchor the pass epoch, while ordinary program adoption and host recording-state changes do not. Keep modifier expansion behind ProgramCompileRequest::maximum_note_events_per_track: wasm's memory ceiling makes an unbounded ratchet fan-out especially dangerous. This engine support does not by itself add a JavaScript authoring surface.

  • Sequence groove rendering and compile-context invalidation are equally portable. Built-in MIDI subscribes to CompileContextKind::Groove; its owner sequence timing displacement and velocity accent are compiled before the bounded ratchet expansion. WAM and WebCLAP therefore inherit the same note program without browser-only math. Keep CommitResult predecessor provenance, registry snapshots, and MIDI compile-structure tokens in the shared timeline / playback lane so sparse reuse cannot publish stale browser programs. This also does not create a JavaScript authoring surface by itself.

  • Registered-content compiler code is portable, but its declaration is process-local. Adding a renderer to the shared playback source resolver makes the implementation available to WAM and WebCLAP; it does not install the schema, codec, or ContentRendererRegistration into a browser plugin's registries. A plugin that authors or loads that content must declare the same exact schema provenance and bounded renderer before compiling in each wasm instance. Run schema/JSON validation and the registered compile hook in the non-RT producer/Worker, then publish only validated ProgramWire bytes to the AudioWorklet; none of that content compilation belongs on the render thread. Keep unresolved content as a named compile failure rather than browser-only silence, and do not carry a nondefault renderer production claim through ProgramWire: the remote instance cannot inherit a reproducibility claim without the process-local hook that justified it.

  • A compile-time guard in a portable timeline header fires in the browser lanes too. core/timeline's AutomationTarget carries a static_assert on its alternative count (and an overload set with no generic fallback) precisely so that widening the variant cannot slip through silently. Because those sources are in the WAM/WebCLAP closure, widening it breaks the wasm builds at the same point as the native ones — which is what you want. It also means the guard must be satisfied before the widening lands, not after, or both lanes go red together.

  • Extraction produces a new translation unit and carries the same obligation. Moving a helper out of an already-listed engine .cpp into its own file reads as a pure refactor, because the origin unit stays listed everywhere — but the extracted file is new to every list its module maintains.

  • core/timeline and core/playback are in the wasm closure, so their compile-time model guards fire in the browser lanes too. ClipContent carries an overload set with no generic fallback plus static_asserts on its alternative count, precisely so a new clip content kind cannot slip through as silence. Widening it therefore reds the WAM and WebCLAP builds at the same points as the native ones, which is the intent — but it also means the guards must be satisfied before the widening lands, not in a follow-up, or every lane goes red at once with no partial-progress path.

  • Both ABIs already expose the plugin's opaque state behind ONE HostAdapter call — WAM through wam_state_size/wam_read_state/wam_write_state, WebCLAP through the clap.state extension — and both produce the same PLST blob the native VST3/AU/CLAP builds write.

  • So: read the live state, swap its plugin-owned blob for a record carrying the payload, write it back (packages/pulp-web-player/src/state/plugin-state.js — parseContainer / buildContainer, exported from the package). The plugin's deserialize_plugin_state() is the receiver. Preserving the params half of the container is what stops a load from resetting the user's knobs.

  • Three things fall out for free, which is why this is the seam: it is identical on WAM and WebCLAP (nothing to keep in sync), the payload survives a state save/restore because it is the state, and "revert to the built-in" is the same call with a different tag.

  • Decode with the demo's own AudioContext (decodeAudioData resamples to the context rate), so the PCM arrives at the session rate and the plugin's resampler — which is not chunkable, unlike everything else in the rebuild — is skipped.

  • Worked example: examples/web-demos/super-convolver-ui/ir-source.js (the SCv2 record + the drop-zone) and the onReady seam in the shared shell, which is the hook for plugin-specific page chrome that needs the live adapter and the AudioContext. customUi is the wrong hook for this: it replaces the parameter grid and falls back to it on failure.

Landmine: iOS Safari ignores .click() on a display:none file input

The page's IR/sample picker is a <input type=file> the plugin's Source affordance triggers (editor → load_ir_path({}) → Module.onRequestIr → filePicker.click()). Hiding that input with hidden / display:none works on desktop Safari and Chrome but iOS Safari silently drops the .click() — the tap does nothing and it reads as a broken control. Keep the input in the render tree and hide it visually instead (position:fixed; width:1px; height:1px; opacity:0; pointer-events:none). The click must also still be synchronous inside the user gesture — the whole canvas-pointer → wasm → EM_JS → click() path is synchronous, so that holds; do not defer it behind a promise/setTimeout.

Landmine: a control-surface canvas eats page scroll — opt into pan-y, and it's TWO paths

web_input.cpp sets canvas.style.touchAction = 'none' and preventDefaults BOTH pointerdown AND wheel so a knob drag / wheel-over-knob never pans the page — correct for a standalone plugin. But an editor embedded in a scrollable page (only horizontal sliders, no vertical drags) then swallows every gesture and the page cannot be scrolled from on top of the plugin. Opt in by setting canvas.style.touchAction = 'pan-y' after mount (see pulp-ui.js); the input layer checks the live touch-action and, on a pannable canvas:

  • touch (mobile): skips pointer capture + preventDefault on pointerdown, so a VERTICAL drag scrolls the page while a HORIZONTAL drag or tap still drives a slider (pan-y claims only the vertical axis).
  • wheel (DESKTOP trackpad/mouse — a SEPARATE path, easy to forget): still forwards the wheel to the view but does NOT preventDefault, so the page scrolls. The touch fix alone leaves desktop broken — the "big screen, can't scroll over the plugin" bug is the wheel handler, not touch-action.

Do NOT flip the default to pan-y — a knob-heavy plugin needs both axes for its own drags. VERIFY IN WEBKIT, not just Chromium: playwright-core's webkit IS Safari's engine (Version/…Safari/605), and page.mouse.wheel(0, 600) over the canvas must move window.scrollY (measured 0→209 on the SuperConvolver editor). Chromium-only "it scrolls" is not proof for the browser the user is actually on.

Landmine: scripted controls need the browser host's on_drag channel

A scripted gesture uses two distinct View callbacks:

  • on_pointer_event carries the press/release/cancel edges. A drag tick must not become another pointerdown merely because its is_down field is true.
  • on_drag carries the JS pointermove stream. The legacy on_mouse_drag virtual reaches stock C++ widgets, but it cannot reach a canvas-drawn control whose handler was installed by WidgetBridge.

Every browser drag tick must therefore deliver, to the target captured for that raw browser pointer ID, the modern event and legacy virtual, then on_drag for mouse or identity-bearing on_pointer_move for touch/pen. Do not redispatch that scripted move on native ancestors: the bridge event already bubbles through the JS element tree, so doing both double-fires ancestor and document listeners. Revalidate the captured target between callbacks because a handler may rebuild and destroy its own subtree synchronously. Missing the scripted channel produces a deceptive failure: the control renders and receives press/release, but it never moves. The router tests pin exact-target delivery, capture teardown, and the rule that release/uncaptured hover emit no extra move.

Landmine: a WebCLAP host must READ parameters back, not mirror them

A host that remembers what it last sent (values.set(id, v)) goes stale the moment the plugin rewrites its own parameters — which it does on every state load and preset change. WebClapPlugin.paramValue() calls clap_plugin_params.get_value(); use it. The WAM lane has the same trap and solves it with wam_param_epoch + wam_read_param_values.

Testing — the assertions a native test cannot reach

The WAMv2 + WebCLAP (Linux, headless Chrome) lane (.github/workflows/web-plugins.yml) is the gate. Two conventions:

  • Drive both ABIs from ONE runner. superconvolver_runner.mjs asserts the same audio / parameter / latency / state behavior against the WAM module and the WebCLAP module, so a divergence between the two fails in CI rather than in a demo page. Do this for any plugin that ships in both.
  • Real pixels + a real gesture. The browser fixture asserts a WebGL2 context, GPU and raster content floors, text ink, a synthesized drag producing a bracketed gestureBegin → setParameterValue → gestureEnd, a host→UI repaint, and the context loss/restore cycle. Nothing here is reachable from a native unit test; don't substitute one.

The lane pins emsdk (never latest) and fetches the Skia wasm slice from tools/deps/manifest.json — see the ci skill's web-plugins.yml section before touching either.

A native/WASM semantic oracle must share the production object closure

When an existing native executable already owns the fixture and semantic hash, compile that same entry point against $<TARGET_OBJECTS:pulp-wam-dsp> and run the emitted Emscripten JavaScript under Node. Do not translate the fixture into JavaScript or create a second list of timeline/playback sources: either can stay green while the production WAM closure drifts. The registered chord-renderer proof in examples/web-demos/wasm-build/CMakeLists.txt is the reference shape.

Its negative control belongs in the production renderer, not in the consumer or the expected hash: perturb one WASM-emitted value, force the pulp-wam-dsp object to rebuild, and require the native executable to stay green while the Node oracle goes red. Restore the renderer byte-for-byte, force both rebuilds, then rerun both green. A control that edits the expected value only proves the comparison can disagree; it does not prove the WASM executable reached the production realization path.

web-plugins.yml also carries a second, unrelated job: Timeline fixture corpus (WASM), which builds pulp-fixture-runner under emscripten and runs the timeline conformance corpus through it. It shares the file only for the emsdk pin — it needs no Skia, no Chrome, no wasi-sdk, no npm, so keep it a separate job rather than a step in the lane above, or a one-minute check starts waiting on a fifteen-minute one. It is not a browser lane; if you are here for WAM/WebCLAP behavior, it is not the job you want.

Landmine: pulp_add_wclap(Foo) declares the target as Foo-wclap

Not Foo. cmake --build … --target Foo is a hard "No rule to make target", not a fallback — so a workflow step with the bare name fails before it reaches whatever it was supposed to prove. Shipped that way once in web-plugins.yml.

Landmine: a plugin is not time-invariant while its IR is still rebuilding

Any fixture that measures an impulse response and then convolves with it (the standard way to check a convolver against an oracle) is assuming the plugin is linear and time-invariant across the capture. SuperConvolver is only that once its IR has stopped moving, and it does not start that way: a Size change is time-sliced across many process() calls and crossfaded (superconvolver::SlicedIrRebuild), exactly so the render callback stays in budget instead of spiking (see "Keep the work bounded").

Set a parameter and capture immediately and you probe h mid-crossfade — a blend of two IRs, a kernel that describes no instant of the run. Everything downstream is then judged against it, and the failure frames the ENGINE as broken when the fixture is what is wrong (measured: CPU vs oracle relative RMS 3.4 — not a drift, a different signal).

Two things fix it, and you want both:

  1. Pre-roll of silence before the marker impulse. The graph is live and the plugin drains its sliced rebuild while nothing is being measured.
  2. A second impulse after the analysis window, whose response must equal the first's. A pre-roll alone is just an assumption that convergence fits inside it, and it rots the moment the slice budget or the IR length changes. The second impulse turns it into a checked claim about the exact property the oracle depends on, and it fails loudly with a named reason before the bad kernel can poison anything.

Demo pages

Publishing or updating a browser demo (shared player, cache-busting, OG images, COOP/COEP) is the screenshot-sync skill plus the personal pulp-web-demo standard. The one rule worth repeating here because it silently kills audio: never cache-bust the worklet processor URL — the registered processor name is derived from it, so a ?v= forks the name and the node never constructs.

Landmine: the OG bake is a TWO-PASS assemble — the second pass needs the same args

gen-og-images.mjs shoots each page, then assemble-gallery.mjs runs again to bake the og:image / twitter block into the HTML — a page's tags are emitted only when its og.png already exists on disk, which is only true on that second pass.

Run the second pass bare (node assemble-gallery.mjs with no --wam-build / --ui-build / --gpu-build) and the assembler cannot find the build trees, so it skips every page that needs one. It skips them quietly — a one-line note — and therefore never rewrites their HTML, which is the only thing that pass exists to do. /super-convolver-gpu/'s og.png was shot perfectly and then never referenced: the page shipped with no og:image at all and unfurled bare. Pass the same trees to both passes.

The page owns the engine readout, not the view tree

A status line inside the plugin canvas that the page refreshes on a timer is a layout event on a timer: every time a counter gains a digit the label re-measures, and at phone width it sheared straight through the knob labels above it. It was also duplicate chrome — the page already renders an Engine <select> directly under the canvas, which both names the engine and is the control that changes it.

Put live metrics in DOM slots beside that control, each with a fixed width and font-variant-numeric: tabular-nums, so a changing number never moves the layout. The view tree carries the controls; the page carries the readout.

Landmine: an offered engine is not a running engine — gate the WORK, not the output

A GPU (or any offload) lane fed by a SharedArrayBuffer ring keeps receiving blocks in both engines: the plugin pushes on every block regardless, because that is what advances the shared block timeline and keeps the two paths sample-aligned. A worker that convolves whatever it is handed therefore keeps the GPU fully busy while the user is on CPU — measured on the shipped SuperConvolver page at ~100 queue submits per second, a whole wet stream produced and discarded. The user sees it: "in CPU mode I still see spikes on my GPU meter." Selecting CPU must make the GPU idle, not merely ignored.

Gate it with a ring FLAG the worker reads once per tick (not a message — the worker's job is to never stall). While it is clear: drain the input ring into plain memory, submit nothing.

But idling is only half of it. A partitioned convolver's tail comes from a frequency-domain delay line of recent input spectra, and updating that line IS the GPU work — the forward FFT is a dispatch. So an idle GPU is one whose memory of the recent past goes stale, and resuming with a stale line smears a ghost of pre-flip audio under the new material. Buffer the raw input blocks while idle (a memcpy) and replay them on the flip, oldest first with outputs discarded, to rebuild exactly the line the GPU would have had. One priming burst, then live. Keep the history exactly as long as the convolver's memory (ceil(irFrames / block) + 1); replaying more just overwrites the same partitions.

Assert BOTH halves in the browser fixture, from the worker's OWN counters in the SAB (never a label the page prints): submits do not advance while on CPU, and the priming counter is non-zero after the flip. Mutation-check the first one — an always-on worker must make it fail.

Landmine: the adapter's values array is POSITIONAL, not keyed by parameter id

onParamsChanged(values, infos) hands you values built as infos.map(p => value(p.id)) — so values[i] belongs to infos[i]. Reading values[param.id] "works" only when ids happen to equal indices; on SuperConvolver, Engine's id is 5, so values[e.id] silently read the SIXTH parameter. Find the INDEX (infos.findIndex(...)) and read values[index]. This bug hid for a long time because the page's <select> handler also set the same state directly — the broken path only ran for preset/host writes.

Landmine: the wasm UI build hand-lists its sources — a core refactor silently breaks it

tools/cmake/PulpWebUi.cmake builds Pulp's own view+canvas render stack to wasm by explicitly listing every TU (it can't glob core/ the way the native build's CMakeLists do — it deliberately excludes Dawn/Graphite/scripting/design-import TUs). So when a core refactor splits a file — skia_canvas.cpp → skia_canvas.cpp + skia_canvas_path.cpp, or the text editor into text_edit_model / text_editor / _clipboard / _ime / _paint — the native build (which lists or globs them) keeps working, and this hand-list silently goes stale. The failure is a wall of undefined symbol: at wasm-ld link time, and it lands far from the PR that caused it.

The web build ("Build + prove + (owner-gated) deploy") is now a required status check. That is the durable fix the older advice asked for — but it has a sharp consequence: when a core change adds a TU this hand-list misses, the stale list no longer breaks silently on main, it reds the required gate for every open PR, including ones that never touched view/canvas. A new file is the same failure mode as a split file: value_source_binding.cpp (defining FrameClockBinding::~FrameClockBinding / refresh) was added to core/view/CMakeLists.txt but not to PulpWebUi.cmake, and view.cpp — already in the wasm list — referenced it, so every web UI module link-failed and blocked the queue. Whoever adds a core/view/** or core/canvas/** .cpp must mirror it into PulpWebUi.cmake in the same change, or the whole PR queue wedges.

This includes small internal policy/helper TUs, not only visible widget or renderer splits. yoga_layout.cpp was refactored to call yoga_measurement_internal.cpp; native view-core and all native tests stayed green, but the required WebCLAP build failed at wasm-ld with undefined sanitize_yoga_measurement / resolve_yoga_measure_dimension until the helper was added to _PULP_WEBUI_VIEW_SOURCES. Treat every new out-of-line dependency of an already-listed wasm UI source as a paired PulpWebUi.cmake edit, even when the helper has no web-specific code.

When you hit it: don't chase symbols one build at a time. Read the symbol's namespace, find the defining TU (git grep -l 'Thing::method' -- core/.../src), and mirror what the native build compiles (core/view/CMakeLists.txt, core/canvas/…) rather than adding files piecemeal — one added TU pulls in its own new references (a Label opening a TextEditor cascades into the editor's model + clipboard + context-menu TUs, and finally into pulp::platform::Clipboard, which had no web impl at all). Build locally against origin/main before trusting it — the cascade is real, and your local linker matches CI (verify the first layer agrees before chasing the next).

Landmine: an animating web editor recurses paint→repaint unless the render loop is armed

A view that calls request_repaint() from inside paint() — any continuously animating editor (SuperConvolver's living field) — routes through WindowHost::mark_dirty() → schedule_repaint(). That method only requests an async rAF frame when PULP_VIEW_HAS_RENDER_LOOP is defined; otherwise it falls back to a SYNCHRONOUS repaint(), which re-enters paint() from within paint() and recurses until the JS stack overflows. Two traps compound it:

  1. The macro is native-only by default. PULP_VIEW_HAS_RENDER_LOOP=1 is set on the native pulp-view-core target. The wasm UI build (PulpWebUi.cmake) hand-lists its sources and does NOT inherit it — even though it DOES compile and arm the rAF RenderLoop. Define it in PulpWebUi.cmake or every animating editor deadlocks on first paint.
  2. run_event_loop() marks dirty before the loop exists. It calls show() → mark_dirty() BEFORE creating the render loop, so the first paint is synchronous even with the macro. The browser WindowHost::render_frame() carries a re-entrancy guard (rendering_ flag): a repaint requested during paint is deferred to the next frame, never nested.

The crash is a red herring generator: the stack overflows in whatever draw is executing when it tips (a gradient FP, a texture upload), so the symbolized top frames point AWAY from the cause. Count the repeating frame — it was 662× View::paint_all — to find the real cycle. The static generated grid never animates, so this stays hidden until a real animating editor mounts. A new custom editor must be exercised by the browser fixture (it mounts and must not overflow).

The demand-driven host checks whether the frame clock has active subscribers before asking needs_continuous_frames() to walk the whole view tree. Preserve that order when changing the browser host: a subscriber scan is the cheap positive path for live meters and animations, while the tree walk is the fallback for views that request continuous painting without a subscription. surface_lost still takes precedence over both because recovery must schedule another frame regardless of view activity.

The full-canvas editor pattern (a plugin's REAL Skia UI on the web)

Two modes, a per-plugin choice — full-canvas is ADDITIVE, not a replacement. A demo page runs in one of two modes, selected by the customUi seam:

  • Default (grid) mode — the shared player renders the plugin's parameters as a knob grid, generated declaratively. Zero bespoke UI code. This is the norm and the right choice for a simple utility (a gain, a basic filter).
  • Full-canvas mode — the page supplies a customUi (a compiled Skia UI module) that paints the plugin's whole bespoke editor onto ONE canvas filling the panel. Opt-in, more work, native-quality. Pick it when the plugin's identity is a custom visual (an EQ's frequency-response curve + draggable bands, a convolver's IR/field, a synth's scope).

No customUi → grid; customUi present → full-canvas — and the full-canvas mode FALLS BACK to the grid if the module fails to mount, so adding it never risks the baseline. Everything below is the full-canvas path.

A full-canvas editor paints the plugin's whole bespoke UI onto ONE Skia canvas that fills the panel edge to edge — the same native editor the DAW shows, running in the browser. super-convolver-gpu is the reference implementation; copy it, do not reinvent it. Where to look:

  • examples/super-convolver/super_convolver_ui.hpp — the editor itself (a vw::View drawn with the canvas API). This compiles into BOTH the native plugin and the wasm UI module — one source, two builds.
  • examples/web-demos/super-convolver-ui/ — the web mount: ui_entry.cpp (the EMSCRIPTEN_KEEPALIVE seam), super_convolver_web_host.hpp (a browser shim implementing the editor's host interface), pulp-ui.js (mountPulpUi), CMakeLists.txt.
  • tools/cmake/PulpWebUi.cmake — builds the editor + Pulp's view/canvas/Skia stack to wasm.
  • The GPU page in examples/web-demos/wclap-build/cloudflare/assemble-gallery.mjs (customUi + the CSS that widens #panel.pulp and pins the canvas height).
  • Docs: docs/guides/web-plugins.md ("Native Editors on the Web"), and the size/tier design in planning/2026-07-15-web-editor-architecture-and-size.md.

The decoupling contract (what lets one editor serve native + web). The editor talks ONLY to a small host interface (SuperConvolverUiHost: gpu_status / ir_path / load_ir_path / impulse_response_snapshot) + StateStore + a data bus — NEVER the DSP header. Native host = the processor; web host = the browser shim. A new plugin defines its own <Plugin>UiHost interface and two implementors.

Params cross the DSP, not the editor. The editor reads its parameter list from the DSP descriptor (adapter → pulp-ui.js → g_store). So a param the editor draws must be DECLARED BY THE DSP — declare the SAME set the native build declares (the web build once dropped Rooms and the editor showed four sliders where native shows five). Rebuild BOTH the UI module (build-webui) AND the DSP wasm when params change; the UI module alone is not enough.

Full-width layout. Strip the page chrome the editor already draws (engine dropdown, CPU/GPU blurbs, a separate load-file area) — it duplicates what the canvas paints. Widen #panel.pulp (max-width: min(1200px,94vw)) and give the canvas a FIXED height (clamp(420px,60vh,680px)), not an aspect ratio (aspect makes the box a function of width and collapses on a phone).

Responsive + iOS is NOT optional — the layout is DRAWN, so CSS won't save you

A full-canvas editor lays itself out in code, so a phone-width bug (overlapping header, sheared slider labels) never surfaces in a unit test — only in a browser, at that width.

A4 browser DPR evidence

The maintained super-convolver-web A4 cells use tools/scripts/gpu_dpr_web_adapter.py with the exact executable tools/scripts/gpu_dpr_web_measurement.mjs. Set PULP_DPR_WEB_MEASUREMENT_BIN, PULP_DPR_BROWSER_BIN, and PULP_DPR_WEB_BUILD_DIR to absolute paths before passing the adapter to gpu_dpr_runner.py. The build directory must contain the exact PulpSuperConvolverUi.js/wasm output for the planned Pulp SHA.

This lane requires hardware WebGL2, EXT_disjoint_timer_query_webgl2, 30 strictly positive timer-query samples, 20 unique Chrome process IDs, WebGL call instrumentation for upload/resident-byte ledgers, real pointer delivery, PNG fidelity, and nonce-scoped DevTools user-timing spans from one renderer PID. SwiftShader/software renderers, zero timings, repeated browser PIDs, mixed trace PIDs/nonces, native adapters, or handwritten category claims are invalid. The checked-in Python tests prove those rejection paths; they do not replace a real browser run.

The terminal v2 runner must invoke and snapshot the exact executable adapter, bind its actual producer PID, and retain eight unique files for every original and repeat web cell: JSON projections, both real PNGs, the real DevTools trace, and the exact wasm/Mach-O/ELF/PE product bytes. A filename, data string, or producer-authored pass claim cannot substitute for those bytes. Only finalize-v2 --run-dir ... may derive the candidate, and only fixed-path Git blobs plus verify-live-v2 establish protected-main publication.

Install exactly playwright-core@1.61.1 in the isolated measurement package; the producer records and verifies that version and rejects drift. Each metric declares measured/derived/unavailable provenance. WebGL timing includes five baseline and five eight-times-known-work query trials and must detect the extra work above its empirical resolution. The scenario manifest—not the page—owns the expected logical point/target; the page reports the actual pointer event and parameter hit. Compare a CPU reference PNG with a WebGL canvas PNG at identical content/state, bind both hashes to one token, and retain numeric similarity, small-text luminance variation, and thin-stroke coverage. Adaptive mode must record real DPR overrides and measured scale transitions. Old incompatible receipts remain SUPERSEDED/NONCOUNTED in the checked-in instrument state.

Every full-canvas editor MUST handle these, all learned the hard way on SuperConvolver:

  • Narrow breakpoint. The UI scale() keys off HEIGHT, so a tall skinny phone canvas has a LARGE scale and a SMALL width — a centered header (mode tabs) then collides with the wordmark on the left and the status chip on the right, and a quarter-width slider cell cannot fit "MIX" and "35 %" on one line. Add a narrow_ mode (W < ~680*scale): drop the tabs to their own row, give secondary chrome its own row, and STACK each slider as label-over-value-over-track. Keep desktop untouched.
  • The info/help overlay must reflow too — a fixed two-column card overflows a phone; give it a full-width, stacked, clipped variant.
  • devicePixelRatio can change with NO CSS resize (window dragged to a different-scale monitor, OS display-scale change, some pinch-zoom) — none of those fire resize or the ResizeObserver, so the backing store stays stale and the canvas goes blurry/mis-scaled. The shared web layer (web_input.cpp) arms a re-arming matchMedia("(resolution: Ndppx)") listener that re-runs the resize (which re-reads dpr) on each DPR change. Browser text-zoom DOES change the CSS size, so it is already covered by the ResizeObserver. The Ganesh surface also retires rasterized cacheable LayerHandles when DPR changes; callers rebuild them after layer_valid() turns false rather than scaling an old-density texture.
  • iOS file picker: the page's <input type=file> must NOT be display:none/hidden (iOS Safari drops .click() on it) — hide it visually instead. See its own landmine above.
  • Page scroll over the canvas: opt into touch-action: pan-y after mount so a vertical drag scrolls the page while a horizontal drag/tap still works the controls. See its landmine.
  • Live readouts: reserve their height (two lines if they can wrap) and WRAP rather than truncate, so flipping state never bumps the page; debounce fast-toggling values (~0.8s hysteresis) so a single dropped poll doesn't strobe the text.

A3/A4 GPU evidence on the maintained web canary

Do not treat a successful browser mount, responsive screenshot, or native Dawn receipt as web GPU evidence. The maintained UI canary is Skia Ganesh/WebGL2, while browser GPU-audio uses a separate emdawnwebgpu worker; record which lane each observation actually exercised.

For the A4 DPR experiment, the web owner supplies all 12 browser cells for the web-canary scenario: DPR 1, 1.5, 2, and 3 across exact, configured-max, and non-shipping adaptive simulations. Each independently produced receipt must bind the browser/build/adapter identity and issued attempt nonce, and retain 30 steady samples plus 20 fresh browser-process first-frame trials, capture similarity, small-text legibility, logical-input correctness, interaction latency, and bounded artifacts. Use the real browser canvas and browser input path; a native producer or synthetic receipt is not a substitute. A zero timing sample is unavailable evidence, never a fast frame.

Ingest through tools/scripts/gpu_dpr_runner.py ingest and require its closed checks before counting a cell. finalize remains forbidden until all 84 cells and exact A2T/A3 dependency receipts exist. The experiment may select no-change, configured-max-candidate, or adaptive-candidate; none of those changes current web DPR policy in Horizon A.

The current runtime control GPU-health provider is Standalone-only. Do not claim that dev.pulp.gpu/health.read@1 works in WAM/WebCLAP until an actual browser product endpoint, exact-instance receipt, and mapped control tests exist. A3's web relevance today is the transferable trace/evidence vocabulary and the rule that missing browser producers remain explicit, not a fabricated pass.

Verifying the GPU-AUDIO path on macOS: use REAL Safari, not Playwright WebKit

This one wastes hours if you don't know it. To verify a WebGPU-audio demo (the GPU engine actually producing blocks) on macOS:

  • Playwright's bundled "WebKit" has NO WebGPU — navigator.gpu is undefined, so it can never exercise the GPU lane. It is NOT real Safari. Do not conclude "Safari can't" from it.
  • Drive REAL Safari.app via safaridriver (W3C WebDriver) — it has WebGPU on the system GPU. One-time: sudo safaridriver --enable + Safari → Develop → "Allow Remote Automation". Client: selenium-webdriver (forBrowser("safari")). Reusable runner: examples/web-demos/tools/measure-safari-gpu.mjs --url <demo>.
  • The GPU-wedge asymmetry: repeated HEADLESS-Chrome (Dawn) WebGPU runs wedge that context's GPU for the session — it silently produces 0 blocks and stays dark, and even the baseline that worked earlier reads zero (that's the tell it's the environment, not your change). REAL Safari runs in its own process on the system GPU and is NOT wedged by that, so it keeps working when headless Chrome goes dark. (Memory: verify-gpu-ui-via-skia-raster.)
  • A fast Mac may be too fast to reproduce a slow device's MISS RATE (0% here vs 37% on a user's phone). So real Safari verifies CORRECTNESS (does the lane produce? does a change keep audio right? does a stat appear?); verify miss-rate LOGIC deterministically in the native stub-GPU harness test/test_super_convolver_web_gpu.cpp, whose fake worker can be made to fall behind and drop/expire blocks with no browser and no GPU.
  • Audio etiquette: safaridriver opens a real window and the demo plays the synth loop out the speakers — announce before, cap the run, driver.quit() after (CLAUDE.md).
  • For Safari/WebGPU specifics (timestamp-query support, small-dispatch quantization, feature gating) search the web or Apple docs via the sosumi CLI (sosumi search "WebGPU", sosumi fetch <developer.apple.com URL>) — Safari's WebGPU lags Dawn's, so never assume a Chrome capability is present. BETTER: query the REAL device via safaridriver (requestAdapter().features), because Safari advertises optional features on the ADAPTER but only grants them on a DEVICE created with requiredFeatures:[...]. Measured 2026-07-16: Safari's adapter lists timestamp-query, but requestDevice() without it returns a device WITHOUT it — so a WebGPU-timing path must opt the feature in explicitly or it silently gets 0.

Verifying it: a browser pass at three sizes is PART OF THE JOB, not a favor to ask for

Do NOT ship a full-canvas editor and let the user discover overlapping text. Before calling it done, run the bundled audit and LOOK at every size — this is the workflow, not an extra:

node examples/web-demos/tools/responsive-audit.mjs \
  --url https://<preview>/<plugin>/ --out /tmp/audit
# then READ /tmp/audit/{desktop,tablet,phone}-editor.png with your own eyes

It screenshots the page + editor at desktop (1440w), tablet (834w), and phone (390w), prints the editor's CSS size and computed touch-action, and lists what to check (header collisions, slider label/value overlap, the info card, even fill, and — on phone — the two-line readout, the file picker, and scroll). Drive real interactions with CDP Input.dispatchTouchEvent (a REAL touch), not synthetic new TouchEvent (which does not drive scrolling and gives a false negative). Fix what the screenshots show, redeploy, re-run.

The wasm DSP lanes compile a hand-picked source subset — not pulp-runtime

PulpWam.cmake / PulpWclap.cmake build pulp-wam-dsp / pulp-wclap-dsp from an explicit _PULP_WAM_CORE_SOURCES / _PULP_WCLAP_CORE_SOURCES list, deliberately NOT linking pulp-runtime (it drags in mbedTLS + http, too heavy for wasm). The web-timeline-source-closure gate then requires that EVERY core/{timebase,timeline,playback}/src/*.cpp appear in those lists — so adding a new production TU under core/timeline/src/ forces it into the wasm plugin. Keep native-only or heavy-dependency code (e.g. the DAWproject importer, which needs pugixml and audio/WAV inspection) OUT of core/timeline/src/ — put it in a sibling module such as core/dawproject/ with its own target so the closure does not sweep it into the wasm DSP binary. If a timeline source genuinely belongs in the web plugin, add it to BOTH lane lists (and provide any dependency the lanes don't already compile).

For core/timeline and core/playback specifically, both lanes consume the shared manifests (PulpTimelineSources.cmake, PulpPlaybackSources.cmake) by variable, so adding a TU to one manifest sweeps it into BOTH wasm binaries with no lane edit and no review prompt. That is a one-line change with a wasm-sized consequence: a source needing std::thread, exceptions, or a dependency the lanes don't compile (only rolling_audio_capture_buffer.cpp and sha256.cpp are carried from outside the three engine modules) must be header-only or live outside src/.

The closure globs src/*.cpp only, so a header-only engine addition — a new core/timeline/include/** type, or new inline logic on an existing one — needs no lane-list edit and cannot break the gate. Skill-sync still flags such a change here (it maps whole directories, not file kinds); confirm the diff adds no TU under core/{timebase,timeline,playback}/src/ before treating the flag as a real source-closure obligation.

A new CLAP adapter TU must be added to PulpWclap.cmake as well

tools/cmake/PulpWclap.cmake keeps its OWN list of core/format/src/*.cpp sources rather than linking pulp::format, because the wasm module is built standalone. So adding a translation unit to the CLAP adapter means editing two build files, not one.

Miss the second and every WebCLAP target fails at link with an undefined symbol — and native builds stay green, so it only shows up in CI. The native test targets that compile clap_adapter.cpp directly (see super_convolver_dsp_tests.cmake, canvas_text_tests.cmake) also link pulp::format, so the missing symbol resolves out of the archive. The wasm module has no archive to fall back on.

The same applies to any sibling file the adapter calls into: PulpWclap.cmake already lists clap_remote_controls.cpp and clap_note_name.cpp next to clap_adapter.cpp for exactly this reason.

The source list covers core/runtime/ too — not just the adapter's TUs

The rule above ("a new CLAP adapter TU must be added to PulpWclap.cmake") generalises further than it reads: PulpWclap.cmake's list is the wasm module's entire world, because it does not link pulp::runtime either. So a symbol the adapter merely references has to be there as well.

Concretely: clap_adapter.cpp gained a runtime::ScopedTracingAttachment, which calls Tracing::attach() / detach(). Under the default PULP_TRACING=OFF those compile to no-op stubs — but the symbols still have to exist, and core/runtime/src/trace.cpp was not in the list. Native builds linked fine (they get it out of the pulp::runtime archive); every WebCLAP target failed with wasm-ld: undefined symbol: pulp::runtime::Tracing::attach().

The failure mode is what makes this expensive: it is invisible locally and on the required macOS gate, and surfaces only in the Build + prove lane. When you add ANY dependency to a TU on this list — not just a new TU — check whether its definition is in the list too.

The Ganesh/WebGL surface reports a frame outcome now (WAH-2)

SkiaSurface::end_frame() returns render::FrameOutcome instead of void. On the browser backend (skia_surface_ganesh.cpp):

  • a normal flush reports presented — the browser composites the canvas element itself, so a successful flushAndSubmit() IS the frame reaching its output;
  • a LOST WebGL context reports recreate, not failed: the caller must rebuild against the restored context before the frame means anything;
  • has_presentable_target() is always true here — there is no offscreen-by-design mode on this backend.

Hosts gate damage retirement on render::frame_reached_output(outcome), so a backend that misreports makes the UI either repaint forever (false failure) or go stale (false success). If you add a web render path, return an honest outcome rather than defaulting to presented.

headless_defaults.cpp must never share a link with pulp-format-core

core/format/src/wasm/headless_defaults.cpp supplies the WASM lane's own definitions of Processor::create_view() and create_ara_document_controller(), because a plugin that overrides neither still carries both slots in its vtable and the wclap/wam links contain no view layer and no ARA SDK.

The native definitions of those same two methods live in format.cpp and ara.cpp, both compiled into pulp-format-core. So the ODR prohibition is now about a target, not just two filenames: PulpWclap.cmake and PulpWam.cmake compile format sources directly and must never grow a link to pulp-format-core. Nothing enforces that mechanically — the comment at the top of the file is the only guard, which is why it must keep naming the right target.

Completeness rule, measured rather than assumed. Defining one of these as return nullptr; does not need the returned type complete; format.cpp compiles against nothing but processor.hpp's forward declaration, which is what keeps pulp-format-core view-free. Calling one does, because the caller destroys the returned unique_ptr and ~unique_ptr instantiates the deleter:

error: invalid application of 'sizeof' to an incomplete type 'pulp::view::View'

So "unique_ptr needs a complete type" is true at the destruction site, not at the definition. The minimal class View {} / class AraDocumentController {} completions in that file are kept because the TU stands in for the whole view and ARA layers in a link that has neither, not because returning nullptr would otherwise fail to compile.

The WebCLAP module compiles its own adapter subset, so new adapter calls need their source here

tools/cmake/PulpWclap.cmake builds clap_adapter.cpp into the wasm module from an explicit source list and does not link pulp-format-core (nor pulp::runtime). A call the CLAP adapter gains into another format-core translation unit links natively and fails only here, as a wasm-ld: undefined symbol in the Build WebCLAP module step of the required Build + prove + (owner-gated) deploy check — request_editor_prewarm() in editor_prewarm.cpp was one. Add that source to _PULP_WCLAP_CORE_SOURCES (keep it free of view-layer dependencies; the editor prewarm is a no-op here because no view scheduler is installed). There is no local wasm toolchain on the Macs by default, so this gate is usually first seen in CI.

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

aax

無料

Optional AAX support for Pulp, including developer-supplied Avid SDK setup, CMake enablement, DigiShell/AAX Validator workflows, and local AAX builds on macOS or Windows.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月12日 更新

Configure, implement, and test Pulp's optional desktop Ableton Link tempo-sync adapter while preserving the developer-supplied SDK, licensing, realtime, latency-compensation, and no-install boundaries.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月12日 更新

Maintain Pulp's installed design-time agent capability manifest and public-surface ledger. Use when adding, removing, renaming, or materially changing public audio, MIDI, signal, timebase, or sequence APIs; registering a new algorithm for generators; changing capability support or deprecation state; or repairing agent-capabilities freshness, schema, fingerprint, tombstone, or installed-SDK tests.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月12日 更新

android

無料

Android platform development for Pulp — NDK cross-compilation, Oboe audio, Dawn/Skia GPU rendering, JNI bridge, touch interaction, emulator workflows, and end-to-end smoke validation. Covers build, deploy, debug, and the gotchas discovered during bringup.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月12日 更新

ara

無料

Optional ARA support for Pulp, including developer-supplied ARA SDK setup, CMake enablement, adapter companion APIs, validation, and ARA-aware plugin implementation guidance.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月12日 更新

The measurement surface for ALL Pulp DSP and audio-pipeline work — read it BEFORE writing or gating DSP, not only when something already sounds wrong. Covers the C++ harness (signal generators, metrics, assertions, RenderScenario, contracts), the offline Audio Doctor (magnitude/frequency response, THD/THD+N, phase/group delay), and their Python sibling the Audio Quality Lab (tools/audio/quality-lab — null residual + alignment, LTAS log-spectral distance, spectral flux/centroid, HNR, Theil-Sen drift slope, Kaiser-sinc resampling, license-guarded corpus, regression-net ratchet). TRIGGER on AUTHORING work — "build/design an oscillator/filter/synth/effect", "add a DSP module", "what should the acceptance gate be", "how do I measure aliasing / anti-aliasing / alias floor", "null against a reference", "is this DSP correct", "choose a tolerance", "golden/regression corpus for audio", "measure drift or jitter", "A/B two renders" — AND on DEBUGGING work — "is there sound / no audio / I hear nothing", "does this filter/compressor/synth/delay produce the right signal", "prove the DSP / prove the contract", "measure the frequency response", "what's the THD / is it distorting", "what's the group delay / phase response / measured latency", "magnitude response curve", "render a test tone and assert", "audio regression", "64-frame works but 128 is silent", "sample-rate change pitch-shifted it", "describe what's in this buffer", "audio doctor", "compare before/after a DSP refactor". Reach for this BEFORE hand-rolling any FFT, null test, alias measurement, pitch tracker, or golden-render script — most of it already exists in one of the two lanes. Test/tool layer over HeadlessHost — deterministic, no audio device, no speakers. Off the realtime thread entirely.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月12日 更新

Generous-Corp のスキルをすべて見る

このスキルの問題を報告する