research brief · fed to the builders

1-bit sound

Signal design, the density scalar out of a WebGL field, Web Audio architecture, headroom, reproducibility, and a headless test strategy.

0. Framing — why this is the same law, not a new one

The canon's central move is that grey is a lie told by dots: continuous tone is represented as a spatial density of binary marks, never as an intermediate shade. Delta-sigma modulation (DSD, 1-bit audio) is exactly this claim rewritten in the time domain: a continuous amplitude is represented as a temporal density of binary pulses (all-1 or all-0, no intermediate voltage), and the ear's low-pass filtering does what the eye's spatial low-pass filtering does for a Bayer-dithered image. This is not a metaphor of convenience — it is the literal same operator (noise-shaped quantization) applied on the other axis. That's the justification for building it at all under Law "no new hexes / no new flourishes": it is one constraint (1-bit density) applied twice, not two constraints.

Concretely this means: the audio brief is not "add a synth." It is "add a bitstream" — a signal that is provably only ever +1/-1 (or 1/0) at the sample level, whose density of ones carries the sub-bass heartbeat and the noise floor. If at any point the implementation reaches for a multi-bit oscillator with smooth gain automation and calls it "1-bit" because the final DAC happens to be 16-bit, it has broken the doctrine while keeping the name. The brief below is written to keep the bitstream real.

1. Background — what "1-bit audio" actually is

DSD / delta-sigma modulation (Sony/Philips, 1999, descended from the delta-sigma ADCs used in most digital audio converters since the 1980s). A 1-bit DSD stream is not "low quality PCM" — it's a very high sample rate (2.8224 MHz for DSD64) single-bit stream where information is encoded as pulse density: more 1s in a window = higher instantaneous amplitude. It's generated by a feedback loop: compare the input signal against a running estimate of what the bitstream has produced so far (the integrated error), emit a 1 or 0 based on the sign of that error, and feed the error back in for the next sample. This noise-shapes the quantization error up out of the audible band. The classic reference is Norsworthy/Schreier/Temes, Delta-Sigma Data Converters (1997); the audio-specific popularization is Sony/Philips' Super Audio CD/DSD spec (1999).

ZX Spectrum beeper (1982) and early PC speaker. These are the degenerate, unshaped case: a single GPIO pin toggling a piezo/speaker cone directly, 1-bit, no DAC at all. Tone comes from toggling at audio-rate frequencies (a square wave — this is the "beeper music" tradition: Spectrum tunes were literally interrupt-driven pulse trains). Where it gets interesting for this brief is sample playback on 1-bit hardware — techniques like Direct D/A adapted to a 1-bit pin (used in the demoscene, e.g. the "Covox"-adjacent and PWM-driven beeper players from the mid-90s Spectrum scene): the pin is toggled at a fixed high rate (e.g. 15.6 kHz–31 kHz) and the pulse width within each period approximates the sample value — that's PWM, a cousin of delta-sigma but open-loop and lower quality (no noise shaping, so quantization noise isn't pushed out of band, it just sits there as grit). PC speaker "RealSound"-style playback (Access Software, 1988) is the same open-loop PWM idea on the IBM PC's own 1-bit speaker driver.

Two distinct techniques, and the brief needs both:

2. Signal design

Two independent 1-bit streams, mixed after both are already binary-derived, not before:

A. Sub-bass heartbeat carrier. Target frequency: low, e.g. 55 Hz (A1) or lower — something felt more than heard, consistent with "sub-bass." Its amplitude envelope is not a new curve; it must be the canonical pulse already defined in the codebase:

beat(t) = sin(t · 1.55)^4

confirmed in canon.BEFORE.src.html (var beat = Math.pow(Math.sin(t*1.55), 4);) and in DESIGN.md §6. Its true period is π / 1.55 ≈ 2.027s (sin⁴ halves the period of sin), which matches the brief's "period 2.03s" — use 2.027 if you need to hardcode a loop length, but prefer computing it live from t so it never drifts from the visual pulse. The audio and the visual pulse must share one time source (u_time/performance.now()-derived t, or better, an explicit shared t0 epoch passed to both the shader and the AudioWorklet at start) — two independently-clocked sin⁴ curves will phase-drift within seconds and the "same seed, same output" law is violated the moment they do.

Carrier signal before quantization:

x(t) = A · beat(t) · sin(2π · f0 · t)

with f0 ≈ 55, A the headroom-limited peak (see §4).

