@audio/shift-transient 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 { makeStftShift } from '@audio/shift-core/stft'
2
+ import { findPeaks, scatterLocked, makeFrameRatio } from '@audio/shift-core'
3
+
4
+ // Transient-aware phase vocoder pitch shift: shift-pvoc-lock's peak-locked scatter kernel
5
+ // (findPeaks + scatterLocked), with `reset` decided per frame by an onset detector instead
6
+ // of only on the first frame. On a detected onset, phase resets to the analysis phase
7
+ // (vertical coherence over horizontal), keeping attacks sharp; between onsets it behaves
8
+ // exactly like phaseLock.
9
+
10
+ function process(mag, phase, state, ctx) {
11
+ if (!state.fr) state.fr = makeFrameRatio(ctx.ratioFn || ctx.ratio || 1)
12
+ let { half } = ctx
13
+ let ratio = state.fr.at(ctx.frameStart, ctx.sampleRate)
14
+ let threshold = ctx.opts.transientThreshold ?? 1.5
15
+ if (!state.prev) {
16
+ state.prev = new Float64Array(half + 1)
17
+ state.prevMag = new Float64Array(half + 1)
18
+ state.syn = new Float64Array(half + 1)
19
+ state.newMag = new Float64Array(half + 1)
20
+ state.newPhase = new Float64Array(half + 1)
21
+ state.peakMag = new Float64Array(half + 1)
22
+ state.peakDest = new Int32Array(half)
23
+ state.peakSynPhase = new Float64Array(half)
24
+ state.fluxMean = 0
25
+ state.fluxVar = 0
26
+ state.energyMean = 0
27
+ state.postFrames = 0
28
+ state.first = true
29
+ }
30
+
31
+ let { prev, prevMag, syn, newMag, newPhase, peakMag, peakDest, peakSynPhase } = state
32
+ newMag.fill(0)
33
+ newPhase.fill(0)
34
+ peakMag.fill(0)
35
+
36
+ // The analysis window still overlaps the zero-padded head while frameStart < 0 (partial-
37
+ // window truncation, not real spectral content): flux is neither computed nor folded into
38
+ // the running mean/variance there, so the boundary can't fire a false onset or desensitize
39
+ // detection of the genuine attack that follows. `state.first` (the literal first frame)
40
+ // still resets unconditionally, exactly like phaseLock, so a t=0 attack is never blinded.
41
+ let boundary = ctx.frameStart < 0
42
+ let isTransient = state.first
43
+
44
+ if (!state.first && !boundary) {
45
+ // Energy-domain (mag^2) half-wave-rectified spectral flux, normalized by frame energy.
46
+ // Quadratic energy (Parseval) tracks the true windowed-signal energy, so a decaying or
47
+ // ending signal reads as a genuine decrease here — unlike a magnitude- or log-domain
48
+ // sum, which the spectral broadening a hard signal edge causes can inflate with no real
49
+ // energy behind it (the same failure mode the zero-padded tail on the last real frames
50
+ // would otherwise trigger).
51
+ let flux = 0, energy = 0
52
+ for (let k = 0; k <= half; k++) {
53
+ let m2 = mag[k] * mag[k], p2 = prevMag[k] * prevMag[k]
54
+ if (m2 > p2) flux += m2 - p2
55
+ energy += m2
56
+ }
57
+ let nFlux = energy > 1e-12 ? flux / energy : 0
58
+ let std = Math.sqrt(state.fluxVar)
59
+ // An onset is an energy increase: require the frame not be measurably below its own
60
+ // recent baseline — what a decay/release (or a signal's tail) looks like — so those
61
+ // can't be mistaken for the rise this flux term is built to catch. The 0.3 floor (up
62
+ // from a naive 0, in this energy-domain scale) is calibrated against amplitude-
63
+ // modulated tonal material: a 5 Hz/60%-depth tremolo peaks at nFlux ≈ 0.28, while
64
+ // isolated kick/snare/hi-hat onsets peak at 0.3–1.0, so 0.3 separates them without
65
+ // needing per-material tuning.
66
+ let rising = energy >= state.energyMean * 0.7
67
+ let fired = state.postFrames > 3 && rising && nFlux > state.fluxMean + threshold * Math.max(0.3, std)
68
+ isTransient = fired
69
+
70
+ let alpha = fired ? 0.25 : 0.1
71
+ let delta = nFlux - state.fluxMean
72
+ state.fluxMean += alpha * delta
73
+ state.fluxVar = (1 - alpha) * (state.fluxVar + alpha * delta * delta)
74
+ state.energyMean += alpha * (energy - state.energyMean)
75
+ state.postFrames++
76
+ }
77
+
78
+ let peaks = findPeaks(mag, half)
79
+ scatterLocked(mag, phase, prev, isTransient, peaks, ratio, ctx, syn, newMag, newPhase, peakDest, peakSynPhase, peakMag)
80
+
81
+ for (let k = 0; k <= half; k++) { prev[k] = phase[k]; prevMag[k] = mag[k] }
82
+ state.first = false
83
+
84
+ return { mag: newMag, phase: newPhase }
85
+ }
86
+
87
+ export default makeStftShift(process, { post: (out) => out })
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@audio/shift-transient",
3
+ "version": "1.0.0",
4
+ "description": "Transient-aware phase vocoder pitch shift with attack preservation",
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-transient#readme",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/audiojs/shift.git",
27
+ "directory": "packages/shift-transient"
28
+ },
29
+ "keywords": [
30
+ "audio",
31
+ "pitch-shift",
32
+ "transient",
33
+ "phase-vocoder",
34
+ "attack",
35
+ "dsp"
36
+ ],
37
+ "engines": {
38
+ "node": ">=18"
39
+ }
40
+ }