fix(audio): keep the pitch shifter's tail; no silence after a bitcrush seek (#5478)

The offline render now pads the source with silence for the whole render
(clip + chain tail + latency) instead of only the true-peak lookahead, so the
pitch shifter's ring drains instead of cutting off about 90 ms early.

A bitcrush node built part-way into a clip seeded its hold counter but started
its held value at 0, playing up to samples-1 frames of silence on a preview
seek. The held value is now primed from the first input sample; render windows
still line up with a whole render.
This commit is contained in:
Jerrai
2026-10-12 01:29:50 +00:00
committed by GitHub
parent 5f9db987c1
commit 8f7906b337
4 changed files with 52 additions and 5 deletions
@@ -81,6 +81,22 @@ describe("the worklet processors themselves", () => {
}
});
describe("HfBitcrush", () => {
it("does not open with silence when its hold counter starts part-way through a hold", async () => {
const HfBitcrush = (await loadProcessors()).get("hf-bitcrush");
if (!HfBitcrush) throw new Error("hf-bitcrush not registered");
// 251 frames in with a hold of 250: one frame into a hold, with 249 left before the next sample.
const p = new HfBitcrush({
processorOptions: { bits: 8, samples: 250, mix: 1, __hfElapsed: 251 / 48000 },
});
const input = new Float32Array(128).fill(0.5);
const output = new Float32Array(128);
p.process([[input]], [[output]]);
expect(Math.abs(output[0] ?? 0)).toBeGreaterThan(0.4);
expect(output.every((x) => Math.abs(x) > 0.4)).toBe(true);
});
});
describe("HfPitchshift", () => {
const SR = 48000;
const BLOCK = 128;
+2 -1
View File
@@ -208,7 +208,8 @@ class HfBitcrush extends AudioWorkletProcessor {
const mix = p.mix ?? 1;
for (let ch = 0; ch < i.length; ch++) {
const inp = i[ch], out = o[ch];
if (this.held[ch] === undefined) this.held[ch] = 0;
// Primed from the input: a counter seeded mid-hold would otherwise play 0 until it wraps.
if (this.held[ch] === undefined) this.held[ch] = Math.round(inp[0] * levels) / levels;
// The hold counter runs from the file's first sample, so a node built part-way in starts where it would be.
if (this.holds[ch] === undefined) this.holds[ch] = this.start % step;
for (let n = 0; n < inp.length; n++) {
@@ -98,14 +98,15 @@ async function render(
// A lookahead limiter delays its output; render that much longer and drop the
// lead-in so the clip stays sample-aligned with its source.
const latency = chainLatencySamples(chain, sampleRate);
const ctx = new OfflineAudioContext(channels, frames + tail + latency, sampleRate);
const total = frames + tail + latency;
const ctx = new OfflineAudioContext(channels, total, sampleRate);
if (chainNeedsWorklets(chain)) await ensureAudioFxWorklets(ctx);
const source = ctx.createBufferSource();
// Padded with silence by the limiter's lookahead: once a source ends its worklets stop being
// fed, so without it the last `latency` samples of the clip never leave the lookahead line.
source.buffer = toBuffer(ctx, planes, channels, frames + latency, sampleRate);
// Padded with silence for the whole render: once a source ends its worklets stop being fed, so
// anything still held in a lookahead line or a grain ring would never leave it.
source.buffer = toBuffer(ctx, planes, channels, total, sampleRate);
const fx = buildFxChain(ctx, chain, elapsed);
@@ -1139,6 +1139,35 @@ describe("windowed rendering of a long input", () => {
120_000,
);
it.skipIf(!HAS_BROWSER)(
"a pitch shifter keeps the last 90 ms of its input",
async () => {
const input = join(dir, "ps.wav");
const frames = Math.round(0.4 * SR);
writeWav(input, new Float32Array(frames).fill(0.3), SR, 1, true);
const out = join(dir, "ps-out.wav");
const chain: HfAudioFxChain = {
version: 1,
nodes: [
{
type: "pitchshift",
id: "p",
enabled: true,
params: { ...defaultAudioFxParams("pitchshift"), semitones: 7, mix: 1 },
},
],
};
await applyAudioFxChain(input, chain, out, { trackId: "ps" });
const samples = readWav(out).samples;
// The shifter reads up to a grain behind its write head, so the last 90 ms of the clip comes
// out after the input ends. A steady level stays steady, so quiet there is dropped audio.
expect(samples.length).toBeGreaterThan(frames);
expect(sliceRms(samples, 0.31, 0.4)).toBeGreaterThan(0.2);
expect(sliceRms(samples, 0.4, 0.45)).toBeGreaterThan(0.1);
},
120_000,
);
it.skipIf(!HAS_BROWSER)(
"bakes a volume envelope on the file's own clock across window boundaries",
async () => {