mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(core): pitch shift — a granular shifter as the fifth FX worklet
Adds hf-pitchshift alongside the four existing dynamics worklets: a dual-tap granular delay line, 100 ms grain, taps 180° apart so one is always crossfading in as the other resets — hides the splice each tap makes on wrap. Read-tap speed relative to the write head tracks the semitone ratio, so pitch shifts without changing duration. Registered through the same workletBuilder/dispose-message path the other four use (so shapeOf never rebuilds on a param tweak, and a chain drop retires it), wired into the registry with a plain-language copy entry and a ~0.2s chain tail (two grains). One implementation, shared by preview (Web Audio in the page) and render (the same worklet run inside an OfflineAudioContext in the headless browser) — confirmed by a browser-render test that measures the actual output frequency, not just that it differs from input. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
58cb979f4d
commit
8e506b7c50
@@ -80,6 +80,24 @@ const rms = (s: Float32Array): number =>
|
||||
Math.sqrt(s.reduce((a, x) => a + x * x, 0) / Math.max(1, s.length));
|
||||
const db = (x: number): number => 20 * Math.log10(x + 1e-30);
|
||||
|
||||
/**
|
||||
* Rising zero-crossings per second, over `[from, to)` seconds.
|
||||
*
|
||||
* `to` matters as much as `from`: a chain with a tail (reverb, delay,
|
||||
* pitchshift) renders extra silence/decay past the input's own end, and
|
||||
* averaging crossings over that stretch too dilutes the estimate toward zero
|
||||
* — measure only the steady, driven portion.
|
||||
*/
|
||||
function estimateFreq(s: Float32Array, sampleRate: number, from = 0.05, to?: number): number {
|
||||
const start = Math.round(from * sampleRate);
|
||||
const end = to === undefined ? s.length : Math.min(s.length, Math.round(to * sampleRate));
|
||||
let crossings = 0;
|
||||
for (let i = start + 1; i < end; i++) {
|
||||
if ((s[i - 1] ?? 0) < 0 && (s[i] ?? 0) >= 0) crossings++;
|
||||
}
|
||||
return crossings / ((end - start) / sampleRate);
|
||||
}
|
||||
|
||||
describe("readWav / writeWav", () => {
|
||||
it("round-trips samples as 16-bit PCM, the format the volume bake requires", () => {
|
||||
const p = join(dir, "rt.wav");
|
||||
@@ -244,6 +262,33 @@ describe.skipIf(!HAS_BROWSER)("browser render", () => {
|
||||
expect(db(rms(readWav(outPath).samples))).toBeLessThan(db(rms(readWav(input).samples)) - 3);
|
||||
}, 180_000);
|
||||
|
||||
it("shifts pitch up an octave, matching the preview worklet", async () => {
|
||||
const input = join(dir, "in.wav");
|
||||
tone(input, 0.5, 220);
|
||||
const outPath = join(dir, "out.wav");
|
||||
await applyAudioFxChain(
|
||||
input,
|
||||
{
|
||||
version: 1,
|
||||
nodes: [
|
||||
{
|
||||
type: "pitchshift",
|
||||
enabled: true,
|
||||
params: { ...defaultAudioFxParams("pitchshift"), semitones: 12, mix: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
outPath,
|
||||
{ trackId: "t" },
|
||||
);
|
||||
// Measure only the driven portion — chainTailSeconds appends ~0.2s of
|
||||
// decaying tail past the clip's own 0.5s, and averaging crossings over
|
||||
// that too dilutes the estimate.
|
||||
const freq = estimateFreq(readWav(outPath).samples, SR, 0.05, 0.45);
|
||||
expect(freq).toBeGreaterThan(220 * 1.7);
|
||||
expect(freq).toBeLessThan(220 * 2.3);
|
||||
}, 180_000);
|
||||
|
||||
it("sweeps a filter across the clip when a lane automates it", async () => {
|
||||
// A 2 kHz tone under a lowpass whose cutoff rises from below it to well
|
||||
// above: the start should be attenuated and the end should not. This is
|
||||
|
||||
Reference in New Issue
Block a user