@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.
Files changed (2) hide show
  1. package/index.js +167 -0
  2. 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
+ }