mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 21:29:34 +00:00
fix(core): reverb level, phaser wiring, per-channel dynamics, one-pole rebuild
Four defects in the graph builders, all found by review rather than by ear. **Reverb was unusable at its own defaults.** A ConvolverNode applies the impulse's gain whole — the graph sets `normalize = false` so a room is deterministic rather than browser-defined — but the impulse was raw decaying noise. Measured L2 at the registry default (size 0.7 / damping 0.5): 46.4, or +33.3 dB, putting the wet path ~24 dB over dry at the default `wet: 0.35`. It is now normalised to unit energy, so the wet knob means what it says. Preview and render both convolve this buffer, so they stayed identical throughout — equally deafening before, equally correct now. **Phaser in_gain/out_gain trim the signal entering and leaving the effect**, not a wet/dry pair. Wired to the wet and dry legs, "Input" muted the dry path and the two defaults summed to 1.14, so inserting a phaser raised the track level. They are now input and output trims with the legs summed at unity. Its declared waveform is also honoured: `lfo.type` was never assigned, so the default "Triangular" was silently a sine. **The dynamics worklets held one envelope across a channel-major loop.** The followers advance per sample, so on stereo a 20 ms attack behaved as 10 ms, and the right channel's gain came from an envelope that had already traversed the left — the two ducked differently from the same input and the image pumped. State is now per channel, as is the gate's smoothed gain and bitcrush's sample-hold counter, which previously advanced only on the last channel and left every earlier one frozen for a whole quantum. The gate also honours the knee it declares instead of chattering on material sitting at the threshold. **A one-pole filter's cutoff was swallowed in preview.** Its coefficients are fixed at construction, so `update` cannot push a new frequency — but the shape signature carried only type and pole count, so a cutoff change looked like a values-only edit and went into a no-op updater. Preview kept filtering at the old frequency while the render used the new one: a preview/render divergence in exactly the two effects that do not use a BiquadFilterNode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f59612850d
commit
c3291f2912
@@ -146,6 +146,17 @@
|
|||||||
"file": "packages/studio/src/utils/studioHelpers.ts",
|
"file": "packages/studio/src/utils/studioHelpers.ts",
|
||||||
"exports": ["resolveDroppedAssetDimensions"],
|
"exports": ["resolveDroppedAssetDimensions"],
|
||||||
},
|
},
|
||||||
|
// Audio FX worklets sit near the bottom of the audio stack: the worklet
|
||||||
|
// source and its test reset are consumed by the runtime and engine PRs
|
||||||
|
// upstack, so a per-PR audit against the merge base sees them as unused.
|
||||||
|
{
|
||||||
|
"file": "packages/core/src/audio/audioFxWorklets.ts",
|
||||||
|
"exports": ["AUDIO_FX_WORKLET_SOURCE", "__resetAudioFxWorkletsForTests"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "packages/core/src/audio/audioFxGraph.ts",
|
||||||
|
"exports": ["ensureAudioFxWorklets"],
|
||||||
|
},
|
||||||
// drawElementService is the bottom of the fast-capture Graphite stack
|
// drawElementService is the bottom of the fast-capture Graphite stack
|
||||||
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
|
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
|
||||||
// a per-PR audit diffing against the merge base sees these exports as
|
// a per-PR audit diffing against the merge base sees these exports as
|
||||||
|
|||||||
@@ -274,3 +274,70 @@ describe("synthesizeReverbImpulse", () => {
|
|||||||
expect(Math.max(...Array.from(tail, Math.abs))).toBeLessThan(0.01);
|
expect(Math.max(...Array.from(tail, Math.abs))).toBeLessThan(0.01);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("levels and per-channel state", () => {
|
||||||
|
const energy = (ir: Float32Array): number => {
|
||||||
|
let e = 0;
|
||||||
|
for (const v of ir) e += v * v;
|
||||||
|
return Math.sqrt(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("normalises the reverb impulse, so adding one does not clip the mix", () => {
|
||||||
|
// A ConvolverNode applies the impulse's gain whole (normalize is off so the
|
||||||
|
// room is deterministic). Raw decaying noise ran to +33 dB at the registry
|
||||||
|
// default, which put the wet path ~24 dB over dry at wet: 0.35.
|
||||||
|
for (const [size, damping] of [
|
||||||
|
[0.7, 0.5],
|
||||||
|
[1, 0],
|
||||||
|
[0, 1],
|
||||||
|
[0.5, 0.5],
|
||||||
|
]) {
|
||||||
|
expect(energy(synthesizeReverbImpulse(48000, size, damping))).toBeCloseTo(1, 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a phaser at unity, rather than summing its two gains above it", () => {
|
||||||
|
// in_gain/out_gain trim what enters and leaves the effect; wired as a wet/dry
|
||||||
|
// pair their defaults summed to 1.14, so inserting a phaser raised the level.
|
||||||
|
const ctx = new FakeCtx();
|
||||||
|
buildFxNode(ctx as unknown as BaseAudioContext, "phaser", defaultAudioFxParams("phaser"));
|
||||||
|
const gains = ctx.created.filter((n) => n.kind === "gain").map((n) => n.gain.value);
|
||||||
|
// The wet and dry legs are summed at unity; the trims carry the knob values.
|
||||||
|
expect(gains.filter((g) => g === 1).length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets the phaser LFO waveform it declares", () => {
|
||||||
|
const ctx = new FakeCtx();
|
||||||
|
buildFxNode(ctx as unknown as BaseAudioContext, "phaser", {
|
||||||
|
...defaultAudioFxParams("phaser"),
|
||||||
|
type: "0",
|
||||||
|
});
|
||||||
|
const osc = ctx.created.find((n) => n.kind === "osc");
|
||||||
|
expect(osc?.type).toBe("triangle");
|
||||||
|
const ctx2 = new FakeCtx();
|
||||||
|
buildFxNode(ctx2 as unknown as BaseAudioContext, "phaser", {
|
||||||
|
...defaultAudioFxParams("phaser"),
|
||||||
|
type: "1",
|
||||||
|
});
|
||||||
|
expect(ctx2.created.find((n) => n.kind === "osc")?.type).toBe("sine");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds a one-pole filter when its cutoff moves", () => {
|
||||||
|
// Its coefficients are fixed at construction, so a cutoff change cannot be
|
||||||
|
// pushed into the running graph — preview kept filtering at the old
|
||||||
|
// frequency while the render used the new one.
|
||||||
|
const ctx = new FakeCtx() as unknown as BaseAudioContext;
|
||||||
|
const chain = (frequency: number): HfAudioFxChain => ({
|
||||||
|
version: 1,
|
||||||
|
nodes: [{ type: "highpass", enabled: true, params: { frequency, q: 0.707, poles: "1" } }],
|
||||||
|
});
|
||||||
|
const built = buildFxChain(ctx, chain(300));
|
||||||
|
expect(built.update(chain(2000))).toBe(false);
|
||||||
|
// A two-pole filter is a BiquadFilterNode and still updates in place.
|
||||||
|
const twoPole = (frequency: number): HfAudioFxChain => ({
|
||||||
|
version: 1,
|
||||||
|
nodes: [{ type: "highpass", enabled: true, params: { frequency, q: 0.707, poles: "2" } }],
|
||||||
|
});
|
||||||
|
expect(buildFxChain(ctx, twoPole(300)).update(twoPole(2000))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,9 +38,22 @@ export function synthesizeReverbImpulse(
|
|||||||
};
|
};
|
||||||
const cutoff = Math.max(0.001, 1 - Math.max(0, Math.min(1, damping)));
|
const cutoff = Math.max(0.001, 1 - Math.max(0, Math.min(1, damping)));
|
||||||
let lp = 0;
|
let lp = 0;
|
||||||
|
let energy = 0;
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
lp += cutoff * (rand() - lp);
|
lp += cutoff * (rand() - lp);
|
||||||
out[i] = lp * Math.pow(1 - i / length, 2.5);
|
const sample = lp * Math.pow(1 - i / length, 2.5);
|
||||||
|
out[i] = sample;
|
||||||
|
energy += sample * sample;
|
||||||
|
}
|
||||||
|
// Scale to unit energy. A ConvolverNode applies the impulse's gain whole (the
|
||||||
|
// graph sets `normalize = false` so the room is deterministic rather than
|
||||||
|
// browser-defined), and this impulse is decaying noise whose raw energy runs
|
||||||
|
// to +33 dB at the default size — loud enough that simply adding a Reverb
|
||||||
|
// clipped the mix. Normalising here keeps the wet knob meaning what it says
|
||||||
|
// and keeps preview and render identical, since both convolve this buffer.
|
||||||
|
const norm = Math.sqrt(energy);
|
||||||
|
if (norm > 0) {
|
||||||
|
for (let i = 0; i < length; i++) out[i] = (out[i] ?? 0) / norm;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -216,12 +229,19 @@ const PHASER_STAGES = 6;
|
|||||||
const allpassPhaser: Builder = (ctx, p) => {
|
const allpassPhaser: Builder = (ctx, p) => {
|
||||||
const input = ctx.createGain();
|
const input = ctx.createGain();
|
||||||
const out = ctx.createGain();
|
const out = ctx.createGain();
|
||||||
|
// aphaser's in_gain/out_gain trim the signal entering and leaving the effect.
|
||||||
|
// Wiring them to the wet and dry legs instead made "Input" mute the dry path
|
||||||
|
// and let the two defaults sum above unity, so inserting a phaser raised the
|
||||||
|
// track level.
|
||||||
|
const inTrim = ctx.createGain();
|
||||||
|
const outTrim = ctx.createGain();
|
||||||
const lfo = ctx.createOscillator();
|
const lfo = ctx.createOscillator();
|
||||||
const depth = ctx.createGain();
|
const depth = ctx.createGain();
|
||||||
const wet = ctx.createGain();
|
const wet = ctx.createGain();
|
||||||
const dry = ctx.createGain();
|
const dry = ctx.createGain();
|
||||||
const stages: BiquadFilterNode[] = [];
|
const stages: BiquadFilterNode[] = [];
|
||||||
let node: AudioNode = input;
|
input.connect(inTrim);
|
||||||
|
let node: AudioNode = inTrim;
|
||||||
for (let i = 0; i < PHASER_STAGES; i++) {
|
for (let i = 0; i < PHASER_STAGES; i++) {
|
||||||
const ap = ctx.createBiquadFilter();
|
const ap = ctx.createBiquadFilter();
|
||||||
ap.type = "allpass";
|
ap.type = "allpass";
|
||||||
@@ -232,9 +252,14 @@ const allpassPhaser: Builder = (ctx, p) => {
|
|||||||
stages.push(ap);
|
stages.push(ap);
|
||||||
}
|
}
|
||||||
lfo.connect(depth);
|
lfo.connect(depth);
|
||||||
|
// aphaser's type 0 is triangular, 1 sinusoidal. The builder never set this, so
|
||||||
|
// the declared default ("Triangular") was silently a sine. An OscillatorNode
|
||||||
|
// has no triangle-with-the-same-phase primitive to switch to, so triangle is
|
||||||
|
// the node's own "triangle" type.
|
||||||
lfo.start();
|
lfo.start();
|
||||||
node.connect(wet).connect(out);
|
node.connect(wet).connect(outTrim);
|
||||||
input.connect(dry).connect(out);
|
inTrim.connect(dry).connect(outTrim);
|
||||||
|
outTrim.connect(out);
|
||||||
const apply = (v: HfAudioFxParamValues): void => {
|
const apply = (v: HfAudioFxParamValues): void => {
|
||||||
// aphaser sweeps around a centre derived from its delay; mirror the range
|
// aphaser sweeps around a centre derived from its delay; mirror the range
|
||||||
// rather than the exact curve, and let the parity harness score it.
|
// rather than the exact curve, and let the parity harness score it.
|
||||||
@@ -242,8 +267,12 @@ const allpassPhaser: Builder = (ctx, p) => {
|
|||||||
for (const ap of stages) ap.frequency.value = centre;
|
for (const ap of stages) ap.frequency.value = centre;
|
||||||
depth.gain.value = centre * n(v.decay);
|
depth.gain.value = centre * n(v.decay);
|
||||||
lfo.frequency.value = n(v.speed);
|
lfo.frequency.value = n(v.speed);
|
||||||
wet.gain.value = n(v.out_gain);
|
lfo.type = String(v.type) === "1" ? "sine" : "triangle";
|
||||||
dry.gain.value = n(v.in_gain);
|
inTrim.gain.value = n(v.in_gain);
|
||||||
|
outTrim.gain.value = n(v.out_gain);
|
||||||
|
// Summed at unity: the sweep is the effect, not a blend control.
|
||||||
|
wet.gain.value = 1;
|
||||||
|
dry.gain.value = 1;
|
||||||
};
|
};
|
||||||
apply(p);
|
apply(p);
|
||||||
return {
|
return {
|
||||||
@@ -256,7 +285,7 @@ const allpassPhaser: Builder = (ctx, p) => {
|
|||||||
} catch {
|
} catch {
|
||||||
/* already stopped */
|
/* already stopped */
|
||||||
}
|
}
|
||||||
[input, out, depth, wet, dry, ...stages].forEach((x) => x.disconnect());
|
[input, out, inTrim, outTrim, depth, wet, dry, ...stages].forEach((x) => x.disconnect());
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -351,7 +380,13 @@ function shapeOf(chain: HfAudioFxChain): string {
|
|||||||
.map((node) => {
|
.map((node) => {
|
||||||
const p = normalizeAudioFxParams(node.type, node.params);
|
const p = normalizeAudioFxParams(node.type, node.params);
|
||||||
const poles = p.poles !== undefined ? `:${p.poles}` : "";
|
const poles = p.poles !== undefined ? `:${p.poles}` : "";
|
||||||
return `${node.type}${poles}`;
|
// A one-pole filter is an IIRFilterNode whose coefficients are fixed at
|
||||||
|
// construction, so its cutoff cannot be pushed into the running graph.
|
||||||
|
// Carrying the frequency here makes a cutoff change rebuild instead of
|
||||||
|
// being pushed into a no-op updater — which is what let preview keep
|
||||||
|
// filtering at the old frequency while the render used the new one.
|
||||||
|
const fixedFreq = String(p.poles) === "1" ? `@${p.frequency}` : "";
|
||||||
|
return `${node.type}${poles}${fixedFreq}`;
|
||||||
})
|
})
|
||||||
.join("|");
|
.join("|");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,18 +12,28 @@
|
|||||||
export const AUDIO_FX_WORKLET_SOURCE = `
|
export const AUDIO_FX_WORKLET_SOURCE = `
|
||||||
const dbToLin = (db) => Math.pow(10, db / 20);
|
const dbToLin = (db) => Math.pow(10, db / 20);
|
||||||
|
|
||||||
/** One-pole envelope follower shared by the dynamics processors. */
|
/**
|
||||||
class Env {
|
* One-pole envelope followers, one per channel.
|
||||||
constructor(attackMs, releaseMs) { this.set(attackMs, releaseMs); this.value = 0; }
|
*
|
||||||
|
* Per channel matters: the followers advance once per sample, so a single shared
|
||||||
|
* follower stepped once per channel per sample. On stereo that ran a 20 ms attack
|
||||||
|
* as 10 ms, and gave the right channel a gain computed from an envelope that had
|
||||||
|
* already traversed the left — so the two channels ducked by different amounts
|
||||||
|
* from the same input and the image pumped.
|
||||||
|
*/
|
||||||
|
class EnvBank {
|
||||||
|
constructor(attackMs, releaseMs) { this.set(attackMs, releaseMs); this.values = []; }
|
||||||
set(attackMs, releaseMs) {
|
set(attackMs, releaseMs) {
|
||||||
this.a = Math.exp(-1 / (sampleRate * Math.max(1e-5, attackMs / 1000)));
|
this.a = Math.exp(-1 / (sampleRate * Math.max(1e-5, attackMs / 1000)));
|
||||||
this.r = Math.exp(-1 / (sampleRate * Math.max(1e-5, releaseMs / 1000)));
|
this.r = Math.exp(-1 / (sampleRate * Math.max(1e-5, releaseMs / 1000)));
|
||||||
}
|
}
|
||||||
push(x) {
|
push(ch, x) {
|
||||||
|
const prev = this.values[ch] ?? 0;
|
||||||
const m = Math.abs(x);
|
const m = Math.abs(x);
|
||||||
const c = m > this.value ? this.a : this.r;
|
const c = m > prev ? this.a : this.r;
|
||||||
this.value = m + c * (this.value - m);
|
const next = m + c * (prev - m);
|
||||||
return this.value;
|
this.values[ch] = next;
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +51,7 @@ class HfCompressor extends AudioWorkletProcessor {
|
|||||||
constructor(o) {
|
constructor(o) {
|
||||||
super();
|
super();
|
||||||
this.p = o.processorOptions || {};
|
this.p = o.processorOptions || {};
|
||||||
this.env = new Env(this.p.attack ?? 20, this.p.release ?? 250);
|
this.env = new EnvBank(this.p.attack ?? 20, this.p.release ?? 250);
|
||||||
this.port.onmessage = (e) => {
|
this.port.onmessage = (e) => {
|
||||||
this.p = { ...this.p, ...e.data };
|
this.p = { ...this.p, ...e.data };
|
||||||
this.env.set(this.p.attack ?? 20, this.p.release ?? 250);
|
this.env.set(this.p.attack ?? 20, this.p.release ?? 250);
|
||||||
@@ -59,7 +69,7 @@ class HfCompressor extends AudioWorkletProcessor {
|
|||||||
const inp = i[ch], out = o[ch];
|
const inp = i[ch], out = o[ch];
|
||||||
for (let n = 0; n < inp.length; n++) {
|
for (let n = 0; n < inp.length; n++) {
|
||||||
const x = inp[n];
|
const x = inp[n];
|
||||||
const env = this.env.push(x);
|
const env = this.env.push(ch, x);
|
||||||
const envDb = env > 1e-9 ? 20 * Math.log10(env) : -200;
|
const envDb = env > 1e-9 ? 20 * Math.log10(env) : -200;
|
||||||
const g = dbToLin(kneeGain(envDb, p.threshold ?? -24, p.ratio ?? 4, kneeDb));
|
const g = dbToLin(kneeGain(envDb, p.threshold ?? -24, p.ratio ?? 4, kneeDb));
|
||||||
out[n] = x * g * makeup * mix + x * (1 - mix);
|
out[n] = x * g * makeup * mix + x * (1 - mix);
|
||||||
@@ -74,7 +84,7 @@ class HfLimiter extends AudioWorkletProcessor {
|
|||||||
constructor(o) {
|
constructor(o) {
|
||||||
super();
|
super();
|
||||||
this.p = o.processorOptions || {};
|
this.p = o.processorOptions || {};
|
||||||
this.env = new Env(this.p.attack ?? 5, this.p.release ?? 50);
|
this.env = new EnvBank(this.p.attack ?? 5, this.p.release ?? 50);
|
||||||
this.port.onmessage = (e) => {
|
this.port.onmessage = (e) => {
|
||||||
this.p = { ...this.p, ...e.data };
|
this.p = { ...this.p, ...e.data };
|
||||||
this.env.set(this.p.attack ?? 5, this.p.release ?? 50);
|
this.env.set(this.p.attack ?? 5, this.p.release ?? 50);
|
||||||
@@ -89,7 +99,7 @@ class HfLimiter extends AudioWorkletProcessor {
|
|||||||
const inp = i[ch], out = o[ch];
|
const inp = i[ch], out = o[ch];
|
||||||
for (let n = 0; n < inp.length; n++) {
|
for (let n = 0; n < inp.length; n++) {
|
||||||
const x = inp[n];
|
const x = inp[n];
|
||||||
const env = this.env.push(x);
|
const env = this.env.push(ch, x);
|
||||||
// Only ever attenuate: a limiter that can raise level is a compressor.
|
// Only ever attenuate: a limiter that can raise level is a compressor.
|
||||||
const g = env > ceiling ? ceiling / env : 1;
|
const g = env > ceiling ? ceiling / env : 1;
|
||||||
out[n] = x * g * outGain;
|
out[n] = x * g * outGain;
|
||||||
@@ -104,8 +114,8 @@ class HfGate extends AudioWorkletProcessor {
|
|||||||
constructor(o) {
|
constructor(o) {
|
||||||
super();
|
super();
|
||||||
this.p = o.processorOptions || {};
|
this.p = o.processorOptions || {};
|
||||||
this.env = new Env(this.p.attack ?? 1, this.p.release ?? 100);
|
this.env = new EnvBank(this.p.attack ?? 1, this.p.release ?? 100);
|
||||||
this.gain = 1;
|
this.gains = [];
|
||||||
this.port.onmessage = (e) => {
|
this.port.onmessage = (e) => {
|
||||||
this.p = { ...this.p, ...e.data };
|
this.p = { ...this.p, ...e.data };
|
||||||
this.env.set(this.p.attack ?? 1, this.p.release ?? 100);
|
this.env.set(this.p.attack ?? 1, this.p.release ?? 100);
|
||||||
@@ -118,11 +128,16 @@ class HfGate extends AudioWorkletProcessor {
|
|||||||
const threshold = dbToLin(p.threshold ?? -35);
|
const threshold = dbToLin(p.threshold ?? -35);
|
||||||
const floor = dbToLin(p.range ?? -24);
|
const floor = dbToLin(p.range ?? -24);
|
||||||
const ratio = p.ratio ?? 10;
|
const ratio = p.ratio ?? 10;
|
||||||
|
// Knee is declared in the registry as a ratio, like the compressor's; a
|
||||||
|
// hard threshold ignored it and chattered on material sitting right at the
|
||||||
|
// gate point.
|
||||||
|
const kneeDb = 20 * Math.log10(Math.max(1.0001, p.knee ?? 2.83));
|
||||||
|
const kneeLin = dbToLin((p.threshold ?? -35) + kneeDb);
|
||||||
for (let ch = 0; ch < i.length; ch++) {
|
for (let ch = 0; ch < i.length; ch++) {
|
||||||
const inp = i[ch], out = o[ch];
|
const inp = i[ch], out = o[ch];
|
||||||
for (let n = 0; n < inp.length; n++) {
|
for (let n = 0; n < inp.length; n++) {
|
||||||
const x = inp[n];
|
const x = inp[n];
|
||||||
const env = this.env.push(x);
|
const env = this.env.push(ch, x);
|
||||||
let target = 1;
|
let target = 1;
|
||||||
if (env < threshold) {
|
if (env < threshold) {
|
||||||
const under = env > 1e-9 ? threshold / env : 1e9;
|
const under = env > 1e-9 ? threshold / env : 1e9;
|
||||||
@@ -130,9 +145,11 @@ class HfGate extends AudioWorkletProcessor {
|
|||||||
if (!isFinite(target)) target = floor;
|
if (!isFinite(target)) target = floor;
|
||||||
}
|
}
|
||||||
// Smooth toward the target so the gate does not click on every sample.
|
// Smooth toward the target so the gate does not click on every sample.
|
||||||
const c = target < this.gain ? this.env.r : this.env.a;
|
const held = this.gains[ch] ?? 1;
|
||||||
this.gain = target + c * (this.gain - target);
|
const c = target < held ? this.env.r : this.env.a;
|
||||||
out[n] = x * this.gain;
|
const smoothed = target + c * (held - target);
|
||||||
|
this.gains[ch] = smoothed;
|
||||||
|
out[n] = x * smoothed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -144,7 +161,7 @@ class HfBitcrush extends AudioWorkletProcessor {
|
|||||||
constructor(o) {
|
constructor(o) {
|
||||||
super();
|
super();
|
||||||
this.p = o.processorOptions || {};
|
this.p = o.processorOptions || {};
|
||||||
this.hold = 0;
|
this.holds = [];
|
||||||
this.held = [];
|
this.held = [];
|
||||||
this.port.onmessage = (e) => { this.p = { ...this.p, ...e.data }; };
|
this.port.onmessage = (e) => { this.p = { ...this.p, ...e.data }; };
|
||||||
}
|
}
|
||||||
@@ -158,10 +175,14 @@ class HfBitcrush extends AudioWorkletProcessor {
|
|||||||
for (let ch = 0; ch < i.length; ch++) {
|
for (let ch = 0; ch < i.length; ch++) {
|
||||||
const inp = i[ch], out = o[ch];
|
const inp = i[ch], out = o[ch];
|
||||||
if (this.held[ch] === undefined) this.held[ch] = 0;
|
if (this.held[ch] === undefined) this.held[ch] = 0;
|
||||||
|
if (this.holds[ch] === undefined) this.holds[ch] = 0;
|
||||||
for (let n = 0; n < inp.length; n++) {
|
for (let n = 0; n < inp.length; n++) {
|
||||||
if (this.hold === 0) this.held[ch] = Math.round(inp[n] * levels) / levels;
|
// Per channel: advancing one shared counter only on the last channel
|
||||||
|
// left every earlier channel either unheld or frozen for a whole
|
||||||
|
// 128-sample quantum, clicking once a block.
|
||||||
|
if (this.holds[ch] === 0) this.held[ch] = Math.round(inp[n] * levels) / levels;
|
||||||
out[n] = this.held[ch] * mix + inp[n] * (1 - mix);
|
out[n] = this.held[ch] * mix + inp[n] * (1 - mix);
|
||||||
if (ch === i.length - 1) this.hold = (this.hold + 1) % step;
|
this.holds[ch] = (this.holds[ch] + 1) % step;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user