@audio/stretch-hybrid 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/LICENSE +25 -0
- package/README.md +26 -0
- package/hybrid.d.ts +20 -0
- package/hybrid.js +181 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Dmitry Iv
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
This work is offered under the Krishnized License (https://github.com/krishnized/license).
|
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @audio/stretch-hybrid
|
|
2
|
+
|
|
3
|
+
Hybrid harmonic/percussive time stretch (Driedger & Müller). Median-filter HPSS splits the spectrogram into a harmonic and a percussive layer; the harmonic layer goes through the phase-locked vocoder, the percussive layer through short-frame OLA — chords stay coherent and attacks stay sharp, where a single algorithm must trade one for the other.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import hybrid from '@audio/stretch-hybrid'
|
|
7
|
+
let out = hybrid(data, { factor: 2 })
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
| Param | Default | |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| `factor` | `1` | Time stretch ratio |
|
|
13
|
+
| `frameSize` | `2048` | FFT size for HPSS + harmonic path |
|
|
14
|
+
| `hopSize` | `frameSize/4` | Hop between frames |
|
|
15
|
+
| `percFrame` | `512` | OLA frame for the percussive layer |
|
|
16
|
+
| `harmMedian` | `17` | Median filter across time (frames) |
|
|
17
|
+
| `percMedian` | `17` | Median filter across frequency (bins) |
|
|
18
|
+
|
|
19
|
+
**Use when:** full mixes — drums over tonal material.<br>
|
|
20
|
+
**Cost:** separation + two stretches ≈ 4–6× the CPU of [`@audio/stretch-pvoc-lock`](../stretch-pvoc-lock) alone; on purely tonal or purely percussive material the single-path algorithms match it for less.
|
|
21
|
+
|
|
22
|
+
Part of [`@audio/stretch`](../..).
|
|
23
|
+
|
|
24
|
+
## License
|
|
25
|
+
|
|
26
|
+
[MIT](./LICENSE) · [ॐ](https://github.com/krishnized/license/)
|
package/hybrid.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { StreamWriter } from '@audio/stretch-core'
|
|
2
|
+
|
|
3
|
+
export interface HybridOpts {
|
|
4
|
+
factor?: number
|
|
5
|
+
/** FFT size for HPSS analysis and the harmonic (vocoder) path (default 2048) */
|
|
6
|
+
frameSize?: number
|
|
7
|
+
hopSize?: number
|
|
8
|
+
/** OLA frame for the percussive path — short keeps attacks tight (default 512) */
|
|
9
|
+
percFrame?: number
|
|
10
|
+
/** Median-filter length across time, in frames (default 17) */
|
|
11
|
+
harmMedian?: number
|
|
12
|
+
/** Median-filter length across frequency, in bins (default 17) */
|
|
13
|
+
percMedian?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
declare const hybrid: {
|
|
17
|
+
(data: Float32Array, opts?: HybridOpts): Float32Array
|
|
18
|
+
(opts?: HybridOpts): StreamWriter
|
|
19
|
+
}
|
|
20
|
+
export default hybrid
|
package/hybrid.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Hybrid harmonic/percussive time stretch (Driedger & Müller; HPSS after FitzGerald).
|
|
2
|
+
// Median-filters the spectrogram along time (harmonic ridge) and frequency
|
|
3
|
+
// (percussive spike), Wiener-masks the STFT into two layers, then stretches each
|
|
4
|
+
// with the algorithm suited to it: phase-locked vocoder for the harmonic layer,
|
|
5
|
+
// short-frame WSOLA for the percussive layer — chords stay coherent AND attacks
|
|
6
|
+
// stay sharp, where either algorithm alone must trade one for the other.
|
|
7
|
+
//
|
|
8
|
+
// References:
|
|
9
|
+
// - Driedger, J. & Müller, M. (2016). "A Review of Time-Scale Modification of
|
|
10
|
+
// Music Signals." Applied Sciences 6(2).
|
|
11
|
+
// - FitzGerald, D. (2010). "Harmonic/Percussive Separation Using Median Filtering."
|
|
12
|
+
// DAFx-10.
|
|
13
|
+
|
|
14
|
+
import { stft, istft } from 'fourier-transform/stft'
|
|
15
|
+
import wsola from '@audio/stretch-wsola'
|
|
16
|
+
import pvocLock from '@audio/stretch-pvoc-lock'
|
|
17
|
+
import { writer } from '@audio/stretch-core'
|
|
18
|
+
|
|
19
|
+
// Median of scratch[0..n) via insertion sort — windows are ~17 wide.
|
|
20
|
+
let _med = new Float64Array(0)
|
|
21
|
+
function median(arr, n) {
|
|
22
|
+
for (let i = 1; i < n; i++) {
|
|
23
|
+
let v = arr[i], j = i - 1
|
|
24
|
+
while (j >= 0 && arr[j] > v) { arr[j + 1] = arr[j]; j-- }
|
|
25
|
+
arr[j + 1] = v
|
|
26
|
+
}
|
|
27
|
+
return n & 1 ? arr[n >> 1] : 0.5 * (arr[(n >> 1) - 1] + arr[n >> 1])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Split data into [harmonic, percussive] via median-filter HPSS + power-Wiener masks.
|
|
31
|
+
function hpssSplit(data, N, hop, tMed, fMed) {
|
|
32
|
+
let frames = stft(data, { frameSize: N, hopSize: hop })
|
|
33
|
+
let nF = frames.length, half = N >> 1
|
|
34
|
+
let win = Math.max(tMed, fMed)
|
|
35
|
+
if (_med.length < win) _med = new Float64Array(win)
|
|
36
|
+
|
|
37
|
+
let hFrames = new Array(nF), pFrames = new Array(nF)
|
|
38
|
+
let tHalf = tMed >> 1, fHalf = fMed >> 1
|
|
39
|
+
for (let f = 0; f < nF; f++) {
|
|
40
|
+
let { re, im, mag, time } = frames[f]
|
|
41
|
+
let hRe = new Float64Array(half + 1), hIm = new Float64Array(half + 1)
|
|
42
|
+
let pRe = new Float64Array(half + 1), pIm = new Float64Array(half + 1)
|
|
43
|
+
let f0 = Math.max(0, f - tHalf), f1 = Math.min(nF - 1, f + tHalf)
|
|
44
|
+
for (let k = 0; k <= half; k++) {
|
|
45
|
+
let c = 0
|
|
46
|
+
for (let g = f0; g <= f1; g++) _med[c++] = frames[g].mag[k]
|
|
47
|
+
let H = median(_med, c)
|
|
48
|
+
let k0 = Math.max(0, k - fHalf), k1 = Math.min(half, k + fHalf)
|
|
49
|
+
c = 0
|
|
50
|
+
for (let b = k0; b <= k1; b++) _med[c++] = mag[b]
|
|
51
|
+
let P = median(_med, c)
|
|
52
|
+
// hard-ish separation (power 4): soft masks leak the tonal bed into the
|
|
53
|
+
// percussive layer, where OLA then modulates it
|
|
54
|
+
let h4 = H * H * H * H, p4 = P * P * P * P, denom = h4 + p4
|
|
55
|
+
let mH = denom > 1e-40 ? h4 / denom : 0.5
|
|
56
|
+
hRe[k] = re[k] * mH; hIm[k] = im[k] * mH
|
|
57
|
+
pRe[k] = re[k] - hRe[k]; pIm[k] = im[k] - hIm[k] // masks sum to 1
|
|
58
|
+
}
|
|
59
|
+
hFrames[f] = { re: hRe, im: hIm, time }
|
|
60
|
+
pFrames[f] = { re: pRe, im: pIm, time }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let harm = istft(hFrames, { frameSize: N, hopSize: hop, signalLength: data.length })
|
|
64
|
+
let perc = istft(pFrames, { frameSize: N, hopSize: hop, signalLength: data.length })
|
|
65
|
+
return [new Float32Array(harm), new Float32Array(perc)]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hybridBatch(data, opts) {
|
|
69
|
+
let factor = opts?.factor ?? 1
|
|
70
|
+
if (factor === 1) return new Float32Array(data)
|
|
71
|
+
|
|
72
|
+
let frameSize = opts?.frameSize ?? 2048
|
|
73
|
+
let hopSize = opts?.hopSize ?? (frameSize >> 2)
|
|
74
|
+
let percFrame = opts?.percFrame ?? 512
|
|
75
|
+
let tMed = opts?.harmMedian ?? 17
|
|
76
|
+
let fMed = opts?.percMedian ?? 17
|
|
77
|
+
|
|
78
|
+
let [harm, perc] = hpssSplit(data, frameSize, hopSize, tMed, fMed)
|
|
79
|
+
let yh = pvocLock(harm, { factor, frameSize, hopSize })
|
|
80
|
+
// plain short-frame OLA (delta:0 — no correlation search): attacks repeat cleanly
|
|
81
|
+
// instead of being hunted for; percussive residue has no phase to preserve
|
|
82
|
+
let yp = wsola(perc, { factor, frameSize: percFrame, delta: 0 })
|
|
83
|
+
|
|
84
|
+
let outLen = Math.round(data.length * factor)
|
|
85
|
+
let out = new Float32Array(outLen)
|
|
86
|
+
for (let i = 0; i < outLen; i++) {
|
|
87
|
+
out[i] = (i < yh.length ? yh[i] : 0) + (i < yp.length ? yp[i] : 0)
|
|
88
|
+
}
|
|
89
|
+
return out
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hybridStream(opts) {
|
|
93
|
+
let factor = opts?.factor ?? 1
|
|
94
|
+
let frameSize = opts?.frameSize ?? 2048
|
|
95
|
+
let segLen = frameSize * 8
|
|
96
|
+
let advance = segLen >> 1
|
|
97
|
+
let outOlap = Math.round((segLen - advance) * factor)
|
|
98
|
+
|
|
99
|
+
let inBuf = new Float32Array(segLen * 2)
|
|
100
|
+
let inLen = 0
|
|
101
|
+
let tail = null
|
|
102
|
+
|
|
103
|
+
function concat(parts) {
|
|
104
|
+
let n = 0
|
|
105
|
+
for (let p of parts) n += p.length
|
|
106
|
+
if (!n) return new Float32Array(0)
|
|
107
|
+
let out = new Float32Array(n)
|
|
108
|
+
let off = 0
|
|
109
|
+
for (let p of parts) { out.set(p, off); off += p.length }
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Crossfade the retained overlap of the previous segment into the new one.
|
|
114
|
+
function blend(out, results) {
|
|
115
|
+
if (tail) {
|
|
116
|
+
let xLen = Math.min(tail.length, out.length, outOlap)
|
|
117
|
+
let xf = new Float32Array(xLen)
|
|
118
|
+
for (let i = 0; i < xLen; i++) {
|
|
119
|
+
let w = (i + 0.5) / xLen
|
|
120
|
+
xf[i] = tail[i] * (1 - w) + out[i] * w
|
|
121
|
+
}
|
|
122
|
+
results.push(xf)
|
|
123
|
+
let emitEnd = out.length - outOlap
|
|
124
|
+
if (emitEnd > xLen) results.push(new Float32Array(out.subarray(xLen, emitEnd)))
|
|
125
|
+
tail = emitEnd < out.length ? new Float32Array(out.subarray(Math.max(xLen, emitEnd))) : null
|
|
126
|
+
} else {
|
|
127
|
+
let emitEnd = out.length - outOlap
|
|
128
|
+
if (emitEnd > 0) results.push(new Float32Array(out.subarray(0, emitEnd)))
|
|
129
|
+
tail = new Float32Array(out.subarray(Math.max(0, emitEnd)))
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
write(chunk) {
|
|
135
|
+
if (inLen + chunk.length > inBuf.length) {
|
|
136
|
+
let nb = new Float32Array(Math.max((inLen + chunk.length) * 2, inBuf.length * 2))
|
|
137
|
+
nb.set(inBuf.subarray(0, inLen))
|
|
138
|
+
inBuf = nb
|
|
139
|
+
}
|
|
140
|
+
inBuf.set(chunk, inLen)
|
|
141
|
+
inLen += chunk.length
|
|
142
|
+
let results = []
|
|
143
|
+
while (inLen >= segLen) {
|
|
144
|
+
let seg = new Float32Array(inBuf.subarray(0, segLen))
|
|
145
|
+
blend(hybridBatch(seg, opts), results)
|
|
146
|
+
inBuf.copyWithin(0, advance, inLen)
|
|
147
|
+
inLen -= advance
|
|
148
|
+
}
|
|
149
|
+
return concat(results)
|
|
150
|
+
},
|
|
151
|
+
flush() {
|
|
152
|
+
let results = []
|
|
153
|
+
if (inLen > 0) {
|
|
154
|
+
let seg = new Float32Array(inBuf.subarray(0, inLen))
|
|
155
|
+
let out = hybridBatch(seg, opts)
|
|
156
|
+
if (tail) {
|
|
157
|
+
let xLen = Math.min(tail.length, out.length, outOlap)
|
|
158
|
+
let xf = new Float32Array(xLen)
|
|
159
|
+
for (let i = 0; i < xLen; i++) {
|
|
160
|
+
let w = (i + 0.5) / xLen
|
|
161
|
+
xf[i] = tail[i] * (1 - w) + out[i] * w
|
|
162
|
+
}
|
|
163
|
+
results.push(xf)
|
|
164
|
+
if (out.length > xLen) results.push(new Float32Array(out.subarray(xLen)))
|
|
165
|
+
} else {
|
|
166
|
+
results.push(out)
|
|
167
|
+
}
|
|
168
|
+
inLen = 0
|
|
169
|
+
} else if (tail) {
|
|
170
|
+
results.push(tail)
|
|
171
|
+
}
|
|
172
|
+
tail = null
|
|
173
|
+
return concat(results)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export default function hybrid(data, opts) {
|
|
179
|
+
if (!(data instanceof Float32Array)) return writer(hybridStream(data))
|
|
180
|
+
return hybridBatch(data, opts)
|
|
181
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/stretch-hybrid",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Hybrid harmonic/percussive time stretch (Driedger) — HPSS split, phase-locked vocoder + OLA per layer",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "hybrid.js",
|
|
8
|
+
"types": "hybrid.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./hybrid.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"hybrid.js",
|
|
15
|
+
"hybrid.d.ts",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@audio/stretch-core": "^1.0.0",
|
|
21
|
+
"@audio/stretch-pvoc-lock": "^1.0.0",
|
|
22
|
+
"@audio/stretch-wsola": "^1.0.0",
|
|
23
|
+
"fourier-transform": "^2.3.0"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"audio",
|
|
27
|
+
"dsp",
|
|
28
|
+
"time-stretch",
|
|
29
|
+
"hpss",
|
|
30
|
+
"harmonic-percussive",
|
|
31
|
+
"hybrid",
|
|
32
|
+
"phase-vocoder",
|
|
33
|
+
"stft"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"author": "Dmitry Iv. <dfcreative@gmail.com>",
|
|
40
|
+
"homepage": "https://github.com/audiojs/stretch/tree/main/packages/stretch-hybrid",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/audiojs/stretch.git",
|
|
44
|
+
"directory": "packages/stretch-hybrid"
|
|
45
|
+
},
|
|
46
|
+
"bugs": "https://github.com/audiojs/stretch/issues",
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=18"
|
|
49
|
+
}
|
|
50
|
+
}
|