@audio/stretch-sms 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 (5) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +29 -0
  3. package/package.json +48 -0
  4. package/sms.d.ts +17 -0
  5. package/sms.js +344 -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,29 @@
1
+ # @audio/stretch-sms
2
+
3
+ Sinusoidal Modeling Synthesis (Serra 1989, McAulay-Quatieri 1986). Decomposes audio into individually tracked sinusoidal partials and resynthesizes at the new time rate — frequency and magnitude of each partial interpolated independently, no bin-by-bin smearing.
4
+
5
+ ```js
6
+ import sms from '@audio/stretch-sms'
7
+
8
+ sms(data, { factor: 2 })
9
+ sms(data, { factor: 0.5, maxTracks: 80 })
10
+ ```
11
+
12
+ | Param | Default | |
13
+ |---|---|---|
14
+ | `factor` | `1` | Time stretch ratio |
15
+ | `frameSize` | `2048` | FFT frame size |
16
+ | `hopSize` | `frameSize/4` | Hop between frames |
17
+ | `maxTracks` | `60` | Max simultaneous sinusoidal tracks |
18
+ | `minMag` | `1e-4` | Peak detection threshold (linear) |
19
+ | `freqDev` | `3` | Max frequency deviation (bins) for track continuation |
20
+ | `residualMix` | `1` | Stochastic residual blended into output |
21
+
22
+ **Use when:** harmonic / tonal content — instruments, chords, vocals — where the phase vocoder smears.<br>
23
+ **Not for:** noise-dominated material.
24
+
25
+ Part of [`@audio/stretch`](../..).
26
+
27
+ ## License
28
+
29
+ [MIT](./LICENSE) · [ॐ](https://github.com/krishnized/license/)
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@audio/stretch-sms",
3
+ "version": "1.0.0",
4
+ "description": "Sinusoidal Modeling Synthesis time stretching — harmonic/tonal material",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "sms.js",
8
+ "types": "sms.d.ts",
9
+ "exports": {
10
+ ".": "./sms.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "sms.js",
15
+ "sms.d.ts",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "dependencies": {
20
+ "@audio/stretch-core": "^1.0.0",
21
+ "fourier-transform": "^2.3.0"
22
+ },
23
+ "keywords": [
24
+ "audio",
25
+ "dsp",
26
+ "time-stretch",
27
+ "sms",
28
+ "sinusoidal",
29
+ "partial-tracking",
30
+ "serra",
31
+ "mcaulay-quatieri"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "license": "MIT",
37
+ "author": "Dmitry Iv. <dfcreative@gmail.com>",
38
+ "homepage": "https://github.com/audiojs/stretch/tree/main/packages/stretch-sms",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/audiojs/stretch.git",
42
+ "directory": "packages/stretch-sms"
43
+ },
44
+ "bugs": "https://github.com/audiojs/stretch/issues",
45
+ "engines": {
46
+ "node": ">=18"
47
+ }
48
+ }
package/sms.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { StreamWriter } from '@audio/stretch-core'
2
+
3
+ export interface SmsOpts {
4
+ factor?: number
5
+ frameSize?: number
6
+ hopSize?: number
7
+ maxTracks?: number
8
+ minMag?: number
9
+ freqDev?: number
10
+ residualMix?: number
11
+ }
12
+
13
+ declare const sms: {
14
+ (data: Float32Array, opts?: SmsOpts): Float32Array
15
+ (opts?: SmsOpts): StreamWriter
16
+ }
17
+ export default sms
package/sms.js ADDED
@@ -0,0 +1,344 @@
1
+ // Sinusoidal Modeling Synthesis time stretching.
2
+ // Tracks individual sinusoidal partials across frames, resynthesizes at new rate.
3
+ // Each harmonic is independently controlled — no phase spreading or bin-by-bin artifacts.
4
+ //
5
+ // References:
6
+ // - Serra, X. (1989). "A System for Sound Analysis/Transformation/Synthesis
7
+ // Based on a Deterministic plus Stochastic Decomposition." PhD thesis, Stanford.
8
+ // - McAulay, R.J. & Quatieri, T.F. (1986). "Speech analysis/synthesis based on
9
+ // a sinusoidal representation." IEEE Trans. ASSP, 34(4).
10
+
11
+ import { fft, ifft } from 'fourier-transform'
12
+ import { hannWindow, clamp, normalize, writer, makeStreamBufs, PI2 } from '@audio/stretch-core'
13
+
14
+ function createNoiseState(seed = 0x12345678) {
15
+ return { seed: seed >>> 0 }
16
+ }
17
+
18
+ function nextNoisePhase(state) {
19
+ state.seed = (state.seed * 1664525 + 1013904223) >>> 0
20
+ return state.seed / 0x100000000 * PI2
21
+ }
22
+
23
+ // Tracks and peaks are structure-of-arrays: parallel bins/mags Float64Arrays.
24
+ // Frame data (tracks, residual) persists per frame; everything else below is
25
+ // module-level scratch reused across frames — the per-frame hot path allocates nothing.
26
+ function makeFrame(nTracks, half) {
27
+ return {
28
+ tracks: { bins: new Float64Array(nTracks), mags: new Float64Array(nTracks) },
29
+ residual: new Float64Array(half + 1),
30
+ }
31
+ }
32
+
33
+ let _buf = new Float64Array(0) // windowed analysis frame
34
+ let _mag = new Float64Array(0) // magnitude spectrum
35
+ let _pre = new Float64Array(0) // pre-smooth residual
36
+ let _pkBin = new Float64Array(0) // detected peaks
37
+ let _pkMag = new Float64Array(0)
38
+ let _pkBin2 = new Float64Array(0) // permute scratch
39
+ let _pkMag2 = new Float64Array(0)
40
+ let _order = [] // reusable index-sort scratch
41
+ const byMagDesc = (a, b) => _pkMag2[b] - _pkMag2[a]
42
+ const byCostAsc = (a, b) => _pairC[a] - _pairC[b]
43
+ let _pairT = new Int32Array(0) // candidate track/peak pairs
44
+ let _pairP = new Int32Array(0)
45
+ let _pairC = new Float64Array(0)
46
+ let _taken = new Uint8Array(0)
47
+ let _assigned = new Uint8Array(0)
48
+ let _empty = new Int32Array(0)
49
+ let _sre = new Float64Array(0) // synthesis spectrum
50
+ let _sim = new Float64Array(0)
51
+
52
+ // Smooth `pre[0..half]` with a triangular kernel into `out`.
53
+ function smoothResidual(pre, half, width, out) {
54
+ for (let k = 0; k <= half; k++) {
55
+ let sum = 0, weightSum = 0
56
+ let start = Math.max(0, k - width), end = Math.min(half, k + width)
57
+ for (let j = start; j <= end; j++) {
58
+ let weight = width + 1 - Math.abs(j - k)
59
+ sum += pre[j] * weight; weightSum += weight
60
+ }
61
+ out[k] = weightSum ? sum / weightSum : 0
62
+ }
63
+ }
64
+
65
+ // Subtract top-nTracks peaks from the spectrum, smooth → noise envelope into `out`.
66
+ function residualEnvelope(mag, nPeaks, half, nTracks, out) {
67
+ if (_pre.length < half + 1) _pre = new Float64Array(half + 1)
68
+ let residual = _pre
69
+ for (let k = 0; k <= half; k++) residual[k] = mag[k]
70
+
71
+ let count = Math.min(nTracks, nPeaks)
72
+ for (let i = 0; i < count; i++) {
73
+ let bin = _pkBin[i], radius = 3.5
74
+ let start = Math.max(1, Math.floor(bin - radius))
75
+ let end = Math.min(half - 1, Math.ceil(bin + radius))
76
+ for (let k = start; k <= end; k++) {
77
+ let weight = Math.max(0, 1 - Math.abs(k - bin) / radius)
78
+ residual[k] = Math.max(0, residual[k] - _pkMag[i] * weight)
79
+ }
80
+ }
81
+
82
+ residual[0] = 0
83
+ if (half > 0) residual[half] *= 0.5
84
+ smoothResidual(residual, half, 4, out)
85
+ }
86
+
87
+ // Spectral peak detection with parabolic interpolation for sub-bin accuracy.
88
+ // Fills _pkBin/_pkMag sorted by magnitude desc (stable); returns peak count.
89
+ function detectPeaks(mag, half, thresh) {
90
+ if (_pkBin.length < half + 1) {
91
+ _pkBin = new Float64Array(half + 1)
92
+ _pkMag = new Float64Array(half + 1)
93
+ _pkBin2 = new Float64Array(half + 1)
94
+ _pkMag2 = new Float64Array(half + 1)
95
+ }
96
+ let n = 0
97
+ for (let k = 2; k < half - 1; k++) {
98
+ if (mag[k] <= mag[k - 1] || mag[k] <= mag[k + 1] || mag[k] < thresh) continue
99
+ let a = mag[k - 1], b = mag[k], c = mag[k + 1]
100
+ let d = a - 2 * b + c
101
+ let p = d ? 0.5 * (a - c) / d : 0
102
+ _pkBin2[n] = k + p
103
+ _pkMag2[n] = b - 0.25 * (a - c) * p
104
+ n++
105
+ }
106
+ _order.length = n
107
+ for (let i = 0; i < n; i++) _order[i] = i
108
+ _order.sort(byMagDesc)
109
+ for (let i = 0; i < n; i++) {
110
+ _pkBin[i] = _pkBin2[_order[i]]
111
+ _pkMag[i] = _pkMag2[_order[i]]
112
+ }
113
+ return n
114
+ }
115
+
116
+ // Cost-weighted sinusoidal tracking: sorted assignment by frequency + magnitude continuity.
117
+ // Writes the continued track set into `out`.
118
+ function trackPeaks(prev, nPeaks, nTracks, maxDev, out) {
119
+ out.bins.fill(0)
120
+ out.mags.fill(0)
121
+
122
+ let cap = nTracks * Math.max(nPeaks, 1)
123
+ if (_pairT.length < cap) {
124
+ _pairT = new Int32Array(cap)
125
+ _pairP = new Int32Array(cap)
126
+ _pairC = new Float64Array(cap)
127
+ }
128
+ let nPairs = 0
129
+ for (let i = 0; i < nTracks; i++) {
130
+ if (!prev.bins[i]) continue
131
+ for (let j = 0; j < nPeaks; j++) {
132
+ let fd = Math.abs(_pkBin[j] - prev.bins[i])
133
+ if (fd >= maxDev) continue
134
+ let mr = prev.mags[i] > 1e-10 ? _pkMag[j] / prev.mags[i] : 1
135
+ _pairT[nPairs] = i
136
+ _pairP[nPairs] = j
137
+ _pairC[nPairs] = fd / maxDev + 0.25 * Math.abs(Math.log(clamp(mr, 0.01, 100)))
138
+ nPairs++
139
+ }
140
+ }
141
+ _order.length = nPairs
142
+ for (let i = 0; i < nPairs; i++) _order[i] = i
143
+ _order.sort(byCostAsc)
144
+
145
+ if (_taken.length < nPeaks) _taken = new Uint8Array(nPeaks)
146
+ if (_assigned.length < nTracks) {
147
+ _assigned = new Uint8Array(nTracks)
148
+ _empty = new Int32Array(nTracks)
149
+ }
150
+ _taken.fill(0); _assigned.fill(0)
151
+
152
+ for (let s = 0; s < nPairs; s++) {
153
+ let t = _pairT[_order[s]], p = _pairP[_order[s]]
154
+ if (_assigned[t] || _taken[p]) continue
155
+ out.bins[t] = _pkBin[p]
156
+ out.mags[t] = _pkMag[p]
157
+ _assigned[t] = 1; _taken[p] = 1
158
+ }
159
+
160
+ let nEmpty = 0
161
+ for (let i = 0; i < nTracks; i++) if (!_assigned[i]) _empty[nEmpty++] = i
162
+ let e = 0
163
+ for (let j = 0; j < nPeaks && e < nEmpty; j++) {
164
+ if (!_taken[j]) {
165
+ let t = _empty[e++]
166
+ out.bins[t] = _pkBin[j]
167
+ out.mags[t] = _pkMag[j]
168
+ }
169
+ }
170
+ }
171
+
172
+ // Analyze one frame into `frame`: window+FFT, detect+track peaks, residual envelope
173
+ // (blended 70/30 with the previous frame's when given).
174
+ function analyzeFrame(data, pos, win, N, half, thresh, prev, nTracks, maxDev, prevResidual, frame) {
175
+ if (_buf.length !== N) _buf = new Float64Array(N)
176
+ if (_mag.length < half + 1) _mag = new Float64Array(half + 1)
177
+ let buf = _buf
178
+ for (let i = 0; i < N; i++) buf[i] = data[pos + i] * win[i]
179
+ let [re, im] = fft(buf)
180
+ let mag = _mag
181
+ for (let k = 0; k <= half; k++) mag[k] = Math.sqrt(re[k] * re[k] + im[k] * im[k])
182
+
183
+ let nPeaks = detectPeaks(mag, half, thresh)
184
+ trackPeaks(prev, nPeaks, nTracks, maxDev, frame.tracks)
185
+
186
+ let residual = frame.residual
187
+ residualEnvelope(mag, nPeaks, half, nTracks, residual)
188
+ if (prevResidual) for (let k = 0; k <= half; k++) residual[k] = 0.7 * residual[k] + 0.3 * prevResidual[k]
189
+ }
190
+
191
+ // Build synthesis spectrum from interpolated tracks, IFFT to time domain
192
+ function synthFrame(t0, t1, r0, r1, alpha, nTracks, phi, half, N, hop, noiseState, residualMix) {
193
+ if (_sre.length !== half + 1) {
194
+ _sre = new Float64Array(half + 1)
195
+ _sim = new Float64Array(half + 1)
196
+ }
197
+ let re = _sre, im = _sim
198
+ re.fill(0); im.fill(0)
199
+ let dphi = PI2 * hop / N
200
+
201
+ for (let i = 0; i < nTracks; i++) {
202
+ let b0 = t0.bins[i], m0 = t0.mags[i], b1 = t1.bins[i], m1 = t1.mags[i]
203
+ if (!b0 && !b1) continue
204
+
205
+ let bin, mag
206
+ if (!b0) { bin = b1; mag = m1 * alpha }
207
+ else if (!b1) { bin = b0; mag = m0 * (1 - alpha) }
208
+ else { bin = b0 + (b1 - b0) * alpha; mag = m0 + (m1 - m0) * alpha }
209
+
210
+ phi[i] += bin * dphi
211
+ let k = Math.round(bin)
212
+ if (k > 0 && k < half) {
213
+ re[k] += 2 * mag * Math.cos(phi[i])
214
+ im[k] += 2 * mag * Math.sin(phi[i])
215
+ }
216
+ }
217
+
218
+ if (residualMix > 0) {
219
+ for (let k = 1; k < half; k++) {
220
+ let resMag = (r0[k] + (r1[k] - r0[k]) * alpha) * residualMix
221
+ if (resMag <= 1e-8) continue
222
+ let phase = nextNoisePhase(noiseState)
223
+ re[k] += 2 * resMag * Math.cos(phase)
224
+ im[k] += 2 * resMag * Math.sin(phase)
225
+ }
226
+ }
227
+
228
+ return ifft(re, im)
229
+ }
230
+
231
+ export default function sms(data, opts = {}) {
232
+ if (!(data instanceof Float32Array)) return writer(smsStream(data))
233
+
234
+ let factor = opts.factor ?? 1
235
+ let N = opts.frameSize ?? 2048
236
+ let hop = opts.hopSize ?? (N >> 2)
237
+ let half = N >> 1
238
+ let nTracks = opts.maxTracks ?? 60
239
+ let thresh = opts.minMag ?? 1e-4
240
+ let maxDev = opts.freqDev ?? 3
241
+ let residualMix = clamp(opts.residualMix ?? 1, 0, 1)
242
+ let win = hannWindow(N)
243
+ let noiseState = createNoiseState()
244
+
245
+ if (factor === 1) return new Float32Array(data)
246
+
247
+ // Analysis pass — frames persist (synthesis interpolates between any two)
248
+ let nAna = Math.max(1, Math.floor((data.length - N) / hop) + 1)
249
+ let frames = new Array(nAna)
250
+ let prev = { bins: new Float64Array(nTracks), mags: new Float64Array(nTracks) }
251
+ let prevResidual = null
252
+ for (let f = 0; f < nAna; f++) {
253
+ let frame = makeFrame(nTracks, half)
254
+ analyzeFrame(data, f * hop, win, N, half, thresh, prev, nTracks, maxDev, prevResidual, frame)
255
+ frames[f] = frame; prev = frame.tracks; prevResidual = frame.residual
256
+ }
257
+
258
+ // Synthesis pass
259
+ let outLen = Math.round(data.length * factor)
260
+ let out = new Float32Array(outLen), nrm = new Float32Array(outLen)
261
+ let phi = new Float64Array(nTracks)
262
+
263
+ for (let s = 0; ; s++) {
264
+ let sPos = s * hop
265
+ if (sPos + N > outLen) break
266
+ let af = Math.min(s / factor, nAna - 1)
267
+ let f0 = Math.floor(af), f1 = Math.min(f0 + 1, nAna - 1), alpha = af - f0
268
+ let fr = synthFrame(frames[f0].tracks, frames[f1].tracks, frames[f0].residual, frames[f1].residual, alpha, nTracks, phi, half, N, hop, noiseState, residualMix)
269
+ for (let i = 0; i < N && sPos + i < outLen; i++) {
270
+ let w2 = win[i] * win[i]
271
+ out[sPos + i] += fr[i] * w2; nrm[sPos + i] += w2
272
+ }
273
+ }
274
+
275
+ normalize(out, nrm)
276
+ return out
277
+ }
278
+
279
+ function smsStream(opts = {}) {
280
+ let factor = opts.factor ?? 1
281
+ let N = opts.frameSize ?? 2048
282
+ let hop = opts.hopSize ?? (N >> 2)
283
+ let half = N >> 1
284
+ let nTracks = opts.maxTracks ?? 60
285
+ let thresh = opts.minMag ?? 1e-4
286
+ let maxDev = opts.freqDev ?? 3
287
+ let residualMix = clamp(opts.residualMix ?? 1, 0, 1)
288
+ let win = hannWindow(N)
289
+ let noiseState = createNoiseState()
290
+
291
+ let st = makeStreamBufs(N)
292
+ // Two frame slots ping-ponged: each analysis writes into the retiring slot,
293
+ // so the steady-state stream allocates nothing per frame.
294
+ let slotA = makeFrame(nTracks, half)
295
+ let slotB = makeFrame(nTracks, half)
296
+ let prevFrame = slotA
297
+ let currFrame = null
298
+ let phi = new Float64Array(nTracks)
299
+ let anaIdx = 0, synIdx = 0, anaPos = 0
300
+
301
+ function synthOne(alpha) {
302
+ st.growOut(st.pos + N)
303
+ let ob = st.ob, nb = st.nb, base = st.pos
304
+ let fr = synthFrame(prevFrame.tracks, currFrame.tracks, prevFrame.residual, currFrame.residual, alpha, nTracks, phi, half, N, hop, noiseState, residualMix)
305
+ for (let i = 0; i < N && base + i < ob.length; i++) {
306
+ let w2 = win[i] * win[i]; ob[base + i] += fr[i] * w2; nb[base + i] += w2
307
+ }
308
+ st.pos += hop; synIdx++
309
+ }
310
+
311
+ function emitSynth() {
312
+ if (anaIdx < 2) return
313
+ while (synIdx / factor < anaIdx - 1) synthOne(synIdx / factor - (anaIdx - 2))
314
+ }
315
+
316
+ function processInput() {
317
+ while (anaPos + N <= st.il) {
318
+ let source = currFrame || prevFrame
319
+ let target = source === slotA ? slotB : slotA
320
+ analyzeFrame(st.ib, anaPos, win, N, half, thresh, source.tracks, nTracks, maxDev, source.residual, target)
321
+ prevFrame = source; currFrame = target
322
+ anaIdx++; anaPos += hop
323
+ emitSynth()
324
+ }
325
+ if (anaPos > N * 2) { let trim = anaPos - N; st.compactIn(trim); anaPos -= trim }
326
+ }
327
+
328
+ return {
329
+ write(chunk) {
330
+ st.appendIn(chunk)
331
+ processInput()
332
+ return st.take(Math.max(0, st.pos - N + hop))
333
+ },
334
+ flush() {
335
+ if (anaIdx >= 2 && currFrame) {
336
+ while (synIdx / factor < anaIdx) {
337
+ let af = Math.min(synIdx / factor, anaIdx - 1)
338
+ synthOne(Math.min(af - (anaIdx - 2), 1))
339
+ }
340
+ }
341
+ return st.take(st.pos)
342
+ }
343
+ }
344
+ }