@audio/shift-core 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 +467 -0
- package/package.json +40 -0
- package/stft.js +47 -0
package/index.js
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
export const PI2 = Math.PI * 2
|
|
2
|
+
|
|
3
|
+
export function wrapPhase(p) {
|
|
4
|
+
return p - Math.round(p / PI2) * PI2
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Periodic Hann (denominator N, endpoints not both zero) — the DFT-correct form for
|
|
8
|
+
// OLA/STFT analysis-synthesis, matching fourier-transform/stft.js's own window. Cached
|
|
9
|
+
// by frame size, same as every other per-N derived table in this dependency graph.
|
|
10
|
+
let _hannCache = new Map()
|
|
11
|
+
export function hannWindow(N) {
|
|
12
|
+
let w = _hannCache.get(N)
|
|
13
|
+
if (w) return w
|
|
14
|
+
w = new Float64Array(N)
|
|
15
|
+
for (let i = 0; i < N; i++) w[i] = 0.5 - 0.5 * Math.cos(PI2 * i / N)
|
|
16
|
+
_hannCache.set(N, w)
|
|
17
|
+
return w
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isChannelArray(data) {
|
|
21
|
+
return Array.isArray(data) && data.every((c) => c instanceof Float32Array)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeOptionsInput(data) {
|
|
25
|
+
if (data === undefined || data === null || typeof data === 'object') return data
|
|
26
|
+
throw new TypeError('pitchShift: options must be an object')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function validateInput(data) {
|
|
30
|
+
if (data instanceof Float32Array || isChannelArray(data)) return
|
|
31
|
+
throw new TypeError('pitchShift: input must be Float32Array or array of Float32Array channels')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolvePitchParams(opts) {
|
|
35
|
+
let semitones = opts?.semitones ?? 0
|
|
36
|
+
if (!Number.isFinite(semitones)) throw new TypeError('pitchShift: `semitones` must be a finite number')
|
|
37
|
+
let raw = opts?.ratio
|
|
38
|
+
if (typeof raw === 'function' || raw instanceof Float32Array) {
|
|
39
|
+
throw new TypeError('pitchShift: variable `ratio` (function or Float32Array) is supported by vocoder, phaseLock, transient, formant, paulstretch, sms, hpss, and sample')
|
|
40
|
+
}
|
|
41
|
+
let ratio = raw ?? (semitones ? Math.pow(2, semitones / 12) : 1)
|
|
42
|
+
if (!Number.isFinite(ratio) || ratio <= 0) throw new TypeError('pitchShift: `ratio` must be a finite number > 0')
|
|
43
|
+
return { ratio, semitones }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Variable-ratio resolver. Returns `{ ratio, ratioFn }` where `ratio` is the scalar value
|
|
47
|
+
// at t=0 (for identity checks and fallbacks) and `ratioFn` is a `(timeSeconds) => ratio`
|
|
48
|
+
// function, or `null` when the caller passed a plain scalar. Algorithms that support
|
|
49
|
+
// time-varying pitch use `ratioFn`; algorithms that don't should use `resolvePitchParams`
|
|
50
|
+
// (which throws on function/array input).
|
|
51
|
+
export function resolveRatio(opts) {
|
|
52
|
+
let raw = opts?.ratio
|
|
53
|
+
if (typeof raw === 'function') {
|
|
54
|
+
let r0 = raw(0)
|
|
55
|
+
if (!Number.isFinite(r0) || r0 <= 0) throw new TypeError('pitchShift: `ratio(0)` must be a finite number > 0')
|
|
56
|
+
return { ratio: r0, ratioFn: raw }
|
|
57
|
+
}
|
|
58
|
+
if (raw instanceof Float32Array) {
|
|
59
|
+
if (raw.length === 0) throw new TypeError('pitchShift: `ratio` Float32Array must be non-empty')
|
|
60
|
+
let arr = raw
|
|
61
|
+
let last = arr.length - 1
|
|
62
|
+
// Sampled curve on [0, durationSeconds]; caller supplies `ratioDuration` in seconds.
|
|
63
|
+
// Fallback: treat as a per-sample curve at `sampleRate`.
|
|
64
|
+
let sr = opts?.sampleRate || 44100
|
|
65
|
+
let durOpt = opts?.ratioDuration
|
|
66
|
+
if (durOpt !== undefined && durOpt !== null && (!Number.isFinite(durOpt) || durOpt <= 0)) {
|
|
67
|
+
throw new TypeError('pitchShift: `ratioDuration` must be a finite number > 0')
|
|
68
|
+
}
|
|
69
|
+
let dur = durOpt ?? (arr.length / sr)
|
|
70
|
+
let fn = (t) => {
|
|
71
|
+
let pos = (t / dur) * last
|
|
72
|
+
if (pos <= 0) return arr[0]
|
|
73
|
+
if (pos >= last) return arr[last]
|
|
74
|
+
let i0 = Math.floor(pos)
|
|
75
|
+
let frac = pos - i0
|
|
76
|
+
return (1 - frac) * arr[i0] + frac * arr[i0 + 1]
|
|
77
|
+
}
|
|
78
|
+
return { ratio: arr[0], ratioFn: fn }
|
|
79
|
+
}
|
|
80
|
+
let { ratio } = resolvePitchParams(opts)
|
|
81
|
+
return { ratio, ratioFn: null }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Rescale `out` in place so its RMS matches `ref`'s RMS. Pitch shift preserves loudness
|
|
85
|
+
// by definition, so any STFT bin-shift path that loses or inflates energy through round()
|
|
86
|
+
// quantisation / scatter collisions can be corrected with a single global scalar at the
|
|
87
|
+
// tail. Bounded correction: only applied when output is in the 0.1..10× ballpark of the
|
|
88
|
+
// reference — outside that range the output is either legitimately silent (pitch-up past
|
|
89
|
+
// Nyquist, where only aliasing energy remains) or the algorithm is catastrophically broken
|
|
90
|
+
// and a blind rescale would hide the problem instead of fixing it.
|
|
91
|
+
export function matchGain(out, ref) {
|
|
92
|
+
let no = out.length, nr = ref.length
|
|
93
|
+
let so = 0, sr = 0
|
|
94
|
+
for (let i = 0; i < no; i++) so += out[i] * out[i]
|
|
95
|
+
for (let i = 0; i < nr; i++) sr += ref[i] * ref[i]
|
|
96
|
+
if (so <= 1e-12 || sr <= 1e-12) return out
|
|
97
|
+
let rmsO = Math.sqrt(so / no)
|
|
98
|
+
let rmsR = Math.sqrt(sr / nr)
|
|
99
|
+
let ratio = rmsO / rmsR
|
|
100
|
+
if (ratio < 0.1 || ratio > 10) return out
|
|
101
|
+
let g = rmsR / rmsO
|
|
102
|
+
for (let i = 0; i < no; i++) out[i] *= g
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A windowed-sinc kernel's per-position-invariant state: the sinc argument and Hann-taper
|
|
107
|
+
// argument both advance by a fixed step per tap (`dTheta`, `dPhi`), so `sincAccumulate` can
|
|
108
|
+
// rotate two unit vectors by angle-addition instead of calling sin/cos per tap. Depends only
|
|
109
|
+
// on `hw`/`cutoff`, so callers looping over many positions at the same `hw`/`cutoff` (e.g.
|
|
110
|
+
// `resampleTo`) build this once and reuse it, while `sincRead` (one read per call, and
|
|
111
|
+
// `cutoff` may vary call to call) builds it fresh each time.
|
|
112
|
+
function sincKernel(hw, cutoff) {
|
|
113
|
+
let dTheta = Math.PI * cutoff, dPhi = Math.PI / hw
|
|
114
|
+
return { hw, cutoff, dTheta, dPhi, cosDT: Math.cos(dTheta), sinDT: Math.sin(dTheta), cosDP: Math.cos(dPhi), sinDP: Math.sin(dPhi) }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Accumulate a Hann-windowed sinc read of `buf` at fractional position `i0+frac` under
|
|
118
|
+
// kernel `k` (from `sincKernel`). Taps that fall outside `buf` are dropped (equivalent to
|
|
119
|
+
// zero-padding — the standard finite-support boundary convention), then the sum is divided
|
|
120
|
+
// by the actual accumulated tap weight rather than the full kernel's, so truncation at a
|
|
121
|
+
// buffer edge droops the tap count, not the gain: DC response stays ≈1 at edges and interior.
|
|
122
|
+
function sincAccumulate(buf, bufLen, i0, frac, k) {
|
|
123
|
+
let { hw, cutoff, dTheta, dPhi, cosDT, sinDT, cosDP, sinDP } = k
|
|
124
|
+
let x = -hw + 1 - frac
|
|
125
|
+
let sinT = Math.sin(x * dTheta), cosT = Math.cos(x * dTheta)
|
|
126
|
+
let sinP = Math.sin(x * dPhi), cosP = Math.cos(x * dPhi)
|
|
127
|
+
let sum = 0, weight = 0
|
|
128
|
+
for (let n = -hw + 1; n <= hw; n++) {
|
|
129
|
+
if (Math.abs(x) < hw) {
|
|
130
|
+
let idx = i0 + n
|
|
131
|
+
if (idx >= 0 && idx < bufLen) {
|
|
132
|
+
let theta = x * dTheta
|
|
133
|
+
let si = Math.abs(x * cutoff) < 1e-9 ? 1 : sinT / theta
|
|
134
|
+
let wt = si * cutoff * (0.5 + 0.5 * cosP)
|
|
135
|
+
sum += buf[idx] * wt
|
|
136
|
+
weight += wt
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
let sinT1 = sinT * cosDT + cosT * sinDT
|
|
140
|
+
cosT = cosT * cosDT - sinT * sinDT
|
|
141
|
+
sinT = sinT1
|
|
142
|
+
let sinP1 = sinP * cosDP + cosP * sinDP
|
|
143
|
+
cosP = cosP * cosDP - sinP * sinDP
|
|
144
|
+
sinP = sinP1
|
|
145
|
+
x += 1
|
|
146
|
+
}
|
|
147
|
+
return weight > 1e-9 ? sum / weight : 0
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Hann-windowed sinc read at a fractional source position. `cutoff ∈ (0,1]` sets an
|
|
151
|
+
// anti-alias lowpass at `cutoff × Nyquist`; use `cutoff = min(1, 1/stride)` when the
|
|
152
|
+
// caller is stepping through the source faster than one sample per read to suppress
|
|
153
|
+
// content above the new Nyquist before it folds. `r` is the kernel half-width in
|
|
154
|
+
// zero-crossings (8 is standard: deep stopband (>60 dB) is reached only ~40% above the
|
|
155
|
+
// new Nyquist — the transition band right at cutoff is a much shallower 6-20 dB).
|
|
156
|
+
export function sincRead(buf, pos, r, cutoff) {
|
|
157
|
+
let i0 = Math.floor(pos)
|
|
158
|
+
let frac = pos - i0
|
|
159
|
+
let hw = Math.ceil(r / cutoff)
|
|
160
|
+
return sincAccumulate(buf, buf.length, i0, frac, sincKernel(hw, cutoff))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Hann-windowed sinc resampler with anti-aliasing. When downsampling (inLen > outLen) the
|
|
164
|
+
// sinc cutoff scales to outLen/inLen so content above Nyquist/step is suppressed before it
|
|
165
|
+
// can fold (see `sincRead` for the actual stopband shape). Upsampling (inLen ≤ outLen) uses
|
|
166
|
+
// cutoff=1, identical to the standard reconstruction sinc.
|
|
167
|
+
export function resampleTo(data, outLen, r = 8) {
|
|
168
|
+
let inLen = data.length
|
|
169
|
+
let out = new Float32Array(outLen)
|
|
170
|
+
if (outLen === 0 || inLen === 0) return out
|
|
171
|
+
if (outLen === inLen) return new Float32Array(data)
|
|
172
|
+
// outLen===1 has no defined step/ratio to anti-alias against; degrade to the pos=0
|
|
173
|
+
// sample, matching every outLen>=2 case where the first output is always read at pos=0.
|
|
174
|
+
if (outLen === 1) { out[0] = data[0]; return out }
|
|
175
|
+
let step = (inLen - 1) / (outLen - 1)
|
|
176
|
+
let cutoff = step > 1 ? 1 / step : 1
|
|
177
|
+
let k = sincKernel(Math.ceil(r / cutoff), cutoff)
|
|
178
|
+
for (let i = 0; i < outLen; i++) {
|
|
179
|
+
let pos = i * step
|
|
180
|
+
let i0 = Math.floor(pos)
|
|
181
|
+
out[i] = sincAccumulate(data, inLen, i0, pos - i0, k)
|
|
182
|
+
}
|
|
183
|
+
return out
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function mapInput(data, fn, opts) {
|
|
187
|
+
validateInput(data)
|
|
188
|
+
if (data instanceof Float32Array) return fn(data, opts)
|
|
189
|
+
return data.map((c) => fn(c, opts))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function passThroughWriter() {
|
|
193
|
+
let flushed = false
|
|
194
|
+
return (chunk) => {
|
|
195
|
+
if (chunk === undefined) { flushed = true; return new Float32Array(0) }
|
|
196
|
+
if (flushed) throw new Error('pitchShift: stream already flushed')
|
|
197
|
+
return new Float32Array(chunk)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Streaming adapter for algorithms that need whole-signal look-ahead (e.g. HPSS median
|
|
202
|
+
// windows, hybrid's parallel engines). Buffers input; emits empty on writes and the full
|
|
203
|
+
// batch result on flush. Canonical simplest form for inherently non-causal algorithms.
|
|
204
|
+
export function bufferedStream(batch, opts) {
|
|
205
|
+
let parts = []
|
|
206
|
+
let flushed = false
|
|
207
|
+
return (chunk) => {
|
|
208
|
+
if (chunk === undefined) {
|
|
209
|
+
if (flushed) return new Float32Array(0)
|
|
210
|
+
flushed = true
|
|
211
|
+
let total = 0
|
|
212
|
+
for (let p of parts) total += p.length
|
|
213
|
+
let all = new Float32Array(total)
|
|
214
|
+
let o = 0
|
|
215
|
+
for (let p of parts) { all.set(p, o); o += p.length }
|
|
216
|
+
return batch(all, opts)
|
|
217
|
+
}
|
|
218
|
+
if (flushed) throw new Error('pitchShift: stream already flushed')
|
|
219
|
+
parts.push(new Float32Array(chunk))
|
|
220
|
+
return new Float32Array(0)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function createChannelWriter(factory) {
|
|
225
|
+
let mode = null
|
|
226
|
+
let writers = null
|
|
227
|
+
|
|
228
|
+
return (chunk) => {
|
|
229
|
+
if (chunk === undefined) {
|
|
230
|
+
if (!writers) return new Float32Array(0)
|
|
231
|
+
return mode === 'channels' ? writers.map((w) => w()) : writers[0]()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (isChannelArray(chunk)) {
|
|
235
|
+
if (!writers) { mode = 'channels'; writers = chunk.map(() => factory()) }
|
|
236
|
+
if (mode !== 'channels') throw new TypeError('pitchShift: cannot mix mono and multi-channel writes')
|
|
237
|
+
if (writers.length !== chunk.length) throw new TypeError('pitchShift: streaming channel count must stay constant')
|
|
238
|
+
return chunk.map((c, i) => writers[i](c))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!(chunk instanceof Float32Array)) {
|
|
242
|
+
throw new TypeError('pitchShift: streaming input must be Float32Array or array of Float32Array channels')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!writers) { mode = 'mono'; writers = [factory()] }
|
|
246
|
+
if (mode !== 'mono') throw new TypeError('pitchShift: cannot mix mono and multi-channel writes')
|
|
247
|
+
return writers[0](chunk)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// First-order local magnitude peaks above a fraction of the frame's peak.
|
|
252
|
+
// ±1 comparison keeps closely-spaced chord partials whose mainlobes overlap. `>=` on the
|
|
253
|
+
// left / `>` on the right reports the trailing edge of an exact-magnitude plateau exactly
|
|
254
|
+
// once, instead of a strict `>` on both sides missing the whole plateau.
|
|
255
|
+
export function findPeaks(mag, half) {
|
|
256
|
+
let maxM = 0
|
|
257
|
+
for (let k = 0; k <= half; k++) if (mag[k] > maxM) maxM = mag[k]
|
|
258
|
+
let floor = Math.max(1e-8, maxM * 0.005)
|
|
259
|
+
let peaks = []
|
|
260
|
+
for (let k = 1; k < half; k++) {
|
|
261
|
+
let v = mag[k]
|
|
262
|
+
if (v < floor) continue
|
|
263
|
+
if (v >= mag[k - 1] && v > mag[k + 1]) peaks.push(k)
|
|
264
|
+
}
|
|
265
|
+
return peaks
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Binary-search nearest peak index for bin k.
|
|
269
|
+
export function nearestPeak(peaks, k) {
|
|
270
|
+
if (!peaks.length) return -1
|
|
271
|
+
let lo = 0, hi = peaks.length - 1
|
|
272
|
+
while (lo < hi) {
|
|
273
|
+
let mid = (lo + hi) >> 1
|
|
274
|
+
if (peaks[mid] < k) lo = mid + 1
|
|
275
|
+
else hi = mid
|
|
276
|
+
}
|
|
277
|
+
if (lo > 0 && Math.abs(peaks[lo - 1] - k) <= Math.abs(peaks[lo] - k)) return lo - 1
|
|
278
|
+
return lo
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Peak-gated bin scatter (Bernsee/SMB scheme): every analysis bin at or adjacent (±1) to a
|
|
282
|
+
// local magnitude peak advances phase at its own instantaneous frequency, scales it by
|
|
283
|
+
// `ratio`, and deposits into the destination bin that frequency implies. Colliding bins
|
|
284
|
+
// accumulate in the energy domain (Σmag², √ at the end) — synthesis treats each bin as an
|
|
285
|
+
// independent oscillator, so energies add where magnitude-summing overshoots (+4.3 dB for
|
|
286
|
+
// a Hann mainlobe's own ±1 bins landing together) and last-writer-wins discards every
|
|
287
|
+
// other contributor. The frequency written to a bin is its loudest contributor's (a
|
|
288
|
+
// quieter contributor's frequency estimate is masked anyway).
|
|
289
|
+
// The gate keeps only mainlobe cores; skirt bins it drops carry real energy belonging to
|
|
290
|
+
// the same partials. The frame is renormalized so kept-bin energy matches the input
|
|
291
|
+
// frame's — minus content whose destination fell outside Nyquist, which is legitimately
|
|
292
|
+
// lost — times WIN_GAIN: concentrating a windowed mainlobe into one bin makes the ISTFT
|
|
293
|
+
// frame a pure sinusoid where the analysis frame was a windowed one, and through the
|
|
294
|
+
// engine's w·(·)/Σw² overlap-add that costs exactly mean(w)/rms(w). Per-frame and causal,
|
|
295
|
+
// so batch and stream reconstruct at identical loudness with no whole-signal correction.
|
|
296
|
+
// `newMag`/`newFreq`/`peakMag` are caller-owned scratch sized `half+1`, zero-filled by the
|
|
297
|
+
// caller before the call. `prevPhase` is the previous frame's unwrapped phase, or `null`
|
|
298
|
+
// on the first frame.
|
|
299
|
+
export function scatterGated(mag, phase, prevPhase, ratio, ctx, newMag, newFreq, peakMag) {
|
|
300
|
+
let { half, hop, freqPerBin } = ctx
|
|
301
|
+
let maxM = 0
|
|
302
|
+
for (let k = 0; k <= half; k++) if (mag[k] > maxM) maxM = mag[k]
|
|
303
|
+
let floor = Math.max(1e-8, maxM * 0.005)
|
|
304
|
+
let eIn = 0, eOut = 0
|
|
305
|
+
for (let k = 0; k <= half; k++) {
|
|
306
|
+
let e = mag[k] * mag[k]
|
|
307
|
+
eIn += e
|
|
308
|
+
let eligible = false
|
|
309
|
+
for (let d = -1; d <= 1; d++) {
|
|
310
|
+
let j = k + d
|
|
311
|
+
if (j <= 0 || j >= half) continue
|
|
312
|
+
if (mag[j] >= floor && mag[j] >= mag[j - 1] && mag[j] > mag[j + 1]) { eligible = true; break }
|
|
313
|
+
}
|
|
314
|
+
if (!eligible) continue
|
|
315
|
+
let trueFreq
|
|
316
|
+
if (!prevPhase) trueFreq = k * freqPerBin
|
|
317
|
+
else {
|
|
318
|
+
let dp = wrapPhase(phase[k] - prevPhase[k] - k * freqPerBin * hop)
|
|
319
|
+
trueFreq = k * freqPerBin + dp / hop
|
|
320
|
+
}
|
|
321
|
+
let shifted = trueFreq * ratio
|
|
322
|
+
let destBin = Math.round(shifted / freqPerBin)
|
|
323
|
+
if (destBin < 0 || destBin > half) { eIn -= e; continue }
|
|
324
|
+
eOut += e
|
|
325
|
+
let r = lobeGain(shifted - destBin * freqPerBin, ctx.N)
|
|
326
|
+
newMag[destBin] += e / (r * r)
|
|
327
|
+
if (mag[k] > peakMag[destBin]) { peakMag[destBin] = mag[k]; newFreq[destBin] = shifted }
|
|
328
|
+
}
|
|
329
|
+
let g = (eOut > 1e-24 && eIn > 1e-24 ? Math.sqrt(eIn / eOut) : 1) * WIN_GAIN
|
|
330
|
+
for (let k = 0; k <= half; k++) if (newMag[k]) newMag[k] = Math.sqrt(newMag[k]) * g
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// rms(w)/mean(w) for the engine's periodic Hann: sqrt(3/8)/(1/2) = sqrt(3/2).
|
|
334
|
+
export const WIN_GAIN = Math.sqrt(1.5)
|
|
335
|
+
|
|
336
|
+
// Mean overlap-add amplitude of a partial whose intra-frame (bin-grid) and inter-frame
|
|
337
|
+
// (true) frequencies differ by `dw` rad/sample: |W(dw)|/W(0) for the engine's periodic
|
|
338
|
+
// Hann of length N. The synthesized bin oscillates on the bin grid inside each frame
|
|
339
|
+
// while its phase steps at the true frequency across frames, so overlapping frames sum
|
|
340
|
+
// slightly incoherently — the classic vocoder scalloping loss, deterministic per bin.
|
|
341
|
+
function lobeGain(dw, N) {
|
|
342
|
+
if (!dw) return 1
|
|
343
|
+
let d = (t) => {
|
|
344
|
+
let s = Math.sin(t / 2)
|
|
345
|
+
return Math.abs(s) < 1e-12 ? N : Math.sin(N * t / 2) / s
|
|
346
|
+
}
|
|
347
|
+
let b = PI2 / N
|
|
348
|
+
return Math.abs(0.5 * d(dw) + 0.25 * d(dw - b) + 0.25 * d(dw + b)) / (0.5 * N)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Peak-locked rigid-ROI bin scatter (Laroche-Dolson): each `findPeaks` peak advances its own
|
|
352
|
+
// phase at its instantaneous frequency × `ratio`; every other bin rides along rigidly at its
|
|
353
|
+
// nearest peak's integer bin-shift, carrying phase relative to that peak (phase coherence
|
|
354
|
+
// across the peak's region of influence). Colliding destination bins accumulate in the
|
|
355
|
+
// energy domain (Σmag², √ at the end); the phase written is the loudest contributor's —
|
|
356
|
+
// same RMS-preserving collision policy as `scatterGated`.
|
|
357
|
+
// `reset` skips phase-derivative estimation (first frame, or a caller-detected phase
|
|
358
|
+
// discontinuity such as a transient) and uses the analysis phase directly instead of
|
|
359
|
+
// integrating. `syn` is the caller-owned running per-bin phase accumulator (persists across
|
|
360
|
+
// frames). `newMag`/`newPhase`/`peakMag` are caller-owned scratch sized `half+1`, zero-filled
|
|
361
|
+
// by the caller; `peakDest`/`peakSynPhase` are caller-owned scratch sized `peaks.length`.
|
|
362
|
+
export function scatterLocked(mag, phase, prevPhase, reset, peaks, ratio, ctx, syn, newMag, newPhase, peakDest, peakSynPhase, peakMag) {
|
|
363
|
+
let { half, hop, freqPerBin } = ctx
|
|
364
|
+
if (_boost.length <= half) _boost = new Float64Array(half + 1)
|
|
365
|
+
for (let i = 0; i < peaks.length; i++) {
|
|
366
|
+
let k = peaks[i]
|
|
367
|
+
let trueFreq
|
|
368
|
+
if (reset) trueFreq = k * freqPerBin
|
|
369
|
+
else {
|
|
370
|
+
let dp = wrapPhase(phase[k] - prevPhase[k] - k * freqPerBin * hop)
|
|
371
|
+
trueFreq = k * freqPerBin + dp / hop
|
|
372
|
+
}
|
|
373
|
+
let shifted = trueFreq * ratio
|
|
374
|
+
// Shift the lobe by the integer bin count closest to the true frequency delta: the
|
|
375
|
+
// lobe's own frac is preserved, so the intra-/inter-frame frequency mismatch stays
|
|
376
|
+
// within ±half a bin — where the scalloping model below is accurate.
|
|
377
|
+
let destBin = k + Math.round((shifted - trueFreq) / freqPerBin)
|
|
378
|
+
if (destBin < 0 || destBin > half) { peakDest[i] = -1; continue }
|
|
379
|
+
let newSyn = reset ? phase[k] : wrapPhase(syn[destBin] + shifted * hop)
|
|
380
|
+
peakDest[i] = destBin
|
|
381
|
+
peakSynPhase[i] = newSyn
|
|
382
|
+
syn[destBin] = newSyn
|
|
383
|
+
let r = lobeGain(shifted - (trueFreq + (destBin - k) * freqPerBin), ctx.N)
|
|
384
|
+
_boost[i] = 1 / (r * r)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// No WIN_GAIN here: the rigid ROI shift carries the whole mainlobe shape to the
|
|
388
|
+
// destination, so the ISTFT frame stays a windowed one — only collision and
|
|
389
|
+
// past-Nyquist bookkeeping need correction. Bins riding a peak whose destination
|
|
390
|
+
// fell outside Nyquist are legitimately lost and excluded from the energy target.
|
|
391
|
+
let eIn = 0, eOut = 0
|
|
392
|
+
for (let k = 0; k <= half; k++) {
|
|
393
|
+
let pi = nearestPeak(peaks, k)
|
|
394
|
+
if (pi < 0) continue
|
|
395
|
+
let destBin = peakDest[pi]
|
|
396
|
+
if (destBin < 0) continue
|
|
397
|
+
let e = mag[k] * mag[k]
|
|
398
|
+
eIn += e
|
|
399
|
+
let pk = peaks[pi]
|
|
400
|
+
let dest = destBin + (k - pk)
|
|
401
|
+
if (dest < 0 || dest > half) continue
|
|
402
|
+
eOut += e
|
|
403
|
+
newMag[dest] += e * _boost[pi]
|
|
404
|
+
if (mag[k] > peakMag[dest]) { peakMag[dest] = mag[k]; newPhase[dest] = peakSynPhase[pi] + (phase[k] - phase[pk]) }
|
|
405
|
+
}
|
|
406
|
+
let g = eOut > 1e-24 && eIn > 1e-24 ? Math.sqrt(eIn / eOut) : 1
|
|
407
|
+
for (let k = 0; k <= half; k++) if (newMag[k]) newMag[k] = Math.sqrt(newMag[k]) * g
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// scatterLocked-internal per-peak scalloping boosts; grown once, reused across frames.
|
|
411
|
+
let _boost = new Float64Array(0)
|
|
412
|
+
|
|
413
|
+
// Variable-ratio resolver for STFT process callbacks. Returns `{ scalar, at }`
|
|
414
|
+
// where `scalar` is the ratio at t=0 and `at(frameStart, sampleRate)` resolves
|
|
415
|
+
// the ratio for a given frame position. Replaces the 4-line boilerplate that was
|
|
416
|
+
// duplicated in every makeProcess function.
|
|
417
|
+
export function makeFrameRatio(ratio) {
|
|
418
|
+
if (typeof ratio !== 'function') return { scalar: ratio, at: () => ratio }
|
|
419
|
+
let scalar = ratio(0)
|
|
420
|
+
return {
|
|
421
|
+
scalar,
|
|
422
|
+
at(frameStart, sampleRate) {
|
|
423
|
+
let r = ratio(Math.max(0, frameStart) / sampleRate)
|
|
424
|
+
return (!Number.isFinite(r) || r <= 0) ? (scalar || 1) : r
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function makePitchShift(batch, stream) {
|
|
430
|
+
let isVariable = (opts) => {
|
|
431
|
+
let raw = opts?.ratio
|
|
432
|
+
return typeof raw === 'function' || raw instanceof Float32Array
|
|
433
|
+
}
|
|
434
|
+
let isIdentity = (opts) => {
|
|
435
|
+
if (isVariable(opts)) return false
|
|
436
|
+
return resolvePitchParams(opts).ratio === 1
|
|
437
|
+
}
|
|
438
|
+
return function shift(data, opts) {
|
|
439
|
+
if (data instanceof Float32Array) {
|
|
440
|
+
if (isIdentity(opts)) return new Float32Array(data)
|
|
441
|
+
return batch(data, opts)
|
|
442
|
+
}
|
|
443
|
+
if (isChannelArray(data)) {
|
|
444
|
+
if (isIdentity(opts)) return data.map((c) => new Float32Array(c))
|
|
445
|
+
return data.map((c) => batch(c, opts))
|
|
446
|
+
}
|
|
447
|
+
opts = normalizeOptionsInput(data)
|
|
448
|
+
if (isIdentity(opts)) return createChannelWriter(() => passThroughWriter())
|
|
449
|
+
return createChannelWriter(() => stream(opts))
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Wraps a `time-stretch` algorithm into a stretch-then-resample batch+stream pitch shifter:
|
|
454
|
+
// stretch to `ratio × length` at the stretch fn's own frame parameters, then anti-aliased
|
|
455
|
+
// sinc-resample back to the original length. `stretch` is supplied by the caller (ola/wsola/
|
|
456
|
+
// psola/granular each plug in their own `time-stretch` export) so shift-core gains no new
|
|
457
|
+
// dependency; `deriveOpts(opts, ratio)` computes that fn's own options (frameSize, hopSize,
|
|
458
|
+
// delta, minFreq/maxFreq, ...).
|
|
459
|
+
export function makeStretchShift(stretch, deriveOpts) {
|
|
460
|
+
function batch(data, opts) {
|
|
461
|
+
let { ratio } = resolvePitchParams(opts)
|
|
462
|
+
let stretched = stretch(data, { factor: ratio, ...deriveOpts(opts, ratio) })
|
|
463
|
+
return resampleTo(stretched, data.length)
|
|
464
|
+
}
|
|
465
|
+
let stream = (opts) => bufferedStream(batch, opts)
|
|
466
|
+
return makePitchShift(batch, stream)
|
|
467
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/shift-core",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shared utilities for @audio/shift-* atoms: ratio resolution, channel writer, buffered stream, gain match, sinc resample, frame-ratio helper",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./stft": "./stft.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"stft.js"
|
|
16
|
+
],
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"fourier-transform": "^2.3.0"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "audiojs",
|
|
25
|
+
"homepage": "https://github.com/audiojs/shift/tree/main/packages/shift-core#readme",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/audiojs/shift.git",
|
|
29
|
+
"directory": "packages/shift-core"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"audio",
|
|
33
|
+
"pitch-shift",
|
|
34
|
+
"dsp",
|
|
35
|
+
"utilities"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/stft.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { stftBatch as ftStftBatch, stftStream as ftStftStream, winSqFloor } from 'fourier-transform/stft'
|
|
2
|
+
import { matchGain, resolveRatio, makePitchShift } from './index.js'
|
|
3
|
+
|
|
4
|
+
// Thin wrapper over `fourier-transform/stft` that exposes `ratio` and `ratioFn`
|
|
5
|
+
// at the top of `ctx` (pitch-shift convention) in addition to FT's default
|
|
6
|
+
// `ctx.opts.*` surface. Atoms' process callbacks can continue to read
|
|
7
|
+
// `ctx.ratio` / `ctx.ratioFn` directly.
|
|
8
|
+
|
|
9
|
+
function wrapProcess(process) {
|
|
10
|
+
return function (mag, phase, state, ctx) {
|
|
11
|
+
if (ctx.ratio === undefined) ctx.ratio = ctx.opts?.ratio
|
|
12
|
+
if (ctx.ratioFn === undefined) ctx.ratioFn = ctx.opts?.ratioFn ?? null
|
|
13
|
+
return process(mag, phase, state, ctx)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function stftBatch(data, process, opts) {
|
|
18
|
+
return ftStftBatch(data, wrapProcess(process), opts)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function stftStream(process, opts) {
|
|
22
|
+
let s = ftStftStream(wrapProcess(process), opts)
|
|
23
|
+
return { write: (chunk) => s.write(chunk), flush: () => s.flush() }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { winSqFloor }
|
|
27
|
+
|
|
28
|
+
// Wraps a per-frame STFT `process` callback into batch+stream pitch-shift entry points:
|
|
29
|
+
// resolves `ratio`/`ratioFn`, threads them through `opts`, and (by default) matchGain-
|
|
30
|
+
// corrects the batch output. `deriveOpts(opts)` computes any extra STFT options the
|
|
31
|
+
// process fn needs (frameSize, hopSize, ...) — called once per batch/stream construction,
|
|
32
|
+
// not per frame. `post(out, data)` overrides the default loudness correction (e.g.
|
|
33
|
+
// paulstretch's peak-match — its randomized-phase reconstruction is noise-like, so
|
|
34
|
+
// RMS-matching would push peaks toward clipping).
|
|
35
|
+
export function makeStftShift(process, { deriveOpts = () => ({}), post = matchGain } = {}) {
|
|
36
|
+
function batch(data, opts) {
|
|
37
|
+
let { ratio, ratioFn } = resolveRatio(opts)
|
|
38
|
+
let out = stftBatch(data, process, { ...opts, ratio, ratioFn, ...deriveOpts(opts) })
|
|
39
|
+
return post(out, data)
|
|
40
|
+
}
|
|
41
|
+
function stream(opts) {
|
|
42
|
+
let { ratio, ratioFn } = resolveRatio(opts)
|
|
43
|
+
let s = stftStream(process, { ...opts, ratio, ratioFn, ...deriveOpts(opts) })
|
|
44
|
+
return (chunk) => chunk === undefined ? s.flush() : s.write(chunk)
|
|
45
|
+
}
|
|
46
|
+
return makePitchShift(batch, stream)
|
|
47
|
+
}
|