@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.
Files changed (2) hide show
  1. package/package.json +35 -0
  2. 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
+ }