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