@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.
- package/index.js +206 -0
- 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
|
+
}
|