@audio/shift-hybrid 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +206 -0
  2. package/package.json +44 -0
package/index.js ADDED
@@ -0,0 +1,206 @@
1
+ import { stft } from 'fourier-transform/stft'
2
+ import phaseLock from '@audio/shift-pvoc-lock'
3
+ import wsola from '@audio/shift-wsola'
4
+ import { makePitchShift, resolvePitchParams, bufferedStream } from '@audio/shift-core'
5
+
6
+ // Hybrid pitch shifter. Runs two canonical engines in parallel and crossfades between them
7
+ // sample-by-sample, driven by a per-sample transient confidence signal:
8
+ //
9
+ // out[i] = (1 - τ[i]) · phaseLock(input)[i] + τ[i] · wsola(input, aligned)[i]
10
+ //
11
+ // Where τ[i] is derived from spectral-flux transient detection on the input. On sustained
12
+ // tonal material τ→0 and the output is purely phase-vocoded. On attacks τ→1 and the output
13
+ // is purely WSOLA, whose time-domain similarity search preserves transient shape.
14
+ //
15
+ // Canonical motivation: no single domain wins everywhere. Frequency-domain methods smear
16
+ // transients; time-domain methods mistrack tonal phase. Running both and letting the input
17
+ // decide where each is trusted is the simplest principled combination — provided the two
18
+ // engines' notion of "now" actually agrees (`estimateLag`/`alignTd`) and actually resembles
19
+ // each other once aligned (`alignmentTrust`) before either is allowed to outweigh phaseLock.
20
+
21
+ // Spectral flux, energy-domain half-wave-rectified and normalized by frame energy — the same
22
+ // shape shift-transient's onset detector uses (see packages/shift-transient/index.js), copied
23
+ // locally rather than imported since transient exposes no reusable export for it (consolidate
24
+ // later if one appears). `nFlux` is a bounded ratio (energy that appeared / total energy), not
25
+ // a raw magnitude sum, so a fixed floor on it is physically meaningful across signals — this
26
+ // is what makes the detector robust where a bare EMA z-score on an unbounded flux magnitude is
27
+ // not: a smooth tremolo/vibrato swell tops out at a low, predictable nFlux (measured ≈0.17 at
28
+ // this frameSize/hop for a full-scale 5 Hz/60%-depth AM sine) while genuine onsets reach
29
+ // several times that, so `threshold × max(0.3, std)` separates them without per-material tuning.
30
+ function transientConfidence(data, opts) {
31
+ let N = 1024, hop = N >> 2, half = N >> 1
32
+ if (data.length < 64) return new Float32Array(data.length)
33
+ let frames = stft(data, { frameSize: N, hopSize: hop })
34
+ if (frames.length < 2) return new Float32Array(data.length)
35
+
36
+ let threshold = opts?.hybridThreshold ?? 0.8
37
+ let raw = new Float32Array(frames.length)
38
+ let fluxMean = 0, fluxVar = 0, energyMean = 0, postFrames = 0
39
+
40
+ for (let i = 1; i < frames.length; i++) {
41
+ // `stft` zero-pads a full frame on each side, so early frames' windows still overlap
42
+ // that padding: the resulting zero→signal edge is itself a spectral discontinuity,
43
+ // indistinguishable from a real onset — skip flux/statistics there, exactly like
44
+ // shift-transient's own `frameStart < 0` boundary guard.
45
+ if (frames[i].time - half < 0) continue
46
+ let mag = frames[i].mag, prevMag = frames[i - 1].mag
47
+ let flux = 0, energy = 0
48
+ for (let k = 0; k < mag.length; k++) {
49
+ let m2 = mag[k] * mag[k], p2 = prevMag[k] * prevMag[k]
50
+ if (m2 > p2) flux += m2 - p2
51
+ energy += m2
52
+ }
53
+ let nFlux = energy > 1e-12 ? flux / energy : 0
54
+ let floor = threshold * Math.max(0.3, Math.sqrt(fluxVar))
55
+ // A decay/release looks like falling energy, never rising flux above baseline — gating
56
+ // on `rising` keeps a tail from ever being mistaken for an onset.
57
+ if (postFrames > 3 && energy >= energyMean * 0.7) {
58
+ let excess = nFlux - fluxMean - floor
59
+ if (excess > 0) raw[i] = Math.min(1, excess / (floor + 1e-6))
60
+ }
61
+ let alpha = raw[i] > 0 ? 0.25 : 0.1
62
+ let delta = nFlux - fluxMean
63
+ fluxMean += alpha * delta
64
+ fluxVar = (1 - alpha) * (fluxVar + alpha * delta * delta)
65
+ energyMean += alpha * (energy - energyMean)
66
+ postFrames++
67
+ }
68
+
69
+ // Sample-and-hold, not interpolation: a single-frame flux spike must reach its full frame
70
+ // value for the whole hop it covers, not get averaged down toward the next (lower) frame's
71
+ // value halfway through — that averaging was clipping a genuine onset's peak confidence
72
+ // before the attack/release follower below ever saw it.
73
+ let perSample = new Float32Array(data.length)
74
+ for (let i = 0; i < data.length; i++) perSample[i] = raw[Math.min(raw.length - 1, (i / hop) | 0)]
75
+
76
+ // Attack/release envelope follower smooths the frame-rate steps into a click-free crossfade
77
+ // and widens transients so the WSOLA grain covers the whole attack.
78
+ let sr = opts?.sampleRate || 44100
79
+ let ca = 1 - Math.exp(-1 / (0.002 * sr))
80
+ let cr = 1 - Math.exp(-1 / (0.040 * sr))
81
+ let env = 0
82
+ for (let i = 0; i < perSample.length; i++) {
83
+ let x = perSample[i]
84
+ env += (x > env ? ca : cr) * (x - env)
85
+ perSample[i] = env
86
+ }
87
+ return perSample
88
+ }
89
+
90
+ // Samples where WSOLA will actually be blended in (expanded by ±margin) — restricting both
91
+ // the lag search and the trust check (below) to this region both bounds their cost on long
92
+ // buffers and keeps them numerically sound: normalized correlation over mostly-silent or
93
+ // steady stretches is dominated by whichever value happens to catch the least noise, not the
94
+ // signal that's actually there.
95
+ function activeRanges(conf, floor, margin, n) {
96
+ let include = new Uint8Array(n)
97
+ let any = false
98
+ for (let i = 0; i < n; i++) {
99
+ if (conf[i] <= floor) continue
100
+ any = true
101
+ let lo = Math.max(0, i - margin), hi = Math.min(n, i + margin + 1)
102
+ for (let j = lo; j < hi; j++) include[j] = 1
103
+ }
104
+ if (!any) return []
105
+ let ranges = []
106
+ for (let i = 0; i < n;) {
107
+ if (!include[i]) { i++; continue }
108
+ let start = i
109
+ while (i < n && include[i]) i++
110
+ ranges.push([start, i])
111
+ }
112
+ return ranges
113
+ }
114
+
115
+ // WSOLA's correlation search displaces its grain read-position near non-stationary content,
116
+ // giving it a net reconstruction delay phaseLock (near-zero group delay by construction)
117
+ // doesn't share — left uncompensated, the crossfade blends phaseLock's on-time attack against
118
+ // WSOLA's late one, suppressing the true attack and injecting a phantom echo where WSOLA's
119
+ // delayed copy lands. Estimate that delay as a single global lag via cross-correlation over
120
+ // `ranges` and compensate before blending. Returns a sample count; positive means `td` lags `pv`.
121
+ function estimateLag(pv, td, ranges, maxLag) {
122
+ if (!ranges.length) return 0
123
+ let n = pv.length
124
+ let bestLag = 0, bestScore = -Infinity
125
+ for (let lag = -maxLag; lag <= maxLag; lag++) {
126
+ let dot = 0, na = 0, nb = 0
127
+ for (let [s, e] of ranges) {
128
+ let lo = Math.max(s, -lag), hi = Math.min(e, n - lag)
129
+ for (let k = lo; k < hi; k++) {
130
+ let a = pv[k], b = td[k + lag]
131
+ dot += a * b; na += a * a; nb += b * b
132
+ }
133
+ }
134
+ let score = dot / Math.sqrt(Math.max(1e-12, na * nb))
135
+ if (score > bestScore) { bestScore = score; bestLag = lag }
136
+ }
137
+ return bestLag
138
+ }
139
+
140
+ // Shift `td` earlier by `lag` samples (a single constant compensation, applied whole-signal)
141
+ // so its reconstruction of a transient lines up with phaseLock's before blending.
142
+ function alignTd(td, lag) {
143
+ if (!lag) return td
144
+ let n = td.length
145
+ let out = new Float32Array(n)
146
+ for (let i = 0; i < n; i++) {
147
+ let j = i + lag
148
+ out[i] = j < 0 ? 0 : td[Math.min(n - 1, j)]
149
+ }
150
+ return out
151
+ }
152
+
153
+ // A single global lag can't correct every onset in a busy, multi-instrument passage equally
154
+ // well (a kick and a hi-hat rarely share WSOLA's exact correlation-search behavior) — measure
155
+ // how much of tdAligned's variance in the actually-blended samples is explained by pv (R²,
156
+ // restricted to `conf`-active samples themselves, not the wider ±maxLag search margin, whose
157
+ // silence/steady padding would understate genuine agreement). Below R=0.5 the two reconstructions
158
+ // no longer share a majority of their variation, so the alignment is disagreement, not delay —
159
+ // trust is 0 and the blend falls back to phaseLock exactly, never a worse-than-either compromise.
160
+ function alignmentTrust(pv, tdAligned, conf, floor) {
161
+ let dot = 0, na = 0, nb = 0, count = 0
162
+ for (let i = 0; i < pv.length; i++) {
163
+ if (conf[i] <= floor) continue
164
+ let a = pv[i], b = tdAligned[i]
165
+ dot += a * b; na += a * a; nb += b * b; count++
166
+ }
167
+ if (count < 10 || na < 1e-9 || nb < 1e-9) return 1 // too little evidence to distrust
168
+ let r = dot / Math.sqrt(na * nb)
169
+ return r > 0.5 ? r * r : 0
170
+ }
171
+
172
+ function hybridBatch(data, opts) {
173
+ resolvePitchParams(opts) // validate early — wsola rejects variable ratio, catch it here with a clear message
174
+ let pv = phaseLock(data, opts)
175
+ let td = wsola(data, opts)
176
+ let conf = transientConfidence(data, opts)
177
+
178
+ // Mirrors wsola's own deriveOpts so the search bound tracks whatever tolerance it's
179
+ // actually configured with; ×1.5 covers the observed delay (which runs somewhat above
180
+ // the nudge bound itself), capped so a caller-supplied extreme tolerance can't blow up
181
+ // the search cost.
182
+ let frameSize = opts?.frameSize ?? 2048
183
+ let delta = opts?.tolerance ?? (frameSize >> 2)
184
+ let maxLag = Math.min(4096, Math.max(64, Math.round(delta * 1.5)))
185
+ let confFloor = 0.05
186
+ let ranges = activeRanges(conf, confFloor, maxLag, data.length)
187
+ let tdAligned = alignTd(td, estimateLag(pv, td, ranges, maxLag))
188
+ let trust = alignmentTrust(pv, tdAligned, conf, confFloor)
189
+
190
+ // Linear (equal-gain) crossfade, not constant-power: pv/tdAligned are two reconstructions
191
+ // of the *same* source, not independent signals, so they're correlated rather than
192
+ // decorrelated. Measured by RMS sweep on steady aligned content — linear stays flat (no dip)
193
+ // while constant-power overshoots by up to +40% at the midpoint, exactly the artifact
194
+ // constant-power is meant to prevent, in reverse, when the two inputs already agree in phase.
195
+ let out = new Float32Array(data.length)
196
+ for (let i = 0; i < data.length; i++) {
197
+ let t = conf[i] * trust
198
+ out[i] = (1 - t) * pv[i] + t * tdAligned[i]
199
+ }
200
+ return out
201
+ }
202
+
203
+ // Two engines + crossfade are inherently non-causal — buffer input and batch on flush.
204
+ let hybridStream = (opts) => bufferedStream(hybridBatch, opts)
205
+
206
+ export default makePitchShift(hybridBatch, hybridStream)
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@audio/shift-hybrid",
3
+ "version": "1.0.0",
4
+ "description": "Hybrid pitch shift — runs phase-lock + WSOLA in parallel, crossfades by transient confidence",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "index.js"
14
+ ],
15
+ "dependencies": {
16
+ "@audio/shift-core": "^1.0.0",
17
+ "@audio/shift-pvoc-lock": "^1.0.0",
18
+ "@audio/shift-wsola": "^1.0.0",
19
+ "fourier-transform": "^2.3.0"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "license": "MIT",
25
+ "author": "audiojs",
26
+ "homepage": "https://github.com/audiojs/shift/tree/main/packages/shift-hybrid#readme",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/audiojs/shift.git",
30
+ "directory": "packages/shift-hybrid"
31
+ },
32
+ "keywords": [
33
+ "audio",
34
+ "pitch-shift",
35
+ "hybrid",
36
+ "phase-vocoder",
37
+ "wsola",
38
+ "transient",
39
+ "dsp"
40
+ ],
41
+ "engines": {
42
+ "node": ">=18"
43
+ }
44
+ }