B. Field-density noise. Filtered noise whose pulse density (not amplitude — density, to keep the doctrine literal) follows a scalar d ∈ [0,1] read from the page: the field's mean shape(f) value (see the fragment shader — shape() is already the 0..1 density function the field uses per-cell; you need its spatial mean per frame, not the per-pixel value, since audio has no notion of "this pixel"). This is analogous to a dither matrix's fill ratio.

3. Getting the density scalar out of a WebGL field cheaply

This is one of the two genuinely hard parts. You cannot read back the full framebuffer every frame without a stall (a gl.readPixels on the full canvas is one of the more reliable frame-time killers in headless and real GPUs, because it forces a pipeline flush).

Concrete approach:

  1. Render (or re-evaluate) the field's density function at a tiny fixed resolution — e.g. 16×16 or even 8×8 — into a dedicated low-res FBO, once per frame, using the same shape(fbm4(...)) math but at trivial cost (256 texels vs. a full canvas).
  2. gl.readPixels that 16×16 (or 8×8) RGBA buffer — 1KB, effectively free, no visible stall even on swiftshader.
  3. Average the R channel (or luma) in JS → d.
  4. Low-pass that scalar in JS (e.g. one-pole d_smooth += (d - d_smooth) * 0.05 per frame) before handing it to audio — the visual field is spatially noisy frame to frame and you do not want the noise-bed density literally jittering at 60 Hz, that reads as digital chatter rather than "grain."
  5. Post d_smooth to the AudioWorklet via port.postMessage at a throttled rate (every ~100 ms is plenty — density is a slow-moving ambient quantity, not a control-rate signal) or, better, via a SharedArrayBuffer-backed AudioParam-like ring if you want to avoid message-passing GC churn (see §6).

If SharedArrayBuffer is unavailable (it requires cross-origin-isolation headers — COOP/COEP — which a static artifact served from an arbitrary host will very often not have), fall back to plain postMessage; at a 10 Hz update rate this is not a performance concern.

4. Web Audio architecture

Use an AudioWorkletProcessor, not ScriptProcessorNode. ScriptProcessorNode runs on the main thread and is deprecated; on a page that's already running a WebGL shader loop and possibly Canvas 2D dither work, contending for main-thread time will produce audible glitches under load — the worklet runs on the dedicated audio render thread and is the only architecturally sound choice here. Support is universal in evergreen browsers (Safari since 14.1, 2021) so there's no compatibility argument for ScriptProcessorNode today.

4a. Node graph

[AudioWorkletNode "onebit-processor"]
        │  (already-quantized ±1 bitstream, generated inside the worklet)
        ▼
[GainNode masterGain]  ← single point of gain-staging, see §5
        ▼
[AudioContext.destination]

