@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 +627 -0
- package/index.d.ts +96 -0
- package/index.js +17 -0
- package/license.md +21 -0
- package/package.json +89 -0
- package/pitch-shift.js +118 -0
package/README.md
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
# @audio/shift [](https://github.com/audiojs/shift/actions/workflows/test.yml) [](https://www.npmjs.com/package/@audio/shift) [](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
|
+
}
|