@audio/shift 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/README.md ADDED
@@ -0,0 +1,627 @@
1
+ # @audio/shift [![test](https://github.com/audiojs/shift/actions/workflows/test.yml/badge.svg)](https://github.com/audiojs/shift/actions/workflows/test.yml) [![npm](https://img.shields.io/npm/v/@audio/shift?color=white)](https://www.npmjs.com/package/@audio/shift) [![demo](https://img.shields.io/badge/demo-live-black)](https://audiojs.github.io/pitch-shift/demo)
2
+
3
+ Canonical pitch-shifting algorithms in functional JavaScript.<br>
4
+ _Frequency-domain_: vocoder, phaseLock, transient, formant, sms, hpss.<br>
5
+ _Time-domain_: ola, wsola, psola, granular, sample, delay.<br>
6
+ _Source-filter_: lpc.<br>
7
+ Consistent unified API: batch, stream, multi-channel — 15 algorithms.
8
+ Part of the audiojs ecosystem.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @audio/shift
14
+ ```
15
+
16
+ Each algorithm also ships standalone — `npm install @audio/shift-transient`, `@audio/shift-lpc`, etc. — for installs that need only one.
17
+
18
+ ## Usage
19
+
20
+ ```js
21
+ import { transient } from '@audio/shift'
22
+
23
+ // Batch
24
+ let pitched = transient(audio, { semitones: 5 })
25
+
26
+ // Stream
27
+ let write = transient({ ratio: 1.5 })
28
+ let output = write(inputBlock)
29
+ let tail = write() // flush
30
+
31
+ // Stereo
32
+ let [L, R] = transient([left, right], { ratio: 1.5 })
33
+ ```
34
+
35
+ `write`/`flush` is the same shape everywhere, but not every algorithm streams incrementally. `vocoder`, `phaseLock`, `transient`, `formant`, and `sms` emit audio frame-by-frame as it arrives. Every other algorithm — `ola`, `psola`, `wsola`, `granular`, `paulstretch`, `hpss`, `sample`, `hybrid`, `delay`, `lpc` — buffers the whole input and returns everything on the matching flush call; the API is uniform, the latency isn't.
36
+
37
+ ## Algorithms
38
+
39
+ | | Domain | Best for | shift |
40
+ |---|---|---|---|
41
+ | [pitchShift](#pitchshift) | auto | content-aware default | 1.755 |
42
+ | [transient](#transient) | STFT | music with percussion ★ | 1.755 |
43
+ | [phaseLock](#phaselock) | STFT | general music | 1.755 |
44
+ | [vocoder](#vocoder) | STFT | simple tonal | 1.553 |
45
+ | [formant](#formant) | STFT | voice (no chipmunk) | 1.573 |
46
+ | [hpss](#hpss) | STFT | mixed music (drums+tonal) | **1.487** |
47
+ | [sms](#sms) | sinusoidal | harmonic/tonal | 1.701 |
48
+ | [paulstretch](#paulstretch) | STFT | ambient, extreme shifts | 2.221 |
49
+ | [wsola](#wsola) | time | speech, low-latency | 1.674 |
50
+ | [psola](#psola) | time | speech, mono voice | 1.766 |
51
+ | [delay](#delay) | delay-line | real-time, harmonizer | 1.610 |
52
+ | [ola](#ola) | time | baseline | 2.025 |
53
+ | [granular](#granular) | time | creative textures | 2.256 |
54
+ | [sample](#sample) | time | sampler/tracker playback | 1.614 |
55
+ | [hybrid](#hybrid) | hybrid | mixed dynamic material | 1.824 |
56
+ | [lpc](#lpc) | source-filter | speech, formant filter | 1.811 |
57
+
58
+ Frequency-domain algorithms shift bins natively. `ola`/`wsola`/`psola` stretch time via [stretch](https://github.com/audiojs/stretch), then sinc-resample back to pitch. `granular`, `sample`, `delay`, and `lpc` shift natively — no stretch-then-resample stage. **shift** = log-magnitude distance to canonical reference (lower is better). Run `npm run quality` for all metrics.
59
+
60
+ All algorithms accept `ratio` (1.5 = +7 semitones, 2 = octave) and `semitones`. `frameSize` (2048) / `hopSize` (frameSize/4) apply to the STFT family, `ola`, `wsola`, and `hybrid`; `granular` (`grainSize`), `delay` (`window`/`tolerance`), `lpc` (`frameSize` alone, no `hopSize`), and `sample`/`psola` (no frame parameter) each use their own — see each section.
61
+
62
+ ## Runtime behavior
63
+
64
+ Every algorithm's streaming output is numerically identical to its batch output on the same input, byte for byte. For `ola`, `psola`, `wsola`, `granular`, `paulstretch`, `hpss`, `sample`, `hybrid`, `delay`, and `lpc`, that's automatic — the stream writer buffers the whole input and runs the batch code path once at `flush()`. For `vocoder`, `phaseLock`, `transient`, `formant`, and `sms`, it's a real per-frame guarantee: each frame renormalizes its own energy independently of every other frame, so a batch run and a run fed one differently-chunked block at a time land on the exact same output.
65
+
66
+ That per-frame renormalization also replaces whole-signal loudness correction for those five: none of them apply a post-hoc RMS match (`matchGain`) anymore, and loudness still lands within **~1 dB** of the input (measured −0.98 to +0.10 dB across the family on a plain sine). `lpc` is the exception — its excitation-domain repitching can drift level with the AR envelope's own gain, so it still applies one whole-signal `matchGain` pass at the end.
67
+
68
+
69
+ ### `pitchShift`
70
+
71
+ Content-aware auto-selector. Picks: `voice`/`speech` → psola, `tonal` → sms, else → transient.
72
+
73
+ ```js
74
+ import pitchShift from '@audio/shift'
75
+
76
+ pitchShift(audio, { semitones: 5 })
77
+ pitchShift(audio, { ratio: 1.5, content: 'voice' })
78
+ pitchShift(audio, { ratio: 2, method: 'formant' })
79
+ pitchShift(audio, { ratio: 1.5, method: 'delay' })
80
+ ```
81
+
82
+ | Param | Default | |
83
+ |---|---|---|
84
+ | `content` | `music` | `music`, `voice`/`speech`, `tonal` |
85
+ | `method` | auto | Force any algorithm by name, including `'delay'`/`'lpc'` |
86
+ | `formant` | `false` | Wrap in formant preservation |
87
+
88
+ `formant: true` together with an explicit, different `method` throws instead of silently overriding it — pass just one.
89
+
90
+
91
+ ## Frequency domain
92
+
93
+ ### `transient`
94
+
95
+ Peak-locked phase vocoder with spectral-flux transient detection. On transient frames, synthesis phase resets to analysis phase, preserving attacks. Between transients, behaves like `phaseLock`.
96
+
97
+ ```js
98
+ import { transient } from '@audio/shift'
99
+
100
+ transient(audio, { ratio: 1.5 })
101
+ transient(audio, { semitones: 5, transientThreshold: 2.0 })
102
+ ```
103
+
104
+ | Param | Default | |
105
+ |---|---|---|
106
+ | `transientThreshold` | `1.5` | z-score over log-flux EMA (higher = fewer resets) |
107
+
108
+ **Preserves** phase coherence, partial structure, attack localization on detected transients.<br>
109
+ **Destroys** formants; misses quiet transients at too-high threshold.
110
+
111
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
112
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
113
+ | 0.00 | 0.0 | 0.000 | 0.984 | 1.423 | 0.999 | 1.755 |
114
+
115
+ Byte-identical to `phaseLock` on every fixture in the quality suite — the onset detector's z-score gate never fires on them, not even the plucked string's attack. It's built for real percussive onsets (see the demo's rock-beat fixture, which `npm run quality` doesn't score); on this synthetic suite it's a phaseLock with unused wiring.
116
+
117
+ **Use when:** Music with drums — the default choice.<br>
118
+ **Not for:** Voice where formant preservation matters.
119
+
120
+
121
+ ### `phaseLock`
122
+
123
+ Laroche-Dolson peak-locked phase vocoder. Peaks scatter to shifted bins; non-peak bins lock their phase relative to the nearest peak, keeping the vertical phase relationship inside each sinusoidal lobe intact.
124
+
125
+ ```js
126
+ import { phaseLock } from '@audio/shift'
127
+
128
+ phaseLock(audio, { ratio: 1.5 })
129
+ ```
130
+
131
+ **Preserves** phase coherence around peaks, partial structure.<br>
132
+ **Destroys** transients (still smeared, less than `vocoder`), formants.
133
+
134
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
135
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
136
+ | 0.00 | 0.0 | 0.000 | 0.984 | 1.423 | 0.999 | 1.755 |
137
+
138
+ **Use when:** General music — the "try this first" phase vocoder.<br>
139
+ **Not for:** Music with drums (use `transient`), voice (use `formant`).
140
+
141
+
142
+ ### `vocoder`
143
+
144
+ SMB/Bernsee bin-shift. Computes true instantaneous frequency per bin from consecutive-frame phase advance, scatters peaks to shifted bins, accumulates synthesis phase at the shifted frequency.
145
+
146
+ ```js
147
+ import { vocoder } from '@audio/shift'
148
+
149
+ vocoder(audio, { ratio: 1.5 })
150
+ ```
151
+
152
+ **Preserves** dominant-partial pitch, long-horizon phase per bin.<br>
153
+ **Destroys** transients, vertical phase coherence ("phasiness"), formants.
154
+
155
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
156
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
157
+ | 0.00 | 0.0 | 0.000 | 0.981 | 1.158 | 0.928 | 1.553 |
158
+
159
+ Phase coh 0.928 from independent per-bin phase accumulation — no inter-bin locking. Best shift score among the general phase vocoders — the simpler scatter avoids the peak-locked family's own assignment overhead on these fixtures.
160
+
161
+ **Use when:** Simple tonal material, educational baseline.<br>
162
+ **Not for:** Music with percussion, voice.
163
+
164
+
165
+ ### `formant`
166
+
167
+ Cepstral envelope preservation wrapping a peak-locked vocoder. Extracts spectral envelope via cepstral liftering from temporally-smoothed magnitude, flattens the spectrum, applies peak-locked pitch shift on the flat residual, re-imposes the original envelope.
168
+
169
+ ```js
170
+ import { formant } from '@audio/shift'
171
+
172
+ formant(audio, { semitones: 5 })
173
+ formant(audio, { ratio: 0.75, envelopeWidth: 16 })
174
+ ```
175
+
176
+ | Param | Default | |
177
+ |---|---|---|
178
+ | `envelopeWidth` | `max(8, round(sr/1378))`, ≤ N/4 | Cepstrum lifter cutoff (quefrency bins) |
179
+
180
+ **Preserves** formant envelope (absolute Hz), vocal-tract character.<br>
181
+ **Destroys** transients (same as vocoder); risks cepstral ringing on sparse spectra.
182
+
183
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
184
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
185
+ | 0.00 | 0.0 | 0.000 | 0.987 | **0.765** | **1.000** | 1.573 |
186
+
187
+ Best formant dist in the collection by construction — the envelope is explicitly separated and re-applied. Also the best phase coherence (1.000): the correction reshapes magnitude only, so it inherits the peak-locked vocoder's phase tracking untouched, and gains a hair over `phaseLock` itself.
188
+
189
+ **Use when:** Voice shifting without chipmunk / giant artifact.<br>
190
+ **Not for:** Percussion-heavy material (transients smear).
191
+
192
+
193
+ ### `hpss`
194
+
195
+ Fitzgerald median-filter harmonic/percussive separation. Time-axis and frequency-axis medians produce soft Wiener masks splitting the spectrogram. Harmonic component is vocoder-shifted; percussive component passes through with original phase.
196
+
197
+ ```js
198
+ import { hpss } from '@audio/shift'
199
+
200
+ hpss(audio, { ratio: 1.5 })
201
+ hpss(audio, { ratio: 1.5, hpssTimeWidth: 31, hpssFreqWidth: 31 })
202
+ ```
203
+
204
+ | Param | Default | |
205
+ |---|---|---|
206
+ | `hpssTimeWidth` | `17` | Median window width (frames) |
207
+ | `hpssFreqWidth` | `17` | Median window width (bins) |
208
+ | `hpssPower` | `2` | Soft-mask exponent |
209
+
210
+ **Preserves** percussive onset locations (unshifted) and harmonic pitch (shifted).<br>
211
+ **Destroys** signal quality at ambiguous mask boundaries (leakage in both directions).
212
+
213
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
214
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
215
+ | 0.00 | 0.0 | 0.052 | **0.998** | 1.207 | 0.928 | **1.487** |
216
+
217
+ Best overall shift score — keeping percussion unshifted sidesteps most artifacts. It also has the best attack correlation in the whole collection (0.998): the percussive component's phase is never touched, so a plucked-string attack survives more faithfully than even the time-domain similarity-search methods. Alias 0.052 is residual harmonic energy leaking through the percussive mask.
218
+
219
+ **Use when:** Mixed music where drums should stay stationary while melody shifts.<br>
220
+ **Not for:** Solo tonal material (unnecessary separation overhead).
221
+
222
+
223
+ ### `sms`
224
+
225
+ Spectral Modeling Synthesis. Parabolic-interpolated peak picking builds sinusoidal tracks `(freq, mag, phase)`; each peak's lobe is copied intact to `round(f·ratio)`. Stochastic residual shifts to ratio-scaled bins with analysis phase.
226
+
227
+ ```js
228
+ import { sms } from '@audio/shift'
229
+
230
+ sms(audio, { ratio: 2 })
231
+ sms(audio, { ratio: 1.5, maxTracks: 40 })
232
+ ```
233
+
234
+ | Param | Default | |
235
+ |---|---|---|
236
+ | `maxTracks` | `Infinity` | Max simultaneous sinusoidal tracks |
237
+ | `minMag` | `1e-4` | Peak detection threshold (linear) |
238
+
239
+ **Preserves** formant envelope (lobes scale freely with peaks), harmonic structure, tonal clarity.<br>
240
+ **Destroys** transients, noise-like textures (absorbed into residual), polyphony beyond `maxTracks`.
241
+
242
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
243
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
244
+ | 0.00 | 0.0 | 0.002 | 0.963 | 1.845 | 0.929 | 1.701 |
245
+
246
+ Lower attack corr (0.963) because sinusoidal modeling smooths onset transients into the residual.
247
+
248
+ **Use when:** Sustained tonal / harmonic instruments, vowels.<br>
249
+ **Not for:** Percussion, noise-heavy material.
250
+
251
+
252
+ ### `paulstretch`
253
+
254
+ Large-frame (16k) phase randomization. Magnitudes pulled from source bins at `k/ratio`; phases drawn from a seeded PRNG every frame. Destroys temporal structure by design.
255
+
256
+ ```js
257
+ import { paulstretch } from '@audio/shift'
258
+
259
+ paulstretch(audio, { ratio: 1.5 })
260
+ paulstretch(audio, { ratio: 1.5, seed: 42 })
261
+ ```
262
+
263
+ | Param | Default | |
264
+ |---|---|---|
265
+ | `seed` | fixed (`0x1f123bb5`) | 32-bit PRNG seed for the per-frame phase draw |
266
+
267
+ Deterministic: the same `seed` (the shipped default, or one you pass) always reproduces the same phase sequence and therefore byte-identical output. Pass `seed` to get a different, still-reproducible draw.
268
+
269
+ **Preserves** long-term magnitude-spectrum statistics.<br>
270
+ **Destroys** phase, transients, rhythm — by design.
271
+
272
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
273
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
274
+ | 0.00 | 0.2 | 0.230 | 0.935 | 7.371 | 0.518 | 2.221 |
275
+
276
+ Worst formant dist by a wide margin (7.371 — the next-worst is `granular` at 3.486) because random phases smear spectral energy across the frame; the smear is the aesthetic. Alias 0.230 is the same smear leaking past Nyquist.
277
+
278
+ **Use when:** Ambient/drone textures, extreme shift ratios.<br>
279
+ **Not for:** Anything requiring temporal precision.
280
+
281
+
282
+ ## Time domain
283
+
284
+ ### `wsola`
285
+
286
+ WSOLA time-stretch + sinc resample. Searches each grain position ±`tolerance` samples for maximum cross-correlation with the previous grain's tail, eliminating phase cancellation before resampling to the target pitch.
287
+
288
+ ```js
289
+ import { wsola } from '@audio/shift'
290
+
291
+ wsola(audio, { ratio: 0.85 })
292
+ wsola(audio, { ratio: 1.5, tolerance: 512 })
293
+ ```
294
+
295
+ | Param | Default | |
296
+ |---|---|---|
297
+ | `tolerance` | `frameSize/4` | Similarity search radius (±samples) |
298
+
299
+ **Preserves** local waveform shape, attack envelopes.<br>
300
+ **Destroys** formants (shifted by resample), phase coherence across long spans.
301
+
302
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
303
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
304
+ | 1.67 | 0.1 | 0.005 | 0.995 | 2.358 | 0.864 | 1.674 |
305
+
306
+ f0 err 1.67 Hz from sinc resample quantization (time-domain algorithms round the stretch ratio to grain boundaries). Attack corr 0.995 ties `delay` for second place — only `hpss`'s untouched percussive pass-through scores higher.
307
+
308
+ **Use when:** Speech, low-latency, anywhere the phase vocoder's frame latency is unacceptable.<br>
309
+ **Not for:** Polyphonic music with sustained tones.
310
+
311
+
312
+ ### `psola`
313
+
314
+ PSOLA time-stretch + sinc resample. Autocorrelation detects pitch periods; two-period Hann grains are placed at pitch-synchronous intervals, reducing the grain-boundary artifacts plain WSOLA gets on voiced monophonic material. The final resample rescales the whole spectrum by `ratio`, so formants move with f0 exactly as they do for `wsola` — this is a smoother stretch, not formant preservation.
315
+
316
+ ```js
317
+ import { psola } from '@audio/shift'
318
+
319
+ psola(audio, { ratio: 0.75, sampleRate: 48000 })
320
+ psola(audio, { ratio: 1.5, minFreq: 100, maxFreq: 400 })
321
+ ```
322
+
323
+ | Param | Default | |
324
+ |---|---|---|
325
+ | `sampleRate` | `44100` | For pitch detection range |
326
+ | `minFreq` | `80` | Lowest expected pitch (Hz) |
327
+ | `maxFreq` | `500` | Highest expected pitch (Hz) |
328
+
329
+ **Preserves** waveform-per-period shape, voiced-speech naturalness.<br>
330
+ **Destroys** formants (same resample-driven shift as `wsola`), polyphony (assumes single pitch contour), unvoiced regions (pitch-mark jitter); falls back to plain WSOLA internally when no reliable pitch period is found.
331
+
332
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
333
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
334
+ | 0.66 | 0.2 | 0.005 | 0.941 | 2.336 | 0.998 | 1.766 |
335
+
336
+ Phase coherence 0.998 — pitch-synchronous grains align with the waveform period almost perfectly, just short of `formant`'s 1.000. Lower attack corr (0.941) from pitch-mark jitter on non-periodic onsets.
337
+
338
+ **Use when:** Monophonic speech, solo voice, single melodic instrument.<br>
339
+ **Not for:** Polyphonic material, chords, or anywhere formants must stay put (use `lpc`).
340
+
341
+
342
+ ### `delay`
343
+
344
+ Canonical delay-line (harmonizer) pitch shift — the method behind hardware harmonizers (Eventide H910 lineage, Lexicon "rotating tape head"). Two read taps sweep a modulated delay window at rate `ratio`, alternating through a Hann crossfade a half-cycle apart. Each time a tap wraps, it splices at whichever lag offset in the search window best correlates with the still-live tap ("intelligent splicing"), which is what keeps the join from phase-slipping the carrier.
345
+
346
+ ```js
347
+ import { delay } from '@audio/shift'
348
+
349
+ delay(audio, { ratio: 1.5 })
350
+ delay(audio, { ratio: 1.5, window: 1024, tolerance: 128 })
351
+ ```
352
+
353
+ | Param | Default | |
354
+ |---|---|---|
355
+ | `window` | `2048` | Delay-line length (samples); trades flutter rate against transient smear |
356
+ | `tolerance` | `window/4` | Splice search radius (±samples) |
357
+
358
+ **Preserves** duration, pitch accuracy — no grain or frame to quantize the shift against; state is bounded by `window` samples, the shape a real-time implementation needs.<br>
359
+ **Destroys** clean sustain on wideband or polyphonic material — the two taps share one delay line, so simultaneous partials all splice through the same wrap points and beat against each other; audible as mild flutter at the crossfade rate.
360
+
361
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
362
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
363
+ | 1.10 | 0.1 | 0.028 | 0.995 | 2.445 | 0.940 | 1.610 |
364
+
365
+ f0 err 1.10 Hz sits at the measurement floor — nothing here to quantize pitch against. Formant dist 2.445 is unremarkable: the shared delay line resamples the envelope exactly like a time-domain stretch does, no better.
366
+
367
+ The streaming `write`/`flush` wrapper shipped here buffers the whole input like `hpss`/`hybrid` (see [Runtime behavior](#runtime-behavior)) — the algorithm's own state is bounded by `window` samples, but nothing yet exposes that as incremental output.
368
+
369
+ **Use when:** Real-time-shaped, lowest-latency time-domain shifting, or the hardware-harmonizer flutter is the wanted character.<br>
370
+ **Not for:** Dense chords or polyphony (the splice artifacts stack per partial).
371
+
372
+
373
+ ### `ola`
374
+
375
+ Plain OLA time-stretch + sinc resample. Overlap-add without similarity search — the baseline the others improve on.
376
+
377
+ ```js
378
+ import { ola } from '@audio/shift'
379
+
380
+ ola(audio, { ratio: 1.5 })
381
+ ```
382
+
383
+ **Preserves** amplitude envelope.<br>
384
+ **Destroys** pitch accuracy, formants, transients, phase coherence.
385
+
386
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
387
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
388
+ | 38.33 | 0.2 | 0.004 | 0.980 | 2.342 | 0.995 | 2.025 |
389
+
390
+ f0 err 38.33 Hz — worst by far. Without similarity search, grains land at arbitrary phase offsets causing destructive interference that shifts the perceived pitch.
391
+
392
+ **Use when:** Reference baseline, or the simplest possible shift for comparison.<br>
393
+ **Not for:** Anything quality-sensitive.
394
+
395
+
396
+ ### `granular`
397
+
398
+ Native granular pitch shift: fixed-size Hann grains laid at a constant output hop, each read from the source through an anti-aliased sinc stride read (no separate stretch+resample stage). Small grains make the per-grain splice audible as a signature grain-rate texture — that texture is the point, not a defect.
399
+
400
+ ```js
401
+ import { granular } from '@audio/shift'
402
+
403
+ granular(audio, { ratio: 1.3 })
404
+ granular(audio, { ratio: 1.3, grainSize: 1024 })
405
+ ```
406
+
407
+ | Param | Default | |
408
+ |---|---|---|
409
+ | `grainSize` | `398` | Grain length in samples |
410
+
411
+ **Preserves** grain-local timbre, characteristic textural quality.<br>
412
+ **Destroys** pitch accuracy on complex tones, smooth envelopes — the 398-sample default is tuned so a chord's partials crumble audibly (~14% RMS loss) while a clean 440 Hz tone still tracks true; that crumble on chords is documented character, not a bug.
413
+
414
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
415
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
416
+ | 1.45 | 0.0 | 0.033 | 0.996 | 3.486 | 0.997 | 2.256 |
417
+
418
+ Worst shift score and worst formant dist among all 15 algorithms — small, uncorrelated grains smear the spectrum more than even `paulstretch`'s random phase does. Raise `grainSize` toward 1024+ for a cleaner, less textural shift; the default favors character over transparency.
419
+
420
+ **Use when:** Creative/textural effects where grain character is desired.<br>
421
+ **Not for:** Transparent pitch shifting.
422
+
423
+
424
+ ### `sample`
425
+
426
+ Playback-rate pitch shift. Hann-windowed sinc interpolation at a fractional read-head stepped by `ratio` per output sample. No time preservation — higher pitch = shorter clip.
427
+
428
+ ```js
429
+ import { sample } from '@audio/shift'
430
+
431
+ sample(instrumentBuffer, { semitones: 7 })
432
+ sample(audio, { ratio: 2, sincRadius: 16 })
433
+ ```
434
+
435
+ | Param | Default | |
436
+ |---|---|---|
437
+ | `sincRadius` | `8` | Windowed-sinc half-width (samples) |
438
+
439
+ **Preserves** waveform identity (literally the same audio, faster/slower), formants — everything scales together.<br>
440
+ **Destroys** time: output duration = `input_length / ratio`, zero-padded to match API.
441
+
442
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
443
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
444
+ | 2.50 | 0.1 | 0.007 | 0.951 | 2.330 | 0.170 | 1.614 |
445
+
446
+ Phase coh 0.170 because the modulation rate itself shifts with the pitch (a 5 Hz tremolo becomes 7.5 Hz at ratio 1.5). This is correct behavior for a sampler — not an artifact.
447
+
448
+ **Use when:** Instrument one-shots, ROM-sample playback, tracker-style.<br>
449
+ **Not for:** Time-preserving pitch shift.
450
+
451
+
452
+ ### `hybrid`
453
+
454
+ Runs `phaseLock` and `wsola` in parallel, crossfades sample-by-sample by spectral-flux transient confidence. Tonal regions resolve via the phase vocoder; attacks resolve via WSOLA similarity search, time-aligned against phaseLock before blending so the two engines' attacks land together.
455
+
456
+ ```js
457
+ import { hybrid } from '@audio/shift'
458
+
459
+ hybrid(audio, { ratio: 1.5 })
460
+ hybrid(audio, { ratio: 1.5, hybridThreshold: 0.6 })
461
+ ```
462
+
463
+ | Param | Default | |
464
+ |---|---|---|
465
+ | `hybridThreshold` | `0.8` | Spectral-flux z-score for full WSOLA blend |
466
+
467
+ **Preserves** tonal phase coherence + attack shape — simultaneously.<br>
468
+ **Destroys** CPU budget (≈2×), formants.
469
+
470
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
471
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
472
+ | 0.00 | 0.0 | 0.000 | 0.984 | 1.423 | 0.999 | 1.824 |
473
+
474
+ Same phaseLock-derived numbers as `phaseLock`/`transient` on this non-percussive suite — the WSOLA blend never activates because nothing here crosses `hybridThreshold`. Worst shift score among the frequency-adjacent methods for the same reason: it's carrying `wsola`'s parallel computation for a blend that stays at zero.
475
+
476
+ **Use when:** Mixed dynamic material where a single domain compromises the other.<br>
477
+ **Not for:** Pure tonal (just use `phaseLock`) or pure percussive (just use `transient`).
478
+
479
+
480
+ ## Source-filter
481
+
482
+ ### `lpc`
483
+
484
+ Canonical LPC source-filter pitch shift (residual-excited linear prediction, RELP lineage). Per frame: fit the vocal-tract all-pole filter A(z) by the autocorrelation method (Levinson-Durbin), inverse-filter the signal down to its spectrally-flat excitation residual, repitch that residual with the `delay` line splicer, then resynthesize through the **unmodified** 1/A(z) run continuously with block-switched coefficients. The synthesis filter's poles never move — the classical speech-processing complement to `formant`'s cepstral envelope preservation, built on a different mechanism (an explicit source-filter model instead of a magnitude-envelope correction).
485
+
486
+ ```js
487
+ import { lpc } from '@audio/shift'
488
+
489
+ lpc(audio, { ratio: 1.5 })
490
+ lpc(audio, { ratio: 1.5, order: 24, frameSize: 512 })
491
+ ```
492
+
493
+ | Param | Default | |
494
+ |---|---|---|
495
+ | `order` | `min(frameSize/16, round(2 + sr/1000))` | AR filter order (pole count) |
496
+ | `frameSize` | `1024` | Analysis/synthesis frame length |
497
+
498
+ **Preserves** formant frequencies by construction — the filter that carries them is never touched, only its excitation is repitched.<br>
499
+ **Destroys** pure tones by design: on a single sinusoid the AR envelope IS the partial, so the filter locks onto it and pitch barely moves — the family's defining tradeoff, not a bug.
500
+
501
+ | f0 err | THD% | alias | attack corr | formant dist | phase coh | shift |
502
+ |-------:|-----:|------:|------------:|-------------:|----------:|------:|
503
+ | 228.33 | 0.8 | 2.274 | 0.987 | 1.382 | 0.975 | 1.811 |
504
+
505
+ f0 err 228.33 Hz and alias 2.274 — both worst in the collection, and both the same degeneracy: on the alias test's 14 kHz tone (pushed toward Nyquist), the AR fit locks onto that single partial and the high-order synthesis filter rings into sharp amplitude spikes rather than shifting cleanly. Formant dist 1.382 is higher (worse) than `formant`'s 0.765 on this synthetic vowel too, across every `order` from 8 to 64 — the fitted envelope follows the fixture's individual harmonics as well as its three formants, where `formant`'s deliberately-smoothed cepstral envelope doesn't. None of this is the intended material: real, less strictly periodic speech is what an unmodified synthesis filter is for.
506
+
507
+ **Use when:** Speech/vocals where the formants must not move with pitch, and the material isn't a bare sustained tone.<br>
508
+ **Not for:** Pure tones, synth pads, or anything where the "partial" and the "envelope" are the same thing.
509
+
510
+
511
+ ## Variable pitch
512
+
513
+ `vocoder`, `phaseLock`, `transient`, `formant`, `paulstretch`, `sms`, `hpss`, `sample`, `delay`, and `lpc` accept a time-varying `ratio` — a function `(t) => ratio` or a `Float32Array` (paired with `ratioDuration` in seconds, defaulting to the array's own length at `sampleRate`). `ola`, `wsola`, `psola`, `granular`, and `hybrid` apply a single global ratio and reject a variable one.
514
+
515
+ ```js
516
+ // Vibrato: ±10% at 5 Hz
517
+ let vibrato = phaseLock(audio, {
518
+ ratio: (t) => 1 + 0.1 * Math.sin(2 * Math.PI * 5 * t),
519
+ sampleRate: 44100,
520
+ })
521
+ ```
522
+
523
+ #### Pitch correction
524
+
525
+ Combine with a pitch detector: detect per-frame f0, snap to target scale, pass as `ratio` function. Use `formant` for natural voice, `phaseLock` for hard-tune effect, `sms` for harmonic instruments, `lpc` where formant fidelity matters most.
526
+
527
+ ```js
528
+ import { yin } from '@audio/pitch'
529
+ import { formant } from '@audio/shift'
530
+
531
+ let hop = 512, sr = 44100
532
+ let pitchFrames = []
533
+ for (let i = 0; i + 2048 <= audio.length; i += hop) {
534
+ let r = yin(audio.subarray(i, i + 2048), { fs: sr })
535
+ pitchFrames.push(r ? { freq: r.freq, clarity: r.clarity } : null)
536
+ }
537
+
538
+ let scale = [261.63, 293.66, 329.63, 349.23, 392.00, 440.00, 493.88]
539
+ let snap = (f) => scale.reduce((a, b) =>
540
+ Math.abs(Math.log2(b / f)) < Math.abs(Math.log2(a / f)) ? b : a
541
+ )
542
+
543
+ let corrected = formant(audio, {
544
+ ratio: (t) => {
545
+ let p = pitchFrames[Math.min(Math.round(t * sr / hop), pitchFrames.length - 1)]
546
+ return (!p || p.clarity < 0.5) ? 1 : snap(p.freq) / p.freq
547
+ },
548
+ sampleRate: sr,
549
+ })
550
+ ```
551
+
552
+ ## Quality Tools
553
+
554
+ ```bash
555
+ npm test # correctness
556
+ npm run quality # measured metrics
557
+ npm run bench # performance
558
+ ```
559
+
560
+ <details><summary>Full quality table</summary>
561
+
562
+ | Algorithm | f0 err | THD% | alias | stream corr | cent err | onset err | attack corr | formant dist | phase coh | shift |
563
+ |-----------|-------:|-----:|------:|------------:|---------:|----------:|------------:|-------------:|----------:|------:|
564
+ | `hpss` | 0.00 | 0.0 | 0.052 | 1.000 | 0.013 | 0.000 | **0.998** | 1.207 | 0.928 | **1.487** |
565
+ | `vocoder` | 0.00 | 0.0 | 0.000 | 1.000 | 0.009 | 0.000 | 0.981 | 1.158 | 0.928 | 1.553 |
566
+ | `formant` | 0.00 | 0.0 | 0.000 | 1.000 | 0.058 | 0.000 | 0.987 | **0.765** | **1.000** | 1.573 |
567
+ | `delay` | 1.10 | 0.1 | 0.028 | 1.000 | 0.004 | 0.000 | 0.995 | 2.445 | 0.940 | 1.610 |
568
+ | `sample` | 2.50 | 0.1 | 0.007 | 1.000 | 0.004 | 0.000 | 0.951 | 2.330 | 0.170 | 1.614 |
569
+ | `wsola` | 1.67 | 0.1 | 0.005 | 1.000 | 0.003 | 0.000 | 0.995 | 2.358 | 0.864 | 1.674 |
570
+ | `sms` | 0.00 | 0.0 | 0.002 | 1.000 | 0.002 | 0.000 | 0.963 | 1.845 | 0.929 | 1.701 |
571
+ | `phaseLock` | 0.00 | 0.0 | 0.000 | 1.000 | 0.008 | 0.000 | 0.984 | 1.423 | 0.999 | 1.755 |
572
+ | `pitchShift` | 0.00 | 0.0 | 0.000 | 1.000 | 0.008 | 0.000 | 0.984 | 1.423 | 0.999 | 1.755 |
573
+ | `transient` | 0.00 | 0.0 | 0.000 | 1.000 | 0.008 | 0.000 | 0.984 | 1.423 | 0.999 | 1.755 |
574
+ | `psola` | 0.66 | 0.2 | 0.005 | 1.000 | 0.003 | 0.000 | 0.941 | 2.336 | 0.998 | 1.766 |
575
+ | `lpc` | 228.33 | 0.8 | 2.274 | 1.000 | 0.265 | 0.000 | 0.987 | 1.382 | 0.975 | 1.811 |
576
+ | `hybrid` | 0.00 | 0.0 | 0.000 | 1.000 | 0.000 | 0.000 | 0.984 | 1.423 | 0.999 | 1.824 |
577
+ | `ola` | 38.33 | 0.2 | 0.004 | 1.000 | 0.042 | 0.388 | 0.980 | 2.342 | 0.995 | 2.025 |
578
+ | `paulstretch` | 0.00 | 0.2 | 0.230 | 1.000 | 0.043 | 0.000 | 0.935 | 7.371 | 0.518 | 2.221 |
579
+ | `granular` | 1.45 | 0.0 | 0.033 | 1.000 | 0.040 | 0.452 | 0.996 | 3.486 | 0.997 | 2.256 |
580
+
581
+ <details><summary>Column definitions</summary>
582
+
583
+ - **f0 err** (Hz) — pitch accuracy shifting 440→660 Hz sine.
584
+ - **THD%** — harmonic distortion on shifted pure sine.
585
+ - **alias** — energy above Nyquist when shifting 14 kHz ×2.
586
+ - **stream corr** — streaming vs batch correlation. 1.000 everywhere — see [Runtime behavior](#runtime-behavior).
587
+ - **cent err** — spectral centroid ratio error on a 3-partial chord.
588
+ - **onset err** — impulse-train period error after shift.
589
+ - **attack corr** — plucked-string attack envelope correlation. Bold = leader.
590
+ - **formant dist** — cepstral envelope distance on synthetic vowel. Lower = formants preserved. Bold = leader.
591
+ - **phase coh** — AM-envelope coherence on 5 Hz tremolo. Bold = leader.
592
+ - **shift** — log-magnitude distance to canonical shifted reference, averaged over four fixtures. Lower = better. Bold = leader.
593
+
594
+ </details>
595
+ </details>
596
+
597
+
598
+ ## Dependencies
599
+
600
+ - [stretch](https://github.com/audiojs/stretch) — Time-domain stretchers, used by `ola`/`wsola`/`psola` only
601
+ - [fourier-transform](https://github.com/audiojs/fourier-transform) — FFT, used by the STFT family + `hybrid`
602
+
603
+ Every other algorithm (`granular`, `sample`, `delay`, `lpc`) owns its primitives directly — no FFT, no external stretcher.
604
+
605
+ ## Migration from v0.0.0
606
+
607
+ Previously held by [mikolalysenko/pitch-shift](https://github.com/mikolalysenko/pitch-shift) (2013, v0.0.0) — a single WSOLA/TD-PSOLA implementation. Available here as [`wsola`](#algorithms) or [`psola`](#algorithms) with batch, streaming, and multi-channel support.
608
+
609
+ ```js
610
+ // v0.0.0 (old)
611
+ var shifter = require('@audio/shift')(onData, t => ratio, { frameSize: 2048 })
612
+ shifter.feed(float32Array)
613
+
614
+ // v1 (this package)
615
+ import { wsola } from '@audio/shift'
616
+ let write = wsola({ ratio })
617
+ let out = write(float32Array)
618
+ let tail = write() // flush
619
+ ```
620
+
621
+ ## Related
622
+
623
+ - [stretch](https://github.com/audiojs/stretch) — Time stretching
624
+ - [filter](https://github.com/audiojs/filter) — Audio filters
625
+
626
+
627
+ <p align="center"><a href="./license.md">MIT</a> · <a href="https://github.com/krishnized/license">ॐ</a></p>
package/index.d.ts ADDED
@@ -0,0 +1,96 @@
1
+ export type PitchShiftBuffer = Float32Array
2
+ export type PitchShiftChannels = Float32Array[]
3
+ export type PitchShiftInput = PitchShiftBuffer | PitchShiftChannels
4
+
5
+ type Writer = (chunk?: PitchShiftInput) => PitchShiftInput
6
+
7
+ export interface PitchShiftDecision {
8
+ method: string
9
+ reason: string
10
+ ratio: number
11
+ semitones: number
12
+ content?: 'music' | 'voice' | 'speech' | 'tonal'
13
+ formant: boolean
14
+ }
15
+
16
+ export type PitchShiftMethodName =
17
+ | 'ola'
18
+ | 'vocoder'
19
+ | 'phaseLock'
20
+ | 'phase-lock'
21
+ | 'transient'
22
+ | 'psola'
23
+ | 'wsola'
24
+ | 'granular'
25
+ | 'paulstretch'
26
+ | 'sms'
27
+ | 'hpss'
28
+ | 'sample'
29
+ | 'hybrid'
30
+ | 'formant'
31
+ | 'delay'
32
+ | 'lpc'
33
+
34
+ export type PitchShiftMethod =
35
+ | PitchShiftMethodName
36
+ | {
37
+ (data: Float32Array, opts?: PitchShiftOpts): Float32Array
38
+ (data: Float32Array[], opts?: PitchShiftOpts): Float32Array[]
39
+ (opts?: PitchShiftOpts): Writer
40
+ }
41
+
42
+ export type PitchRatio = number | ((timeSeconds: number) => number) | Float32Array
43
+
44
+ export interface PitchShiftOpts {
45
+ ratio?: PitchRatio
46
+ ratioDuration?: number
47
+ semitones?: number
48
+ formant?: boolean
49
+ content?: 'music' | 'voice' | 'speech' | 'tonal'
50
+ method?: PitchShiftMethod
51
+ onDecision?: (decision: PitchShiftDecision) => void
52
+ frameSize?: number
53
+ hopSize?: number
54
+ sampleRate?: number
55
+ transientThreshold?: number
56
+ minFreq?: number
57
+ maxFreq?: number
58
+ envelopeWidth?: number
59
+ maxTracks?: number
60
+ minMag?: number
61
+ tolerance?: number
62
+ hpssTimeWidth?: number
63
+ hpssFreqWidth?: number
64
+ hpssPower?: number
65
+ sincRadius?: number
66
+ hybridThreshold?: number
67
+ window?: number
68
+ order?: number
69
+ grainSize?: number
70
+ seed?: number
71
+ }
72
+
73
+ type PitchShiftFn = {
74
+ (data: Float32Array, opts?: PitchShiftOpts): Float32Array
75
+ (data: Float32Array[], opts?: PitchShiftOpts): Float32Array[]
76
+ (opts?: PitchShiftOpts): Writer
77
+ }
78
+
79
+ export declare const ola: PitchShiftFn
80
+ export declare const vocoder: PitchShiftFn
81
+ export declare const phaseLock: PitchShiftFn
82
+ export declare const transient: PitchShiftFn
83
+ export declare const psola: PitchShiftFn
84
+ export declare const wsola: PitchShiftFn
85
+ export declare const granular: PitchShiftFn
86
+ export declare const formant: PitchShiftFn
87
+ export declare const paulstretch: PitchShiftFn
88
+ export declare const sms: PitchShiftFn
89
+ export declare const hpss: PitchShiftFn
90
+ export declare const sample: PitchShiftFn
91
+ export declare const hybrid: PitchShiftFn
92
+ export declare const delay: PitchShiftFn
93
+ export declare const lpc: PitchShiftFn
94
+
95
+ export declare const pitchShift: PitchShiftFn
96
+ export default pitchShift
package/index.js ADDED
@@ -0,0 +1,17 @@
1
+ export { default as ola } from '@audio/shift-ola'
2
+ export { default as vocoder } from '@audio/shift-pvoc'
3
+ export { default as phaseLock } from '@audio/shift-pvoc-lock'
4
+ export { default as transient } from '@audio/shift-transient'
5
+ export { default as psola } from '@audio/shift-psola'
6
+ export { default as wsola } from '@audio/shift-wsola'
7
+ export { default as granular } from '@audio/shift-granular'
8
+ export { default as formant } from '@audio/shift-formant'
9
+ export { default as paulstretch } from '@audio/shift-paulstretch'
10
+ export { default as sms } from '@audio/shift-sms'
11
+ export { default as hpss } from '@audio/shift-hpss'
12
+ export { default as sample } from '@audio/shift-sample'
13
+ export { default as hybrid } from '@audio/shift-hybrid'
14
+ export { default as delay } from '@audio/shift-delay'
15
+ export { default as lpc } from '@audio/shift-lpc'
16
+ export { default as pitchShift } from './pitch-shift.js'
17
+ export { default } from './pitch-shift.js'
package/license.md ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Dmitry Iv. <dfcreative@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "version": "1.0.0",
3
+ "name": "@audio/shift",
4
+ "type": "module",
5
+ "types": "index.d.ts",
6
+ "description": "Canonical pitch-shifting algorithms: phase vocoder, peak-locked, transient-aware, PSOLA, WSOLA, granular, PaulStretch, SMS, HPSS, formant, sampler, hybrid",
7
+ "sideEffects": false,
8
+ "main": "index.js",
9
+ "exports": {
10
+ ".": "./index.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "index.d.ts",
16
+ "pitch-shift.js"
17
+ ],
18
+ "workspaces": [
19
+ "packages/*"
20
+ ],
21
+ "scripts": {
22
+ "test": "node test.js",
23
+ "quality": "node scripts/quality.js",
24
+ "quality:ci": "node scripts/quality.js --ci",
25
+ "bench": "node scripts/bench.js",
26
+ "prepublishOnly": "node test.js && node scripts/quality.js --ci"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/audiojs/shift.git"
31
+ },
32
+ "keywords": [
33
+ "pitch-shift",
34
+ "pitch-shifting",
35
+ "phase-vocoder",
36
+ "peak-locked",
37
+ "transient",
38
+ "psola",
39
+ "wsola",
40
+ "formant",
41
+ "granular",
42
+ "paulstretch",
43
+ "sms",
44
+ "sinusoidal",
45
+ "hpss",
46
+ "sampler",
47
+ "hybrid",
48
+ "variable-pitch",
49
+ "audio",
50
+ "dsp",
51
+ "signal-processing"
52
+ ],
53
+ "author": "Dmitry Iv. <dfcreative@gmail.com>",
54
+ "license": "MIT",
55
+ "bugs": {
56
+ "url": "https://github.com/audiojs/shift/issues"
57
+ },
58
+ "homepage": "https://github.com/audiojs/shift#readme",
59
+ "engines": {
60
+ "node": ">=18"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "dependencies": {
66
+ "@audio/shift-core": "^1.0.0",
67
+ "@audio/shift-delay": "^1.0.0",
68
+ "@audio/shift-formant": "^1.0.0",
69
+ "@audio/shift-granular": "^1.0.0",
70
+ "@audio/shift-lpc": "^1.0.0",
71
+ "@audio/shift-hpss": "^1.0.0",
72
+ "@audio/shift-hybrid": "^1.0.0",
73
+ "@audio/shift-ola": "^1.0.0",
74
+ "@audio/shift-paulstretch": "^1.0.0",
75
+ "@audio/shift-psola": "^1.0.0",
76
+ "@audio/shift-pvoc": "^1.0.0",
77
+ "@audio/shift-pvoc-lock": "^1.0.0",
78
+ "@audio/shift-sample": "^1.0.0",
79
+ "@audio/shift-sms": "^1.0.0",
80
+ "@audio/shift-transient": "^1.0.0",
81
+ "@audio/shift-wsola": "^1.0.0"
82
+ },
83
+ "devDependencies": {
84
+ "audio-lena": "^3.0.0",
85
+ "fourier-transform": "^2.3.0",
86
+ "time-stretch": "^1.2.1",
87
+ "tst": "^9.4.0"
88
+ }
89
+ }
package/pitch-shift.js ADDED
@@ -0,0 +1,118 @@
1
+ import {
2
+ createChannelWriter, isChannelArray, mapInput, normalizeOptionsInput,
3
+ passThroughWriter, resolveRatio,
4
+ } from '@audio/shift-core'
5
+ import formant from '@audio/shift-formant'
6
+ import ola from '@audio/shift-ola'
7
+ import vocoder from '@audio/shift-pvoc'
8
+ import phaseLock from '@audio/shift-pvoc-lock'
9
+ import transient from '@audio/shift-transient'
10
+ import psola from '@audio/shift-psola'
11
+ import wsola from '@audio/shift-wsola'
12
+ import granular from '@audio/shift-granular'
13
+ import paulstretch from '@audio/shift-paulstretch'
14
+ import sms from '@audio/shift-sms'
15
+ import hpss from '@audio/shift-hpss'
16
+ import sample from '@audio/shift-sample'
17
+ import hybrid from '@audio/shift-hybrid'
18
+ import delay from '@audio/shift-delay'
19
+ import lpc from '@audio/shift-lpc'
20
+
21
+ function selectMethod(opts) {
22
+ if (typeof opts?.method === 'function') {
23
+ return { fn: opts.method, name: opts.method.name || 'custom', reason: 'explicit-method' }
24
+ }
25
+ switch (opts?.method) {
26
+ case 'ola': return { fn: ola, name: 'ola', reason: 'explicit-method' }
27
+ case 'vocoder': return { fn: vocoder, name: 'vocoder', reason: 'explicit-method' }
28
+ case 'phase-lock':
29
+ case 'phaseLock': return { fn: phaseLock, name: 'phaseLock', reason: 'explicit-method' }
30
+ case 'transient': return { fn: transient, name: 'transient', reason: 'explicit-method' }
31
+ case 'formant': return { fn: formant, name: 'formant', reason: 'explicit-method' }
32
+ case 'psola': return { fn: psola, name: 'psola', reason: 'explicit-method' }
33
+ case 'wsola': return { fn: wsola, name: 'wsola', reason: 'explicit-method' }
34
+ case 'granular': return { fn: granular, name: 'granular', reason: 'explicit-method' }
35
+ case 'paulstretch': return { fn: paulstretch, name: 'paulstretch', reason: 'explicit-method' }
36
+ case 'sms': return { fn: sms, name: 'sms', reason: 'explicit-method' }
37
+ case 'hpss': return { fn: hpss, name: 'hpss', reason: 'explicit-method' }
38
+ case 'sample': return { fn: sample, name: 'sample', reason: 'explicit-method' }
39
+ case 'hybrid': return { fn: hybrid, name: 'hybrid', reason: 'explicit-method' }
40
+ case 'delay': return { fn: delay, name: 'delay', reason: 'explicit-method' }
41
+ case 'lpc': return { fn: lpc, name: 'lpc', reason: 'explicit-method' }
42
+ }
43
+ switch (opts?.content) {
44
+ case 'voice':
45
+ case 'speech': return { fn: psola, name: 'psola', reason: `content:${opts.content}` }
46
+ case 'tonal': return { fn: sms, name: 'sms', reason: 'content:tonal' }
47
+ default: return { fn: transient, name: 'transient', reason: 'fallback:transient' }
48
+ }
49
+ }
50
+
51
+ function notifyDecision(opts, params, decision) {
52
+ if (typeof opts?.onDecision !== 'function') return
53
+ opts.onDecision({
54
+ method: decision.name,
55
+ reason: decision.reason,
56
+ ratio: params.ratio,
57
+ semitones: params.semitones,
58
+ content: opts?.content,
59
+ formant: !!opts?.formant,
60
+ })
61
+ }
62
+
63
+ // `ratio` is a function/Float32Array whenever pitch varies over time — never identity,
64
+ // regardless of its value at t=0 (matches shift-core's makePitchShift.isIdentity).
65
+ function isVariableRatio(opts) {
66
+ let raw = opts?.ratio
67
+ return typeof raw === 'function' || raw instanceof Float32Array
68
+ }
69
+
70
+ function isIdentity(opts) {
71
+ if (isVariableRatio(opts)) return false
72
+ return resolveRatio(opts).ratio === 1
73
+ }
74
+
75
+ // `formant: true` wraps whichever method runs (README: "Wrap in formant preservation") —
76
+ // but shift-formant is a single self-contained algorithm (its own peak-lock shift fused
77
+ // with envelope extraction/reimposition), not a post-processor any other method's output
78
+ // can be piped through. True wrap semantics would need a new cross-package pipeline (STFT-
79
+ // analyze an arbitrary algorithm's output, re-impose the original's envelope on it); absent
80
+ // that, silently discarding an explicit `method` is the wrong failure mode — fail loudly.
81
+ function decide(opts) {
82
+ let { ratio } = resolveRatio(opts)
83
+ let params = { ratio, semitones: opts?.semitones ?? 0 }
84
+ if (opts?.formant) {
85
+ let method = opts?.method
86
+ if (method != null && method !== 'formant') {
87
+ let name = typeof method === 'function' ? (method.name || 'custom') : method
88
+ throw new TypeError(`pitchShift: \`formant: true\` conflicts with explicit \`method: '${name}'\` — choose one`)
89
+ }
90
+ let decision = { fn: formant, name: 'formant', reason: 'formant:true' }
91
+ notifyDecision(opts, params, decision)
92
+ return decision
93
+ }
94
+ let decision = selectMethod(opts)
95
+ notifyDecision(opts, params, decision)
96
+ return decision
97
+ }
98
+
99
+ function shiftAuto(data, opts) {
100
+ return decide(opts).fn(data, opts)
101
+ }
102
+
103
+ function createWriter(opts) {
104
+ let writer = decide(opts).fn(opts)
105
+ if (typeof writer !== 'function') {
106
+ throw new TypeError('pitchShift: selected streaming method must return a writer')
107
+ }
108
+ return writer
109
+ }
110
+
111
+ export default function pitchShift(data, opts) {
112
+ if (data instanceof Float32Array || isChannelArray(data)) {
113
+ return mapInput(data, shiftAuto, opts)
114
+ }
115
+ opts = normalizeOptionsInput(data)
116
+ if (isIdentity(opts)) return createChannelWriter(() => passThroughWriter())
117
+ return createChannelWriter(() => createWriter(opts))
118
+ }