@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.
- package/index.js +87 -0
- 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
|
+
}
|