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