diff --git a/packages/core/src/audio/audioFxWorklets.test.ts b/packages/core/src/audio/audioFxWorklets.test.ts index c6353f9cae..c246adf4c0 100644 --- a/packages/core/src/audio/audioFxWorklets.test.ts +++ b/packages/core/src/audio/audioFxWorklets.test.ts @@ -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; diff --git a/packages/core/src/audio/audioFxWorklets.ts b/packages/core/src/audio/audioFxWorklets.ts index fba4d3de12..d3056567c6 100644 --- a/packages/core/src/audio/audioFxWorklets.ts +++ b/packages/core/src/audio/audioFxWorklets.ts @@ -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++) { diff --git a/packages/core/stubs/audio-fx-runtime-entry.ts b/packages/core/stubs/audio-fx-runtime-entry.ts index 4bd56f9d5a..0df9511eec 100644 --- a/packages/core/stubs/audio-fx-runtime-entry.ts +++ b/packages/core/stubs/audio-fx-runtime-entry.ts @@ -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); diff --git a/packages/engine/src/services/audioFxRender.test.ts b/packages/engine/src/services/audioFxRender.test.ts index eec49fb363..488c5e0a98 100644 --- a/packages/engine/src/services/audioFxRender.test.ts +++ b/packages/engine/src/services/audioFxRender.test.ts @@ -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 () => {