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