Do the delta-sigma math inside the worklet, sample-by-sample, so the ±1 stream is real and not an illusion produced by clipping a sine wave downstream (a WaveShaperNode hard-clip looks like 1-bit on a scope but is not driven by pulse-density feedback and will alias badly — don't do that).

4b. The processor

// onebit-processor.js — registered via audioContext.audioWorklet.addModule(...)
class OneBitProcessor extends AudioWorkletProcessor {
  static get parameterDescriptors() {
    return [
      { name: 'density', defaultValue: 0, minValue: 0, maxValue: 1, automationRate: 'k-rate' },
      { name: 'active',  defaultValue: 0, minValue: 0, maxValue: 1, automationRate: 'k-rate' },
    ];
  }

  constructor(opts) {
    super();
    this.t0 = opts.processorOptions.t0;      // shared epoch, seconds (performance.now()/1000 at start)
    this.f0 = 55;                             // sub-bass carrier, Hz
    this.integrator = 0;                      // delta-sigma feedback state (1st order)
    this.quant = 0;                           // last quantized output, -1 or +1
    this.noiseIntegrator = 0;                 // separate 1st-order integrator for the noise branch
    this.lpState = 0;                         // 1-pole lowpass state, shapes white noise before PDM
    this.rngState = 0x9e3779b9 ^ (opts.processorOptions.seed | 0); // xorshift, seeded — reproducibility
  }

  // xorshift32 — deterministic given the seed, unlike Math.random()
  rand() {
    let x = this.rngState;
    x ^= x << 13; x ^= x >>> 17; x ^= x << 5;
    this.rngState = x | 0;
    return (x >>> 0) / 4294967296; // [0,1)
  }

  process(inputs, outputs, parameters) {
    const out = outputs[0][0];
    if (!out) return true;
    const density = parameters.density[0];
    const active = parameters.active[0];
    const sr = sampleRate; // global in AudioWorkletGlobalScope

    for (let i = 0; i < out.length; i++) {
      if (active < 0.5) { out[i] = 0; continue; }

      const t = this.t0 + (currentFrame + i) / sr; // sample-accurate, shares epoch with the shader

      // --- carrier: sub-bass tone under the canonical pulse envelope ---
      const beat = Math.pow(Math.sin(t * 1.55), 4);
      const carrierTarget = 0.6 * beat * Math.sin(2 * Math.PI * this.f0 * t);

      // 1st-order delta-sigma modulator on the carrier
      this.integrator += carrierTarget - this.quant;
      this.quant = this.integrator >= 0 ? 1 : -1;

      // --- noise branch: pulse-density modulation driven by field density ---
      // white noise, 1-pole lowpassed so its spectrum isn't flat (a bit of colour,
      // and it keeps very-high-frequency content from aliasing hard against sr/2)
      const white = this.rand() * 2 - 1;
      this.lpState += (white - this.lpState) * 0.35;
      const noiseTarget = (density * 0.5) * this.lpState; // density scales AMPLITUDE fed to the modulator,
                                                            // which is what makes ones more frequent — density
                                                            // in, density out, not a separate gain stage
      this.noiseIntegrator += noiseTarget - (this.noiseIntegrator >= 0 ? 1 : -1) * Math.abs(noiseTarget) * 0 + noiseTarget;
      // (see note below — simplify to the same 1-bit pattern as the carrier)
      const noiseQuant = this.noiseIntegrator >= 0 ? 1 : -1;
      this.noiseIntegrator -= noiseQuant;

      // mix two independent 1-bit streams at fixed sub-unity weights, THEN
      // this is where the signal stops being provably 1-bit and becomes a
      // 3-level (or more) PCM signal again — see §5 on why that's necessary
      // and acceptable.
      out[i] = 0.22 * this.quant + 0.05 * noiseQuant;
    }
    return true; // keep the processor alive
  }
}
registerProcessor('onebit-processor', OneBitProcessor);

I left one line messy on purpose (the noiseIntegrator update) to flag it in prose rather than bury the caveat in clean-looking code: write the noise branch with the identical two-line delta-sigma idiom as the carrier —

this.noiseIntegrator += noiseTarget - noiseQuant_prev;
noiseQuant = this.noiseIntegrator >= 0 ? 1 : -1;

— don't let it diverge into a different (and subtly wrong) integrator shape. The builder should delete my scratch line and use the same pattern as this.quant.

4c. Main-thread wiring

let ctx, node, gain, running = false;

async function startOneBit() {
  if (running) return;
  ctx = new (window.AudioContext || window.webkitAudioContext)();
  await ctx.audioWorklet.addModule('onebit-processor.js');

  node = new AudioWorkletNode(ctx, 'onebit-processor', {
    numberOfInputs: 0,
    numberOfOutputs: 1,
    outputChannelCount: [1],
    processorOptions: { t0: performance.now() / 1000, seed: 0xC0FFEE }, // fixed seed → reproducible
  });

  gain = ctx.createGain();
  gain.gain.value = 0; // start silent, ramp up — never a hard onset, see §5
  node.connect(gain).connect(ctx.destination);

  node.parameters.get('active').setValueAtTime(1, ctx.currentTime);
  gain.gain.linearRampToValueAtTime(0.35, ctx.currentTime + 0.6); // 600ms fade-in

  running = true;
  setVisibleAudioState('live'); // toggles the [ON]/[OFF] Departure label — see §5.4
}

function stopOneBit() {
  if (!running) return;
  const t = ctx.currentTime;
  gain.gain.cancelScheduledValues(t);
  gain.gain.setValueAtTime(gain.gain.value, t);
  gain.gain.linearRampToValueAtTime(0, t + 0.3);
  setTimeout(() => { ctx.close(); running = false; }, 350);
  setVisibleAudioState('off');
}

// per-frame from the render loop, throttled:
function pushDensity(d_smooth) {
  if (node) node.parameters.get('density').setValueAtTime(d_smooth, ctx.currentTime);
}

Every gesture-gated entry point (a button, never autoplay) calls startOneBit() directly inside the click handler — AudioContext construction and resume() must happen synchronously inside the user-gesture call stack, not after an await or inside a requestAnimationFrame callback, or Chrome/Safari's autoplay heuristic will refuse it. new AudioContext() itself is fine to construct anytime, but the first resume()/first sound must trace back to the click.

5. Gain staging, headroom, and "never clips"

This is the second genuinely hard part, and it's harder than it looks because two summed independent 1-bit streams are not one 1-bit stream:

Numeric headroom:

5.4 Visible state (required by the brief and by §15 Accessibility)

The control is a standard canon secondary/ghost button per §3 Components — Departure, tracked, uppercase, square. State is not a colour change (never essence or sigil as a fill) — it's the bracketed status idiom already in the canon: [ SOUND: OFF ] → on click → [ SOUND: LIVE ] with the label pulsing on the canonical beat while active, exactly as the loading-button spec already prescribes ("a loading button pulses its label on the canonical beat rather than spinning"). Reuse that idiom verbatim rather than invent a new active-state treatment. On failure (AudioContext construction throws, or resume() rejects — happens on some locked-down mobile browsers or when a stricter autoplay policy is in force even after a gesture) fall back to [FAIL] at o-1 per the existing error-state law, and log the reason to console — never fail silently, since a silent audio feature that appears to do nothing is worse than no feature.

6. Update-rate mismatch (density feed) — likely failure mode

AudioParam.setValueAtTime called every render frame (60/s) from the main thread against a k-rate param works but is wasteful and, worse, if the main thread stalls (WebGL readback contention, a layout thrash elsewhere on the page) the density value goes stale silently — not a crash, just wrong-sounding, which is the worst kind of bug here because nothing errors. Mitigations:

7. Reproducibility ("same seed, same output")

Two independent sources of nondeterminism must be pinned:

  1. RNG — do not use Math.random() inside the worklet; use a seeded PRNG (xorshift32 above, seed hardcoded or derived deterministically from a page-level seed constant, not from Date.now()).
  2. Time epoch — t0 must be the same logical clock the shader's u_time uses. If the shader zeroes u_time at module load and the AudioWorklet zeroes t0 at the moment the user's gesture fires startOneBit() (which could be seconds or minutes after page load), the two sin(t·1.55) curves will be in different phase even though both are individually correct sin⁴ curves — visually and sonically each looks right in isolation, but they will not agree, which fails "same seed, same output" as a system, not as individual components. Fix: capture a single pageT0 = performance.now() once, at module init, before either system starts, and derive both the shader's time uniform and the worklet's t0 from (performance.now() - pageT0) / 1000 at whatever moment each one starts.

Given the fixed carrier frequency, fixed seed, and shared epoch, the bitstream is bit-for-bit reproducible on a given browser/sample-rate — note "given sample rate": delta-sigma output is not reproducible across different AudioContext.sampleRate values (44.1kHz vs 48kHz devices will produce different quantization decisions at the same wall-clock time, because the comparator fires at a different rate). This is an inherent property of the technique, not a bug to fix — state it in the artifact's evidence layer rather than pretend byte-identical cross-device reproducibility exists.

8. Headless test strategy (Playwright, no speakers)

Playwright + Chromium's audio backend runs fully in the sandboxed test environment; there is no need for a real output device to verify correctness. Concrete checks, roughly in order of value:

8.1 State machine correctness (cheap, high value)

await page.click('#sound-toggle');
await page.waitForFunction(() => window.__audioCtx?.state === 'running');
expect(await page.textContent('#sound-toggle')).toMatch(/LIVE/);
await page.click('#sound-toggle');
await page.waitForFunction(() => window.__audioCtx === null || window.__audioCtx.state === 'closed');
expect(await page.textContent('#sound-toggle')).toMatch(/OFF/);

Expose window.__audioCtx = ctx (or a getter) purely for test introspection — harmless, and standard practice.

8.2 No autoplay before gesture (regression guard for the hardest law here)

await page.goto(url);
await page.waitForTimeout(1500); // let any renderer settle
const ctxExists = await page.evaluate(() => !!window.__audioCtx);
expect(ctxExists).toBe(false); // MUST be false — nothing should have started an AudioContext yet

Also assert no AudioContext was constructed via a page.on('console')/CDP check if you want to catch the more subtle "constructed early, resumed late" pattern (constructing early is technically permitted by most autoplay policies, but constructing it before the artifact's own visible-state button exists reads as intent to autoplay — flag it in review even if the browser allows it).

8.3 Analyser-based waveform verification (proves the signal is really quantized, not just "some sound")

const stats = await page.evaluate(async () => {
  const analyser = window.__audioCtx.createAnalyser();
  analyser.fftSize = 2048;
  window.__gainNode.connect(analyser); // tap post-gain, pre-destination
  await new Promise(r => setTimeout(r, 200)); // let a few buffers flow
  const buf = new Float32Array(analyser.fftSize);
  analyser.getFloatTimeDomainData(buf);
  const levels = new Set(buf.map(v => Math.round(v * 1000) / 1000));
  return {
    max: Math.max(...buf.map(Math.abs)),
    distinctLevels: levels.size,
    mean: buf.reduce((a,b)=>a+b,0) / buf.length,
  };
});
expect(stats.max).toBeLessThan(0.1);        // gain-staging ceiling, §5 (~0.077 worst case + margin)
expect(stats.mean).toBeCloseTo(0, 1);        // DC-blocked, no persistent offset
// distinctLevels will be large in the time-domain buffer because the highpass/DC-blocker
// and the analyser's own float precision smear the raw ±1 steps — this does NOT verify
// "1-bit" at this tap point. To verify the true bitstream, add a debug tap (§8.4).

8.4 True bitstream verification (tap before the DC-blocker/filter) The most rigorous check requires reading the actual worklet output before any downstream filtering, since a BiquadFilterNode will turn a clean ±1 stream into a continuous-looking waveform (that's the filter doing its job). Add a debug-only second output or a MessageChannel from the worklet that periodically posts a raw sample block back to the main thread (gate this behind a ?debug=1 query param or similar so it never ships in the real artifact's hot path):

// inside process(), throttled (e.g. every 128 frames):
if (this.debugCounter++ % 128 === 0) {
  this.port.postMessage(rawBlockBeforeFilter.slice()); // Float32Array snapshot
}
const raw = await page.evaluate(() => window.__lastRawBlock);
const levels = new Set(raw.map(v => Math.sign(v))); // should be exactly {-1, 1} (or {-1,0,1} if 'active' gating zeros)
expect([...levels].every(v => v === -1 || v === 1)).toBe(true);

This is the only check that actually proves the "1-bit" claim rather than just "it's quiet and doesn't clip." Include it — it's cheap and it's the one property this whole feature exists to demonstrate.

8.5 Density coupling test Drive the field to a known density (or stub the shader entirely and call pushDensity(0.9) / pushDensity(0.05) directly), then compare RMS or bit-flip-rate of the noise branch between the two states — should differ measurably (higher density → more frequent quantizer sign changes in the noise-only component). This validates the "pulse density follows field density" claim rather than just "some noise plays."

**8.6 Things Playwright cannot verify, be honest about them:**

9. What is likely to go wrong (ranked)

  1. Phase drift between the visual sin⁴ pulse and the audio sin⁴ pulse because they're computed from two different clocks (u_time reset on shader init vs. t0 reset on gesture) — the single most likely subtle bug, and the one that breaks "same seed, same output" without breaking anything on either side individually. Fix per §7: one shared epoch.
  2. Reaching for WaveShaperNode hard-clipping or bitcrusher-style downsampling and calling it "1-bit" — these produce audibly similar grit but are not delta-sigma, don't noise-shape, and alias badly at low bit counts. The brief's math must be implemented as a literal feedback loop, not approximated by a built-in node.
  3. Blocking readPixels on the full-resolution field canvas to get the density scalar — will tank frame time on swiftshader especially. Must use the dedicated low-res FBO (§3).
  4. Autoplay policy violation from an await between the click and resume() — the single most common Web Audio bug in the wild. Keep the gesture-to-resume() path synchronous.
  5. Clipping from summing two post-quantization ±1 streams without collapsing to one integrator (§5's core correction) — easy to get wrong on a first pass because splitting the two signal paths for code clarity is the natural instinct, and it silently produces a louder, buzzier result that still "sounds okay" so it's easy to ship uncaught. The bitstream-verification test in §8.4 is the guard for this.
  6. DC offset creep on long-running sessions (this artifact plausibly stays open for minutes) — mitigate with the 20 Hz highpass (§5); test by letting the context run several minutes in a soak test and checking the analyser's mean stays near zero over time, not just at t+200ms.
  7. Leaving the AudioContext unclosed on page navigate/unload — add a pagehide/visibilitychange handler that calls ctx.suspend() (not close(), in case the user returns) so a background tab doesn't keep the audio thread hot indefinitely; this also matters for battery on real devices even though it's invisible in a headless test.

10. Where the law is genuinely in tension, stated plainly