Skip to content

AudioBufferSourceNode emits one extra sample and perturbs interior samples when start() is not on a render-quantum boundary #196

Description

@mikekasky

Version: reproduced identically on 2.0.0 and 2.2.0 (latest published at time of filing). Environment: Node v24.16.0, macOS darwin/arm64. The two versions produce a cell-for-cell identical grid on the same machine, so nothing about this changed between them.

Reference check: Safari/WebKit (Version/26.5.2 Safari) renders the identical fixture bit-exact at every phase, length and rate — no extra sample, no interior perturbation.

Summary: When an AudioBufferSourceNode's start time falls on a multiple of the 128-sample render quantum, rendering is a bit-exact copy of the buffer. At any other start it is not: the node emits one sample beyond the buffer's length, and interior samples differ by ~1 ULP. The extra sample carries the buffer's own final value once the buffer exceeds 108339 samples (at 48 kHz and 96 kHz; 44.1 kHz behaves differently and does not exhibit it in that range).

Minimal reproduction (no third-party code):

const { OfflineAudioContext } = require('node-web-audio-api')
const SR = 48000, L = 109000, START = 24064 + 1   // +1 => off the 128-sample grid
const ctx = new OfflineAudioContext(1, START + L + 512, SR)
const buf = ctx.createBuffer(1, L, SR), d = buf.getChannelData(0)
for (let i = 0; i < L; i++) d[i] = -0.1 * Math.pow(0.99991, i)   // smooth decay
const src = ctx.createBufferSource(); src.buffer = buf
src.connect(ctx.destination); src.start(START / SR)
ctx.startRendering().then((out) => {
  const o = out.getChannelData(0)
  console.log('one past the buffer end:', o[START + L])   // expected 0, observed -5.4876e-6
  console.log('buffer final value     :', d[L - 1])       //               -5.4881e-6
})

Set START = 24064 (a multiple of 128) and the same sample is exactly 0.

Expected: a start() at a sample-aligned time renders the buffer as a direct copy; nothing is emitted past start + buffer.length.
Actual: one extra sample, plus ~1 ULP interior differences, whenever the start is not quantum-aligned.

Grid. Start phase 0..127 × buffer lengths {4096, 108000, 108300, 108339, 108400, 109000} × rates {44.1k, 48k, 96k}. Reported value is the magnitude of the sample one past the buffer's end (phases 1 / 64 / 127 — identical across all three). Identical output on 2.0.0 and 2.2.0:

rate L=4096 108000 108300 108339 108400 109000
44.1 kHz 1.29e-11 0 0 0 0 0
48 kHz 1.74e-11 7.03e-14 8.50e-14 5.82e-6 5.79e-6 5.49e-6
96 kHz 1.74e-11 7.03e-14 8.50e-14 5.82e-6 5.79e-6 5.49e-6

Three things the grid shows that a single repro does not:

  1. Phase 0 is exactly zero at every length and every rate — not small, zero. All 127 non-zero phases produce the identical magnitude. The behaviour is binary on quantum-alignment, with no dependence on where inside the quantum the start falls. At L=109000: 0/128 phases exceed 1e-9 at 44.1 kHz; 127/128 at 48 kHz and 96 kHz.
  2. The threshold is a fixed sample count, not a time. 108339 fires identically at 48 kHz and 96 kHz — same sample count, half the duration. 108300 does not fire at either. A time-based boundary would have moved.
  3. 44.1 kHz never fires in this range — 0/128 at every tested length, so the rate does not merely shift the boundary proportionally.

Why it matters to us: we render offline at 48 kHz and sum many stroke buffers into one stage-length buffer. Before we understood this, every mid-quantum stroke of long-tailed material was contributing one spurious trailing sample. We have since restructured to play a single source at start(0), which is quantum-aligned by construction and takes the direct-copy path — so we are not blocked. Filing because the divergence from WebKit on the same fixture looks like a real bug rather than a spec latitude, and the fixed-sample-count threshold suggests a specific code path rather than general float behaviour.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions