@audio/shift-sms 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 +185 -0
  2. package/package.json +41 -0
package/index.js ADDED
@@ -0,0 +1,185 @@
1
+ import { makeStftShift } from '@audio/shift-core/stft'
2
+ import { WIN_GAIN, findPeaks, makeFrameRatio, wrapPhase } from '@audio/shift-core'
3
+
4
+ // Spectral Modeling Synthesis (Serra/Smith) pitch shift.
5
+ // Decomposes each frame into sinusoidal peaks (partials) + stochastic residual.
6
+ // Partial frequencies are scaled by `ratio`; residual carries the leftover spectrum.
7
+ //
8
+ // Peaks: `findPeaks` (shared ±1 local-max scan, floor = max(1e-8, maxM·0.005)) locates
9
+ // candidates; each is parabolically refined for sub-bin frequency/magnitude (<2 cents on a
10
+ // well-separated sinusoid), then deposited at a single destination bin — the shifted
11
+ // instantaneous frequency's nearest bin. Colliding partials keep the louder one (a quieter
12
+ // contributor's frequency estimate would be masked anyway — same policy as shift-core's
13
+ // scatter kernels, just resolved by direct magnitude comparison instead of a pre-sort).
14
+ //
15
+ // Residual: every destination bin NOT claimed by a partial GATHERS its value from the
16
+ // residual spectrum at the inverse-mapped source position `k/ratio`, linearly interpolated
17
+ // between the two neighbouring residual bins (paulstretch's resample direction). A gather
18
+ // gives one write per destination bin by construction — bijective, hole-free — unlike a
19
+ // forward scatter at `Math.round(k·ratio)`, which is not a bijection for any ratio != 1
20
+ // (produces periodic comb holes when up-shifting, pileups when down-shifting).
21
+ //
22
+ // Loudness: per-frame energy renormalization (Σmag² in → Σmag² out) replaces whole-signal
23
+ // matchGain, so batch and stream reconstruct identically with no post-hoc correction.
24
+
25
+ // Parabolic (Jacobsen) sub-bin refinement — writes the refined peak magnitude into
26
+ // `refMag[k]` and the fractional bin position into `refFrac[k]` for each `peaks[i]`.
27
+ // Bin-indexed, caller-owned scratch (sized half+1); only entries at peak bins are touched.
28
+ function refinePeaks(mag, peaks, refMag, refFrac) {
29
+ for (let i = 0; i < peaks.length; i++) {
30
+ let k = peaks[i]
31
+ let ym1 = mag[k - 1], y0 = mag[k], yp1 = mag[k + 1]
32
+ let denom = ym1 - 2 * y0 + yp1
33
+ let delta = Math.abs(denom) < 1e-12 ? 0 : 0.5 * (ym1 - yp1) / denom
34
+ refFrac[k] = k + delta
35
+ refMag[k] = y0 - 0.25 * (ym1 - yp1) * delta
36
+ }
37
+ }
38
+
39
+ function process(mag, phase, state, ctx) {
40
+ if (!state.fr) state.fr = makeFrameRatio(ctx.ratioFn || ctx.ratio || 1)
41
+ let { half, hop, freqPerBin } = ctx
42
+ let ratio = state.fr.at(ctx.frameStart, ctx.sampleRate)
43
+ if (!state.prev) {
44
+ state.prev = new Float64Array(half + 1)
45
+ state.syn = new Float64Array(half + 1)
46
+ state.newMag = new Float64Array(half + 1)
47
+ state.newPhase = new Float64Array(half + 1)
48
+ state.residual = new Float64Array(half + 1)
49
+ state.owned = new Uint8Array(half + 1)
50
+ state.peakMag = new Float64Array(half + 1)
51
+ state.refMag = new Float64Array(half + 1)
52
+ state.refFrac = new Float64Array(half + 1)
53
+ state.sel = new Int32Array(half + 1)
54
+ // Tunables resolved once (makeStftShift's deriveOpts) — read here, not from the opts
55
+ // bag on every frame.
56
+ state.maxTracks = ctx.opts.maxTracks
57
+ state.minMag = ctx.opts.minMag
58
+ state.first = true
59
+ }
60
+ let { prev, syn, newMag, newPhase, residual, owned, peakMag, refMag, refFrac, sel, maxTracks, minMag } = state
61
+
62
+ let peaks = findPeaks(mag, half)
63
+ // `minMag`: caller-configurable absolute floor on top of findPeaks' relative gate
64
+ // (in-place filter — peaks is a fresh per-call array, not aliased).
65
+ let n = 0
66
+ for (let i = 0; i < peaks.length; i++) if (mag[peaks[i]] >= minMag) peaks[n++] = peaks[i]
67
+ peaks.length = n
68
+
69
+ refinePeaks(mag, peaks, refMag, refFrac)
70
+
71
+ // Cap to the `maxTracks` loudest peaks (typed-array partial selection — no per-frame
72
+ // heap objects, no closure comparator). `sel[0..take)` indexes into `peaks`.
73
+ for (let i = 0; i < n; i++) sel[i] = i
74
+ let take = n
75
+ if (maxTracks < n) {
76
+ take = maxTracks
77
+ for (let i = 0; i < take; i++) {
78
+ let best = i
79
+ for (let j = i + 1; j < n; j++) if (refMag[peaks[sel[j]]] > refMag[peaks[sel[best]]]) best = j
80
+ if (best !== i) { let t = sel[i]; sel[i] = sel[best]; sel[best] = t }
81
+ }
82
+ }
83
+
84
+ // Residual = original magnitude with the selected peaks' lobes zeroed, so the remaining
85
+ // stochastic energy carries no partial content.
86
+ for (let k = 0; k <= half; k++) residual[k] = mag[k]
87
+ let lobeW = 3
88
+ for (let s = 0; s < take; s++) {
89
+ let k0 = peaks[sel[s]]
90
+ for (let d = -lobeW; d <= lobeW; d++) {
91
+ let k = k0 + d
92
+ if (k >= 0 && k <= half) residual[k] = 0
93
+ }
94
+ }
95
+
96
+ newMag.fill(0)
97
+ newPhase.fill(0)
98
+ owned.fill(0)
99
+ peakMag.fill(0)
100
+
101
+ // `eIn` accumulates only energy that has a valid (in-range) destination — a partial or
102
+ // residual bin that shifts past Nyquist legitimately vanishes and must not inflate the
103
+ // renormalization target (see shift-core's scatterGated, which subtracts the same case
104
+ // back out of its own `eIn`).
105
+ let eIn = 0
106
+ for (let s = 0; s < take; s++) {
107
+ let k0 = peaks[sel[s]]
108
+ let trueFreq
109
+ if (state.first) trueFreq = refFrac[k0] * freqPerBin
110
+ else {
111
+ let dp = wrapPhase(phase[k0] - prev[k0] - k0 * freqPerBin * hop)
112
+ trueFreq = k0 * freqPerBin + dp / hop
113
+ }
114
+ let shifted = trueFreq * ratio
115
+ let center = Math.round(shifted / freqPerBin)
116
+ // Phase accumulator is keyed on the SOURCE bin — stable across frames — so integer-
117
+ // bin jitter at the destination can't reset a partial's phase mid-note. Left un-advanced
118
+ // when out of range (matches re-entry behaviour: a partial that briefly exceeds Nyquist
119
+ // resumes from where it froze rather than jumping).
120
+ if (center < 0 || center > half) continue
121
+ // Full pre-zeroing lobe energy (the same k0±lobeW span excised from `residual` above)
122
+ // is what's being concentrated into one bin — not just the parabolic peak value — so
123
+ // that's `eIn`'s contribution (may double-count a shared bin between two close peaks'
124
+ // overlapping lobes, e.g. a tight chord; accepted as the same approximation shift-core's
125
+ // own ±1 `eligible` gate makes).
126
+ for (let d = -lobeW; d <= lobeW; d++) {
127
+ let k = k0 + d
128
+ if (k >= 0 && k <= half) eIn += mag[k] * mag[k]
129
+ }
130
+ let newSyn = wrapPhase(syn[k0] + shifted * hop)
131
+ syn[k0] = newSyn
132
+ owned[center] = 1
133
+ // Single-bin deposit at the nearest dest bin (see module doc): a same-phase triangular
134
+ // spread is mathematically incorrect for a Hann-windowed single sinusoid, so overlap-add
135
+ // reconstructs correctly only when the whole partial's magnitude lands on one bin.
136
+ if (refMag[k0] > peakMag[center]) {
137
+ peakMag[center] = refMag[k0]
138
+ newMag[center] = refMag[k0]
139
+ newPhase[center] = newSyn
140
+ }
141
+ }
142
+
143
+ // Residual (stochastic) bins: GATHER each unclaimed destination bin's value from the
144
+ // ratio-scaled source position, linearly interpolated — bijective by construction (every
145
+ // k in [0, half] is visited exactly once), with the source analysis phase (no synthesis
146
+ // accumulator — the residual has no cross-frame phase coherence to preserve). A source
147
+ // position past Nyquist (down-shift) has no counterpart and legitimately contributes zero.
148
+ for (let k = 0; k <= half; k++) {
149
+ if (owned[k]) continue
150
+ let src = k / ratio
151
+ if (src < 0 || src > half) continue
152
+ let i0 = src | 0
153
+ let frac = src - i0
154
+ let r0 = residual[i0]
155
+ let r1 = i0 + 1 <= half ? residual[i0 + 1] : r0
156
+ let r = r0 + (r1 - r0) * frac
157
+ eIn += r * r
158
+ if (r <= 0) continue
159
+ newMag[k] = r
160
+ newPhase[k] = phase[Math.min(half, Math.round(src))]
161
+ }
162
+
163
+ // Per-frame energy renormalization (Σmag² reachable-in → Σmag² out), the same policy
164
+ // shift-core's scatter kernels use, so batch and stream reconstruct at identical loudness
165
+ // with no whole-signal matchGain tail correction. `WIN_GAIN` (rms(w)/mean(w) of the
166
+ // engine's periodic Hann, = √1.5) compensates for the peaks' single-bin deposit: a
167
+ // windowed mainlobe's energy concentrated onto one unwindowed synthesis bin reconstructs
168
+ // quieter through the engine's w·(·)/Σw² overlap-add than the windowed analysis frame it
169
+ // came from (see shift-core's scatterGated/scatterLocked, which apply the identical factor).
170
+ let eOut = 0
171
+ for (let k = 0; k <= half; k++) eOut += newMag[k] * newMag[k]
172
+ if (eOut > 1e-24 && eIn > 1e-24) {
173
+ let g = Math.sqrt(eIn / eOut) * WIN_GAIN
174
+ for (let k = 0; k <= half; k++) newMag[k] *= g
175
+ }
176
+
177
+ for (let k = 0; k <= half; k++) prev[k] = phase[k]
178
+ state.first = false
179
+ return { mag: newMag, phase: newPhase }
180
+ }
181
+
182
+ export default makeStftShift(process, {
183
+ deriveOpts: (opts) => ({ maxTracks: opts?.maxTracks ?? Infinity, minMag: opts?.minMag ?? 1e-4 }),
184
+ post: (out) => out,
185
+ })
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@audio/shift-sms",
3
+ "version": "1.0.0",
4
+ "description": "Spectral Modeling Synthesis (Serra/Smith) sinusoidal+residual pitch shift",
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
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "license": "MIT",
22
+ "author": "audiojs",
23
+ "homepage": "https://github.com/audiojs/shift/tree/main/packages/shift-sms#readme",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/audiojs/shift.git",
27
+ "directory": "packages/shift-sms"
28
+ },
29
+ "keywords": [
30
+ "audio",
31
+ "pitch-shift",
32
+ "sms",
33
+ "sinusoidal",
34
+ "serra",
35
+ "tonal",
36
+ "dsp"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ }
41
+ }