@audio/tune-snap 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/package.json +35 -0
- package/snap.js +66 -0
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/tune-snap",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Pitch correction \u2014 YIN track \u2192 note segments \u2192 scale snap \u2192 PSOLA retune (Auto-Tune class, per-note v1)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "snap.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./snap.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"snap.js"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@audio/pitch-yin": "^1.0.0",
|
|
17
|
+
"@audio/note": "^1.0.0",
|
|
18
|
+
"@audio/shift-psola": "^1.0.0"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"audio",
|
|
22
|
+
"dsp",
|
|
23
|
+
"pitch-correction",
|
|
24
|
+
"autotune",
|
|
25
|
+
"tune"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"author": "Dmitry Iv. <dfcreative@gmail.com>",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/snap.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Pitch correction (Auto-Tune class) — YIN F0 track → note segmentation → per-segment
|
|
2
|
+
// scale snap → formant-moving PSOLA retune, crossfaded back over the dry signal.
|
|
3
|
+
// v1 corrects per note segment (no intra-note glide); unvoiced audio passes through.
|
|
4
|
+
|
|
5
|
+
import yin from '@audio/pitch-yin'
|
|
6
|
+
import { snapHz } from '@audio/note'
|
|
7
|
+
import psola from '@audio/shift-psola'
|
|
8
|
+
|
|
9
|
+
const median = arr => {
|
|
10
|
+
let s = [...arr].sort((a, b) => a - b)
|
|
11
|
+
return s[s.length >> 1]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {Float32Array} data — mono PCM
|
|
16
|
+
* @param {object} opts — { fs=44100, scale='chromatic', root=0, a4=440,
|
|
17
|
+
* tolerance=8 (¢, leave in-tune notes alone), strength=1 (0..1 correction amount),
|
|
18
|
+
* frameSize=2048, hop=512, fade=0.01 (s) }
|
|
19
|
+
* @returns {Float32Array} corrected copy
|
|
20
|
+
*/
|
|
21
|
+
export default function snap (data, { fs = 44100, scale = 'chromatic', root = 0, a4 = 440, tolerance = 8, strength = 1, frameSize = 2048, hop = 512, fade = 0.01 } = {}) {
|
|
22
|
+
// 1. frame-wise F0
|
|
23
|
+
let nFrames = Math.max(0, Math.floor((data.length - frameSize) / hop) + 1)
|
|
24
|
+
let f0 = new Float32Array(nFrames)
|
|
25
|
+
for (let i = 0; i < nFrames; i++) {
|
|
26
|
+
let r = yin(data.subarray(i * hop, i * hop + frameSize), { fs })
|
|
27
|
+
if (r) f0[i] = r.freq
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 2. segment into note runs (split on >80¢ jumps from the running median)
|
|
31
|
+
let segs = []
|
|
32
|
+
let run = null
|
|
33
|
+
let flush = () => { if (run && run.frames.length >= 2) segs.push(run); run = null }
|
|
34
|
+
for (let i = 0; i < nFrames; i++) {
|
|
35
|
+
if (!f0[i]) { flush(); continue }
|
|
36
|
+
if (run) {
|
|
37
|
+
let m = median(run.frames)
|
|
38
|
+
if (Math.abs(1200 * Math.log2(f0[i] / m)) > 80) flush()
|
|
39
|
+
}
|
|
40
|
+
if (!run) run = { start: i, frames: [] }
|
|
41
|
+
run.frames.push(f0[i])
|
|
42
|
+
run.end = i
|
|
43
|
+
}
|
|
44
|
+
flush()
|
|
45
|
+
|
|
46
|
+
// 3-5. per segment: snap → psola → crossfade over the dry copy
|
|
47
|
+
let out = Float32Array.from(data)
|
|
48
|
+
let fadeN = Math.max(1, Math.round(fade * fs))
|
|
49
|
+
for (let s of segs) {
|
|
50
|
+
let m = median(s.frames)
|
|
51
|
+
let target = snapHz(m, { scale, root, a4 })
|
|
52
|
+
let cents = 1200 * Math.log2(target / m)
|
|
53
|
+
if (Math.abs(cents) < tolerance) continue
|
|
54
|
+
let ratio = 2 ** (cents * strength / 1200)
|
|
55
|
+
|
|
56
|
+
let from = s.start * hop
|
|
57
|
+
let to = Math.min(data.length, s.end * hop + frameSize)
|
|
58
|
+
let shifted = psola(data.slice(from, to), { ratio, sampleRate: fs })
|
|
59
|
+
let n = Math.min(shifted.length, to - from)
|
|
60
|
+
for (let i = 0; i < n; i++) {
|
|
61
|
+
let g = Math.min(1, i / fadeN, (n - 1 - i) / fadeN)
|
|
62
|
+
out[from + i] = out[from + i] * (1 - g) + shifted[i] * g
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|