@audio/shift-hpss 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 +167 -0
- package/package.json +42 -0
package/index.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { fft, ifft } from 'fourier-transform'
|
|
2
|
+
import { winSqFloor } from '@audio/shift-core/stft'
|
|
3
|
+
import { PI2, hannWindow, makeFrameRatio, scatterGated, wrapPhase, makePitchShift, resolveRatio, bufferedStream } from '@audio/shift-core'
|
|
4
|
+
|
|
5
|
+
// Harmonic/Percussive Source Separation (Fitzgerald 2010) + per-component pitch shift.
|
|
6
|
+
//
|
|
7
|
+
// Canonical form:
|
|
8
|
+
// 1. STFT analysis → magnitude spectrogram |X|.
|
|
9
|
+
// 2. Time-axis median filter (per-bin, across frames) → Mh, a harmonic-friendly view.
|
|
10
|
+
// 3. Freq-axis median filter (per-frame, across bins) → Mp, a percussive-friendly view.
|
|
11
|
+
// 4. Soft Wiener-style mask at exponent `power`:
|
|
12
|
+
// Hk = Mh^p / (Mh^p + Mp^p), Pk = Mp^p / (Mh^p + Mp^p)
|
|
13
|
+
// 5. Harmonic component Xh = Hk · X is shifted with the peak-gated scatter (`scatterGated`,
|
|
14
|
+
// same energy-domain kernel shift-pvoc uses — identical pattern by construction).
|
|
15
|
+
// 6. Percussive component Xp = Pk · X passes through with its original phase, preserving
|
|
16
|
+
// attack localization exactly.
|
|
17
|
+
// 7. Output = iSTFT(Xh_shifted) + iSTFT(Xp).
|
|
18
|
+
//
|
|
19
|
+
// Transients survive unmoved; tonals shift in pitch. A purely harmonic signal behaves like
|
|
20
|
+
// the vocoder; a purely percussive signal passes through untouched. Loudness is preserved
|
|
21
|
+
// by construction (scatterGated's own energy conservation on Xh + untouched Xp), so no
|
|
22
|
+
// whole-signal matchGain correction is applied.
|
|
23
|
+
|
|
24
|
+
function medianSort(buf, len) {
|
|
25
|
+
let arr = buf.subarray(0, len)
|
|
26
|
+
// Insertion sort — small windows (~17), faster than generic sort on TypedArrays.
|
|
27
|
+
for (let i = 1; i < len; i++) {
|
|
28
|
+
let v = arr[i], j = i - 1
|
|
29
|
+
while (j >= 0 && arr[j] > v) { arr[j + 1] = arr[j]; j-- }
|
|
30
|
+
arr[j + 1] = v
|
|
31
|
+
}
|
|
32
|
+
let m = len >> 1
|
|
33
|
+
return len & 1 ? arr[m] : 0.5 * (arr[m - 1] + arr[m])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hpssBatch(data, opts) {
|
|
37
|
+
let { ratio, ratioFn } = resolveRatio(opts)
|
|
38
|
+
let N = opts?.frameSize ?? 2048
|
|
39
|
+
let hop = opts?.hopSize ?? (N >> 2)
|
|
40
|
+
let half = N >> 1
|
|
41
|
+
let bins = half + 1
|
|
42
|
+
let kTime = opts?.hpssTimeWidth ?? 17
|
|
43
|
+
let kFreq = opts?.hpssFreqWidth ?? 17
|
|
44
|
+
if ((kTime & 1) === 0) kTime += 1
|
|
45
|
+
if ((kFreq & 1) === 0) kFreq += 1
|
|
46
|
+
let power = opts?.hpssPower ?? 2
|
|
47
|
+
let win = hannWindow(N)
|
|
48
|
+
let freqPerBin = PI2 / N
|
|
49
|
+
let sr = opts?.sampleRate || 44100
|
|
50
|
+
let fr = makeFrameRatio(ratioFn || ratio)
|
|
51
|
+
|
|
52
|
+
let pad = N
|
|
53
|
+
let padded = new Float32Array(data.length + pad * 2)
|
|
54
|
+
padded.set(data, pad)
|
|
55
|
+
let nFrames = Math.max(1, Math.floor((padded.length - N) / hop) + 1)
|
|
56
|
+
|
|
57
|
+
// Analyze all frames into flat mag/phase spectrogram buffers, frame-major (stride=bins).
|
|
58
|
+
let magM = new Float64Array(nFrames * bins)
|
|
59
|
+
let phM = new Float64Array(nFrames * bins)
|
|
60
|
+
let scratch = new Float64Array(N)
|
|
61
|
+
for (let f = 0; f < nFrames; f++) {
|
|
62
|
+
let pos = f * hop
|
|
63
|
+
for (let i = 0; i < N; i++) scratch[i] = (padded[pos + i] || 0) * win[i]
|
|
64
|
+
let [re, im] = fft(scratch)
|
|
65
|
+
let base = f * bins
|
|
66
|
+
for (let k = 0; k < bins; k++) {
|
|
67
|
+
magM[base + k] = Math.sqrt(re[k] * re[k] + im[k] * im[k])
|
|
68
|
+
phM[base + k] = Math.atan2(im[k], re[k])
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Time-axis median → harmonic estimate Mh.
|
|
73
|
+
let Mh = new Float64Array(nFrames * bins)
|
|
74
|
+
let rT = kTime >> 1
|
|
75
|
+
let colBuf = new Float64Array(kTime)
|
|
76
|
+
for (let k = 0; k < bins; k++) {
|
|
77
|
+
for (let f = 0; f < nFrames; f++) {
|
|
78
|
+
let c = 0
|
|
79
|
+
let a = Math.max(0, f - rT)
|
|
80
|
+
let b = Math.min(nFrames - 1, f + rT)
|
|
81
|
+
for (let g = a; g <= b; g++) colBuf[c++] = magM[g * bins + k]
|
|
82
|
+
Mh[f * bins + k] = medianSort(colBuf, c)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Freq-axis median → percussive estimate Mp.
|
|
87
|
+
let Mp = new Float64Array(nFrames * bins)
|
|
88
|
+
let rF = kFreq >> 1
|
|
89
|
+
let rowBuf = new Float64Array(kFreq)
|
|
90
|
+
for (let f = 0; f < nFrames; f++) {
|
|
91
|
+
let base = f * bins
|
|
92
|
+
for (let k = 0; k < bins; k++) {
|
|
93
|
+
let c = 0
|
|
94
|
+
let a = Math.max(0, k - rF)
|
|
95
|
+
let b = Math.min(half, k + rF)
|
|
96
|
+
for (let g = a; g <= b; g++) rowBuf[c++] = magM[base + g]
|
|
97
|
+
Mp[base + k] = medianSort(rowBuf, c)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Per-frame: split via soft mask, scatter-shift H, pass-through P, resynth combined.
|
|
102
|
+
let outPadded = new Float32Array(padded.length)
|
|
103
|
+
let norm = new Float32Array(padded.length)
|
|
104
|
+
let syn = new Float64Array(bins)
|
|
105
|
+
let newMag = new Float64Array(bins)
|
|
106
|
+
let newFreq = new Float64Array(bins)
|
|
107
|
+
let peakMag = new Float64Array(bins)
|
|
108
|
+
let hMag = new Float64Array(bins)
|
|
109
|
+
let pMag = new Float64Array(bins)
|
|
110
|
+
let re = new Float64Array(bins)
|
|
111
|
+
let im = new Float64Array(bins)
|
|
112
|
+
let ctx = { half, hop, freqPerBin, N }
|
|
113
|
+
|
|
114
|
+
for (let f = 0; f < nFrames; f++) {
|
|
115
|
+
let r = fr.at(f * hop - pad, sr)
|
|
116
|
+
let base = f * bins
|
|
117
|
+
|
|
118
|
+
for (let k = 0; k < bins; k++) {
|
|
119
|
+
let mh = Mh[base + k], mp = Mp[base + k]
|
|
120
|
+
let hp = power === 2 ? mh * mh : Math.pow(mh, power)
|
|
121
|
+
let pp = power === 2 ? mp * mp : Math.pow(mp, power)
|
|
122
|
+
let sum = hp + pp + 1e-12
|
|
123
|
+
let maskH = hp / sum
|
|
124
|
+
let m = magM[base + k]
|
|
125
|
+
hMag[k] = m * maskH
|
|
126
|
+
pMag[k] = m * (1 - maskH)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
newMag.fill(0)
|
|
130
|
+
newFreq.fill(0)
|
|
131
|
+
peakMag.fill(0)
|
|
132
|
+
|
|
133
|
+
let ph = phM.subarray(base, base + bins)
|
|
134
|
+
let prevPh = f > 0 ? phM.subarray(base - bins, base) : null
|
|
135
|
+
scatterGated(hMag, ph, prevPh, r, ctx, newMag, newFreq, peakMag)
|
|
136
|
+
|
|
137
|
+
for (let k = 0; k < bins; k++) syn[k] = wrapPhase(syn[k] + newFreq[k] * hop)
|
|
138
|
+
|
|
139
|
+
// Harmonic: scatter-shifted. Percussive: original phase, unshifted — attack
|
|
140
|
+
// localization is exact because no bin ever leaves its analysis position.
|
|
141
|
+
for (let k = 0; k < bins; k++) {
|
|
142
|
+
re[k] = newMag[k] * Math.cos(syn[k]) + pMag[k] * Math.cos(ph[k])
|
|
143
|
+
im[k] = newMag[k] * Math.sin(syn[k]) + pMag[k] * Math.sin(ph[k])
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let sf = ifft(re, im)
|
|
147
|
+
let pos = f * hop
|
|
148
|
+
for (let i = 0; i < N; i++) {
|
|
149
|
+
outPadded[pos + i] += sf[i] * win[i]
|
|
150
|
+
norm[pos + i] += win[i] * win[i]
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let result = new Float32Array(data.length)
|
|
155
|
+
let normFloor = winSqFloor(win, hop)
|
|
156
|
+
for (let i = 0; i < data.length; i++) {
|
|
157
|
+
let j = i + pad
|
|
158
|
+
let n = norm[j] < normFloor ? normFloor : norm[j]
|
|
159
|
+
result[i] = n > 1e-10 ? outPadded[j] / n : 0
|
|
160
|
+
}
|
|
161
|
+
return result
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Median filters need centered time windows — buffer input and run batch on flush.
|
|
165
|
+
let hpssStream = (opts) => bufferedStream(hpssBatch, opts)
|
|
166
|
+
|
|
167
|
+
export default makePitchShift(hpssBatch, hpssStream)
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/shift-hpss",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Harmonic/Percussive Source Separation pitch shift — shifts harmonics, preserves transients",
|
|
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
|
+
"fourier-transform": "^2.3.0"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "audiojs",
|
|
24
|
+
"homepage": "https://github.com/audiojs/shift/tree/main/packages/shift-hpss#readme",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/audiojs/shift.git",
|
|
28
|
+
"directory": "packages/shift-hpss"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"audio",
|
|
32
|
+
"pitch-shift",
|
|
33
|
+
"hpss",
|
|
34
|
+
"harmonic",
|
|
35
|
+
"percussive",
|
|
36
|
+
"fitzgerald",
|
|
37
|
+
"dsp"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
}
|
|
42
|
+
}
|