@audio/shift-paulstretch 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +87 -0
- package/package.json +40 -0
package/index.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { stftBatch } from '@audio/shift-core/stft'
|
|
2
|
+
import { bufferedStream, makeFrameRatio, makePitchShift, resolveRatio, PI2 } from '@audio/shift-core'
|
|
3
|
+
|
|
4
|
+
// Peak-match (not RMS-match) because the random-phase reconstruction is noise-like:
|
|
5
|
+
// its sample distribution is approximately Gaussian with peaks at ~3× RMS. Matching RMS
|
|
6
|
+
// to the input (which for a tone has RMS ≈ peak / √2) would push peaks to ~2× the input
|
|
7
|
+
// peak — audible clipping. Peak-match keeps the output within the input's dynamic range
|
|
8
|
+
// at the cost of a quieter RMS, which is the correct trade-off for a textural blurrer.
|
|
9
|
+
function matchPeak(out, ref) {
|
|
10
|
+
let po = 0, pr = 0
|
|
11
|
+
for (let i = 0; i < out.length; i++) { let v = out[i]; if (v < 0) v = -v; if (v > po) po = v }
|
|
12
|
+
for (let i = 0; i < ref.length; i++) { let v = ref[i]; if (v < 0) v = -v; if (v > pr) pr = v }
|
|
13
|
+
if (po < 1e-9 || pr < 1e-9) return out
|
|
14
|
+
let g = pr / po
|
|
15
|
+
for (let i = 0; i < out.length; i++) out[i] *= g
|
|
16
|
+
return out
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// mulberry32 — smallest standard PRNG with no known short-period/low-bit weaknesses (unlike
|
|
20
|
+
// a bare LCG). Deterministic per seed: same seed always reproduces the same phase sequence
|
|
21
|
+
// and therefore the same output, run to run. Default seed is fixed, so paulstretch(...) with
|
|
22
|
+
// no `opts.seed` is itself deterministic; pass `opts.seed` (any 32-bit int) to vary the draw.
|
|
23
|
+
const DEFAULT_SEED = 0x1f123bb5
|
|
24
|
+
function mulberry32(seed) {
|
|
25
|
+
let a = seed >>> 0
|
|
26
|
+
return function () {
|
|
27
|
+
a = (a + 0x6d2b79f5) | 0
|
|
28
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
29
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
30
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Paulstretch-style pitch shift: large frames, phases randomized uniformly in [0, 2π),
|
|
35
|
+
// magnitudes gathered from source-bin k/ratio. Destroys temporal transients by design,
|
|
36
|
+
// producing the signature smooth, textural timbre — now shifted in pitch.
|
|
37
|
+
|
|
38
|
+
function process(mag, phase, state, ctx) {
|
|
39
|
+
if (!state.fr) state.fr = makeFrameRatio(ctx.ratioFn || ctx.ratio || 1)
|
|
40
|
+
let { half } = ctx
|
|
41
|
+
let ratio = state.fr.at(ctx.frameStart, ctx.sampleRate)
|
|
42
|
+
if (!state.newMag) {
|
|
43
|
+
state.newMag = new Float64Array(half + 1)
|
|
44
|
+
state.newPhase = new Float64Array(half + 1)
|
|
45
|
+
state.rand = mulberry32(ctx.opts?.seed ?? DEFAULT_SEED)
|
|
46
|
+
}
|
|
47
|
+
let { newMag, newPhase, rand } = state
|
|
48
|
+
newMag.fill(0)
|
|
49
|
+
newPhase.fill(0) // ratio<1 skips high bins below — don't leak last frame's phase there
|
|
50
|
+
for (let k = 0; k <= half; k++) {
|
|
51
|
+
let src = k / ratio
|
|
52
|
+
if (src > half) continue
|
|
53
|
+
let i = src | 0
|
|
54
|
+
let f = src - i
|
|
55
|
+
newMag[k] = mag[i] * (1 - f) + (i + 1 <= half ? mag[i + 1] : 0) * f
|
|
56
|
+
newPhase[k] = rand() * PI2
|
|
57
|
+
}
|
|
58
|
+
return { mag: newMag, phase: newPhase }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Paulstretch's defining randomized phase means adjacent frames recombine incoherently,
|
|
62
|
+
// producing envelope modulation at the frame rate (sr / synHop). Larger frames push that
|
|
63
|
+
// rate down out of the audible-roughness range into slow tremolo — at sr=44100 the 16k/4k
|
|
64
|
+
// default gives ~10.8 Hz — but this changes the artifact's character, not its size. Measured
|
|
65
|
+
// with `scripts/metrics.js`'s `hopRateMod` (Goertzel envelope probe) on the quality rig's own
|
|
66
|
+
// 0.5 s / 440 Hz sine fixture at ratio 1.5, default seed: ~8.5% modulation depth at the
|
|
67
|
+
// shipped default — reproducible exactly since the phase draw is now seeded (see below).
|
|
68
|
+
// Depth does not shrink monotonically as frameSize grows. "Inaudible" overstated this; the
|
|
69
|
+
// honest claim is "moved to a lower, less rough-sounding frequency, not eliminated."
|
|
70
|
+
function paulBatch(data, opts) {
|
|
71
|
+
let { ratio, ratioFn } = resolveRatio(opts)
|
|
72
|
+
let frameSize = opts?.frameSize ?? 16384
|
|
73
|
+
let hopSize = opts?.hopSize ?? (frameSize >> 2)
|
|
74
|
+
let out = stftBatch(data, process, { ...opts, ratio, ratioFn, frameSize, hopSize })
|
|
75
|
+
return matchPeak(out, data)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// matchPeak needs the whole signal's peak before it can scale a single sample, so it can't
|
|
79
|
+
// run incrementally per chunk without batch and stream landing on different gains (the
|
|
80
|
+
// bug: an earlier per-chunk stftStream path shipped raw, uncorrected output — batch/stream
|
|
81
|
+
// levels differed by >10%). bufferedStream buffers input and runs paulBatch once at flush,
|
|
82
|
+
// same as every other whole-signal-dependent algorithm in this repo (hpss, hybrid, sample).
|
|
83
|
+
let paulStream = (opts) => bufferedStream(paulBatch, opts)
|
|
84
|
+
|
|
85
|
+
// Deterministic per seed (default fixed) — same input/opts always produces byte-identical
|
|
86
|
+
// output. Pass `opts.seed` to get a different (but still reproducible) phase draw.
|
|
87
|
+
export default makePitchShift(paulBatch, paulStream)
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/shift-paulstretch",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Paulstretch-style randomized-phase pitch shift for textural blur",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@audio/shift-core": "^1.0.0"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": "audiojs",
|
|
23
|
+
"homepage": "https://github.com/audiojs/shift/tree/main/packages/shift-paulstretch#readme",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/audiojs/shift.git",
|
|
27
|
+
"directory": "packages/shift-paulstretch"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"audio",
|
|
31
|
+
"pitch-shift",
|
|
32
|
+
"paulstretch",
|
|
33
|
+
"texture",
|
|
34
|
+
"drone",
|
|
35
|
+
"dsp"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
}
|
|
40
|
+
}
|