@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 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
+ }