creator-editing-studio 1.3.5 → 1.3.6
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/bin/init.mjs +18 -1
- package/bin/update.mjs +146 -0
- package/history.json +1 -0
- package/package.json +2 -1
- package/template/CLAUDE.md +254 -236
- package/template/STYLE.md +179 -179
- package/template/requirements.txt +23 -23
- package/template/scripts/check_pair.py +90 -90
- package/template/scripts/doctor.mjs +141 -141
- package/template/scripts/find_cuts.py +45 -45
- package/template/scripts/lib/media.mjs +228 -228
- package/template/scripts/takes.mjs +424 -424
- package/template/scripts/transcribe.py +103 -103
- package/template/src/components/CommandCard.tsx +12 -11
- package/template/src/components/Cursor.tsx +153 -153
- package/template/src/compositions/BeatTest.tsx +16 -15
- package/template/templates/BRIEF.md +97 -97
- package/template/templates/STYLE.md +179 -179
|
@@ -1,228 +1,228 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared media helpers for the analysis scripts (beats, sync, reference style).
|
|
3
|
-
* Everything goes through ffmpeg; nothing here needs Python or a GPU.
|
|
4
|
-
*/
|
|
5
|
-
import {spawnSync} from 'node:child_process';
|
|
6
|
-
import {existsSync} from 'node:fs';
|
|
7
|
-
import {createRequire} from 'node:module';
|
|
8
|
-
import {dirname, join, resolve} from 'node:path';
|
|
9
|
-
import {fileURLToPath} from 'node:url';
|
|
10
|
-
|
|
11
|
-
/** The studio root, found from this file rather than from wherever the shell happens to be. */
|
|
12
|
-
const STUDIO = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Find ffmpeg wherever this machine keeps it, in order of how reliable it is:
|
|
16
|
-
*
|
|
17
|
-
* 1. $FFMPEG / $FFPROBE, for anyone who knows exactly what they want.
|
|
18
|
-
* 2. The ffmpeg-static / ffprobe-static packages, which ship a binary per platform and
|
|
19
|
-
* arrive with `npm install`. This is the one that makes a fresh machine just work —
|
|
20
|
-
* no Homebrew, no winget, no PATH editing, nothing for anyone to get wrong.
|
|
21
|
-
* 3. Whatever is on PATH.
|
|
22
|
-
* 4. The usual install locations per platform.
|
|
23
|
-
*
|
|
24
|
-
* Every script goes through this, so a studio behaves the same on a Mac laptop as on a
|
|
25
|
-
* Windows desktop, and the same for us as for someone who bought it this morning.
|
|
26
|
-
*/
|
|
27
|
-
const require_ = createRequire(import.meta.url);
|
|
28
|
-
|
|
29
|
-
const fromPackage = (tool) => {
|
|
30
|
-
try {
|
|
31
|
-
const mod = require_(`${tool}-static`);
|
|
32
|
-
const resolved = typeof mod === 'string' ? mod : mod?.path;
|
|
33
|
-
return resolved && existsSync(resolved) ? resolved : null;
|
|
34
|
-
} catch {
|
|
35
|
-
return null; // Not installed — fall through to the system copy.
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const locate = (tool) => {
|
|
40
|
-
const fromEnv = process.env[tool.toUpperCase()];
|
|
41
|
-
if (fromEnv) return fromEnv;
|
|
42
|
-
|
|
43
|
-
const packaged = fromPackage(tool);
|
|
44
|
-
if (packaged) return packaged;
|
|
45
|
-
|
|
46
|
-
const win = process.platform === 'win32';
|
|
47
|
-
const exe = win ? `${tool}.exe` : tool;
|
|
48
|
-
|
|
49
|
-
// On PATH is the common case once the installer has run.
|
|
50
|
-
const which = spawnSync(win ? 'where' : 'which', [exe], {encoding: 'utf8'});
|
|
51
|
-
if (which.status === 0) {
|
|
52
|
-
const hit = which.stdout.split('\n')[0].trim();
|
|
53
|
-
if (hit) return hit;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const candidates = win
|
|
57
|
-
? [`C:\\ffmpeg\\bin\\${exe}`, `D:\\Tools\\ffmpeg\\bin\\${exe}`, `${process.env.LOCALAPPDATA}\\ffmpeg\\bin\\${exe}`]
|
|
58
|
-
: [`/opt/homebrew/bin/${exe}`, `/usr/local/bin/${exe}`, `/usr/bin/${exe}`, `${process.env.HOME}/.local/bin/${exe}`];
|
|
59
|
-
for (const c of candidates) if (c && existsSync(c)) return c;
|
|
60
|
-
|
|
61
|
-
// Fall back to the bare name so the error says "not found" rather than a wrong path.
|
|
62
|
-
return exe;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
export const FFMPEG = locate('ffmpeg');
|
|
66
|
-
export const FFPROBE = locate('ffprobe');
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* The Python that has the studio's packages. A virtual environment inside the studio wins,
|
|
70
|
-
* then $PYTHON, then whatever is on PATH. Mac and Linux answer to `python3`; a bare
|
|
71
|
-
* `python` there is often Python 2 or nothing at all.
|
|
72
|
-
*/
|
|
73
|
-
export const PYTHON = (() => {
|
|
74
|
-
if (process.env.PYTHON) return process.env.PYTHON;
|
|
75
|
-
const win = process.platform === 'win32';
|
|
76
|
-
// Resolved against the studio, not the cwd: run any of these scripts from another folder
|
|
77
|
-
// and a cwd-relative path silently misses the venv, falls through to whatever python is
|
|
78
|
-
// on PATH, and dies with ModuleNotFoundError as though nothing had ever been installed.
|
|
79
|
-
const venv = win ? join(STUDIO, '.venv', 'Scripts', 'python.exe') : join(STUDIO, '.venv', 'bin', 'python3');
|
|
80
|
-
if (existsSync(venv)) return venv;
|
|
81
|
-
for (const name of win ? ['python.exe', 'python3.exe'] : ['python3', 'python']) {
|
|
82
|
-
const which = spawnSync(win ? 'where' : 'which', [name], {encoding: 'utf8'});
|
|
83
|
-
if (which.status === 0) {
|
|
84
|
-
const hit = which.stdout.split('\n')[0].trim();
|
|
85
|
-
if (hit) return hit;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return win ? 'python' : 'python3';
|
|
89
|
-
})();
|
|
90
|
-
|
|
91
|
-
export function run(bin, args, opts = {}) {
|
|
92
|
-
const r = spawnSync(bin, args, {encoding: 'utf8', maxBuffer: 1024 * 1024 * 256, ...opts});
|
|
93
|
-
return {ok: r.status === 0, out: r.stdout || '', err: r.stderr || '', status: r.status};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function probe(path) {
|
|
97
|
-
const r = run(FFPROBE, [
|
|
98
|
-
'-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', path,
|
|
99
|
-
]);
|
|
100
|
-
if (!r.ok) return null;
|
|
101
|
-
try {
|
|
102
|
-
const data = JSON.parse(r.out);
|
|
103
|
-
const v = (data.streams || []).find((s) => s.codec_type === 'video');
|
|
104
|
-
const a = (data.streams || []).find((s) => s.codec_type === 'audio');
|
|
105
|
-
const [n, d] = String(v?.r_frame_rate || '0/1').split('/').map(Number);
|
|
106
|
-
return {
|
|
107
|
-
duration: Number(data.format?.duration || 0),
|
|
108
|
-
width: v?.width ?? 0,
|
|
109
|
-
height: v?.height ?? 0,
|
|
110
|
-
fps: d ? n / d : 0,
|
|
111
|
-
hasAudio: Boolean(a),
|
|
112
|
-
};
|
|
113
|
-
} catch {
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Decode any media's audio to mono float32 samples at the given rate. */
|
|
119
|
-
export function decodePcm(path, rate = 22050) {
|
|
120
|
-
const r = spawnSync(
|
|
121
|
-
FFMPEG,
|
|
122
|
-
['-v', 'error', '-i', path, '-vn', '-ac', '1', '-ar', String(rate), '-f', 'f32le', '-'],
|
|
123
|
-
{maxBuffer: 1024 * 1024 * 512, encoding: 'buffer'},
|
|
124
|
-
);
|
|
125
|
-
if (r.status !== 0 || !r.stdout?.length) return null;
|
|
126
|
-
const buf = r.stdout;
|
|
127
|
-
const usable = buf.length - (buf.length % 4);
|
|
128
|
-
return new Float32Array(buf.buffer, buf.byteOffset, usable / 4);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** In-place iterative radix-2 FFT. re/im are Float64Array of length 2^k. */
|
|
132
|
-
export function fft(re, im) {
|
|
133
|
-
const n = re.length;
|
|
134
|
-
for (let i = 1, j = 0; i < n; i++) {
|
|
135
|
-
let bit = n >> 1;
|
|
136
|
-
for (; j & bit; bit >>= 1) j ^= bit;
|
|
137
|
-
j ^= bit;
|
|
138
|
-
if (i < j) {
|
|
139
|
-
[re[i], re[j]] = [re[j], re[i]];
|
|
140
|
-
[im[i], im[j]] = [im[j], im[i]];
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
for (let len = 2; len <= n; len <<= 1) {
|
|
144
|
-
const ang = (-2 * Math.PI) / len;
|
|
145
|
-
const wr = Math.cos(ang);
|
|
146
|
-
const wi = Math.sin(ang);
|
|
147
|
-
for (let i = 0; i < n; i += len) {
|
|
148
|
-
let cr = 1;
|
|
149
|
-
let ci = 0;
|
|
150
|
-
for (let k = 0; k < len / 2; k++) {
|
|
151
|
-
const ur = re[i + k];
|
|
152
|
-
const ui = im[i + k];
|
|
153
|
-
const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci;
|
|
154
|
-
const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr;
|
|
155
|
-
re[i + k] = ur + vr;
|
|
156
|
-
im[i + k] = ui + vi;
|
|
157
|
-
re[i + k + len / 2] = ur - vr;
|
|
158
|
-
im[i + k + len / 2] = ui - vi;
|
|
159
|
-
const ncr = cr * wr - ci * wi;
|
|
160
|
-
ci = cr * wi + ci * wr;
|
|
161
|
-
cr = ncr;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Onset strength over time (spectral flux): how much new energy arrives in each
|
|
169
|
-
* short window. This is the signal every beat and cut detector is built on.
|
|
170
|
-
*/
|
|
171
|
-
export function onsetEnvelope(samples, rate, {frameSize = 1024, hop = 512} = {}) {
|
|
172
|
-
const frames = Math.max(0, Math.floor((samples.length - frameSize) / hop));
|
|
173
|
-
const env = new Float64Array(frames);
|
|
174
|
-
const window = new Float64Array(frameSize);
|
|
175
|
-
for (let i = 0; i < frameSize; i++) window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (frameSize - 1));
|
|
176
|
-
let prev = new Float64Array(frameSize / 2);
|
|
177
|
-
const re = new Float64Array(frameSize);
|
|
178
|
-
const im = new Float64Array(frameSize);
|
|
179
|
-
for (let f = 0; f < frames; f++) {
|
|
180
|
-
const off = f * hop;
|
|
181
|
-
for (let i = 0; i < frameSize; i++) {
|
|
182
|
-
re[i] = samples[off + i] * window[i];
|
|
183
|
-
im[i] = 0;
|
|
184
|
-
}
|
|
185
|
-
fft(re, im);
|
|
186
|
-
let flux = 0;
|
|
187
|
-
const mag = new Float64Array(frameSize / 2);
|
|
188
|
-
for (let b = 0; b < frameSize / 2; b++) {
|
|
189
|
-
const m = Math.sqrt(re[b] * re[b] + im[b] * im[b]);
|
|
190
|
-
mag[b] = m;
|
|
191
|
-
const diff = m - prev[b];
|
|
192
|
-
if (diff > 0) flux += diff;
|
|
193
|
-
}
|
|
194
|
-
env[f] = flux;
|
|
195
|
-
prev = mag;
|
|
196
|
-
}
|
|
197
|
-
return {env, hopSeconds: hop / rate};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** Loudness envelope, used for lining two recordings up against each other. */
|
|
201
|
-
export function energyEnvelope(samples, rate, hop = 256) {
|
|
202
|
-
const frames = Math.floor(samples.length / hop);
|
|
203
|
-
const env = new Float64Array(frames);
|
|
204
|
-
for (let f = 0; f < frames; f++) {
|
|
205
|
-
let sum = 0;
|
|
206
|
-
for (let i = 0; i < hop; i++) {
|
|
207
|
-
const s = samples[f * hop + i];
|
|
208
|
-
sum += s * s;
|
|
209
|
-
}
|
|
210
|
-
env[f] = Math.sqrt(sum / hop);
|
|
211
|
-
}
|
|
212
|
-
return {env, hopSeconds: hop / rate};
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export const mean = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : 0);
|
|
216
|
-
export const median = (a) => {
|
|
217
|
-
if (!a.length) return 0;
|
|
218
|
-
const s = [...a].sort((x, y) => x - y);
|
|
219
|
-
const m = s.length >> 1;
|
|
220
|
-
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
221
|
-
};
|
|
222
|
-
export const fmt = (n, d = 2) => Number(n).toFixed(d);
|
|
223
|
-
export const timecode = (sec) => {
|
|
224
|
-
const s = Math.max(0, sec);
|
|
225
|
-
const mm = String(Math.floor(s / 60)).padStart(2, '0');
|
|
226
|
-
const ss = String(Math.floor(s % 60)).padStart(2, '0');
|
|
227
|
-
return `${mm}:${ss}`;
|
|
228
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* Shared media helpers for the analysis scripts (beats, sync, reference style).
|
|
3
|
+
* Everything goes through ffmpeg; nothing here needs Python or a GPU.
|
|
4
|
+
*/
|
|
5
|
+
import {spawnSync} from 'node:child_process';
|
|
6
|
+
import {existsSync} from 'node:fs';
|
|
7
|
+
import {createRequire} from 'node:module';
|
|
8
|
+
import {dirname, join, resolve} from 'node:path';
|
|
9
|
+
import {fileURLToPath} from 'node:url';
|
|
10
|
+
|
|
11
|
+
/** The studio root, found from this file rather than from wherever the shell happens to be. */
|
|
12
|
+
const STUDIO = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Find ffmpeg wherever this machine keeps it, in order of how reliable it is:
|
|
16
|
+
*
|
|
17
|
+
* 1. $FFMPEG / $FFPROBE, for anyone who knows exactly what they want.
|
|
18
|
+
* 2. The ffmpeg-static / ffprobe-static packages, which ship a binary per platform and
|
|
19
|
+
* arrive with `npm install`. This is the one that makes a fresh machine just work —
|
|
20
|
+
* no Homebrew, no winget, no PATH editing, nothing for anyone to get wrong.
|
|
21
|
+
* 3. Whatever is on PATH.
|
|
22
|
+
* 4. The usual install locations per platform.
|
|
23
|
+
*
|
|
24
|
+
* Every script goes through this, so a studio behaves the same on a Mac laptop as on a
|
|
25
|
+
* Windows desktop, and the same for us as for someone who bought it this morning.
|
|
26
|
+
*/
|
|
27
|
+
const require_ = createRequire(import.meta.url);
|
|
28
|
+
|
|
29
|
+
const fromPackage = (tool) => {
|
|
30
|
+
try {
|
|
31
|
+
const mod = require_(`${tool}-static`);
|
|
32
|
+
const resolved = typeof mod === 'string' ? mod : mod?.path;
|
|
33
|
+
return resolved && existsSync(resolved) ? resolved : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null; // Not installed — fall through to the system copy.
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const locate = (tool) => {
|
|
40
|
+
const fromEnv = process.env[tool.toUpperCase()];
|
|
41
|
+
if (fromEnv) return fromEnv;
|
|
42
|
+
|
|
43
|
+
const packaged = fromPackage(tool);
|
|
44
|
+
if (packaged) return packaged;
|
|
45
|
+
|
|
46
|
+
const win = process.platform === 'win32';
|
|
47
|
+
const exe = win ? `${tool}.exe` : tool;
|
|
48
|
+
|
|
49
|
+
// On PATH is the common case once the installer has run.
|
|
50
|
+
const which = spawnSync(win ? 'where' : 'which', [exe], {encoding: 'utf8'});
|
|
51
|
+
if (which.status === 0) {
|
|
52
|
+
const hit = which.stdout.split('\n')[0].trim();
|
|
53
|
+
if (hit) return hit;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const candidates = win
|
|
57
|
+
? [`C:\\ffmpeg\\bin\\${exe}`, `D:\\Tools\\ffmpeg\\bin\\${exe}`, `${process.env.LOCALAPPDATA}\\ffmpeg\\bin\\${exe}`]
|
|
58
|
+
: [`/opt/homebrew/bin/${exe}`, `/usr/local/bin/${exe}`, `/usr/bin/${exe}`, `${process.env.HOME}/.local/bin/${exe}`];
|
|
59
|
+
for (const c of candidates) if (c && existsSync(c)) return c;
|
|
60
|
+
|
|
61
|
+
// Fall back to the bare name so the error says "not found" rather than a wrong path.
|
|
62
|
+
return exe;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const FFMPEG = locate('ffmpeg');
|
|
66
|
+
export const FFPROBE = locate('ffprobe');
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The Python that has the studio's packages. A virtual environment inside the studio wins,
|
|
70
|
+
* then $PYTHON, then whatever is on PATH. Mac and Linux answer to `python3`; a bare
|
|
71
|
+
* `python` there is often Python 2 or nothing at all.
|
|
72
|
+
*/
|
|
73
|
+
export const PYTHON = (() => {
|
|
74
|
+
if (process.env.PYTHON) return process.env.PYTHON;
|
|
75
|
+
const win = process.platform === 'win32';
|
|
76
|
+
// Resolved against the studio, not the cwd: run any of these scripts from another folder
|
|
77
|
+
// and a cwd-relative path silently misses the venv, falls through to whatever python is
|
|
78
|
+
// on PATH, and dies with ModuleNotFoundError as though nothing had ever been installed.
|
|
79
|
+
const venv = win ? join(STUDIO, '.venv', 'Scripts', 'python.exe') : join(STUDIO, '.venv', 'bin', 'python3');
|
|
80
|
+
if (existsSync(venv)) return venv;
|
|
81
|
+
for (const name of win ? ['python.exe', 'python3.exe'] : ['python3', 'python']) {
|
|
82
|
+
const which = spawnSync(win ? 'where' : 'which', [name], {encoding: 'utf8'});
|
|
83
|
+
if (which.status === 0) {
|
|
84
|
+
const hit = which.stdout.split('\n')[0].trim();
|
|
85
|
+
if (hit) return hit;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return win ? 'python' : 'python3';
|
|
89
|
+
})();
|
|
90
|
+
|
|
91
|
+
export function run(bin, args, opts = {}) {
|
|
92
|
+
const r = spawnSync(bin, args, {encoding: 'utf8', maxBuffer: 1024 * 1024 * 256, ...opts});
|
|
93
|
+
return {ok: r.status === 0, out: r.stdout || '', err: r.stderr || '', status: r.status};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function probe(path) {
|
|
97
|
+
const r = run(FFPROBE, [
|
|
98
|
+
'-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', path,
|
|
99
|
+
]);
|
|
100
|
+
if (!r.ok) return null;
|
|
101
|
+
try {
|
|
102
|
+
const data = JSON.parse(r.out);
|
|
103
|
+
const v = (data.streams || []).find((s) => s.codec_type === 'video');
|
|
104
|
+
const a = (data.streams || []).find((s) => s.codec_type === 'audio');
|
|
105
|
+
const [n, d] = String(v?.r_frame_rate || '0/1').split('/').map(Number);
|
|
106
|
+
return {
|
|
107
|
+
duration: Number(data.format?.duration || 0),
|
|
108
|
+
width: v?.width ?? 0,
|
|
109
|
+
height: v?.height ?? 0,
|
|
110
|
+
fps: d ? n / d : 0,
|
|
111
|
+
hasAudio: Boolean(a),
|
|
112
|
+
};
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Decode any media's audio to mono float32 samples at the given rate. */
|
|
119
|
+
export function decodePcm(path, rate = 22050) {
|
|
120
|
+
const r = spawnSync(
|
|
121
|
+
FFMPEG,
|
|
122
|
+
['-v', 'error', '-i', path, '-vn', '-ac', '1', '-ar', String(rate), '-f', 'f32le', '-'],
|
|
123
|
+
{maxBuffer: 1024 * 1024 * 512, encoding: 'buffer'},
|
|
124
|
+
);
|
|
125
|
+
if (r.status !== 0 || !r.stdout?.length) return null;
|
|
126
|
+
const buf = r.stdout;
|
|
127
|
+
const usable = buf.length - (buf.length % 4);
|
|
128
|
+
return new Float32Array(buf.buffer, buf.byteOffset, usable / 4);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** In-place iterative radix-2 FFT. re/im are Float64Array of length 2^k. */
|
|
132
|
+
export function fft(re, im) {
|
|
133
|
+
const n = re.length;
|
|
134
|
+
for (let i = 1, j = 0; i < n; i++) {
|
|
135
|
+
let bit = n >> 1;
|
|
136
|
+
for (; j & bit; bit >>= 1) j ^= bit;
|
|
137
|
+
j ^= bit;
|
|
138
|
+
if (i < j) {
|
|
139
|
+
[re[i], re[j]] = [re[j], re[i]];
|
|
140
|
+
[im[i], im[j]] = [im[j], im[i]];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (let len = 2; len <= n; len <<= 1) {
|
|
144
|
+
const ang = (-2 * Math.PI) / len;
|
|
145
|
+
const wr = Math.cos(ang);
|
|
146
|
+
const wi = Math.sin(ang);
|
|
147
|
+
for (let i = 0; i < n; i += len) {
|
|
148
|
+
let cr = 1;
|
|
149
|
+
let ci = 0;
|
|
150
|
+
for (let k = 0; k < len / 2; k++) {
|
|
151
|
+
const ur = re[i + k];
|
|
152
|
+
const ui = im[i + k];
|
|
153
|
+
const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci;
|
|
154
|
+
const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr;
|
|
155
|
+
re[i + k] = ur + vr;
|
|
156
|
+
im[i + k] = ui + vi;
|
|
157
|
+
re[i + k + len / 2] = ur - vr;
|
|
158
|
+
im[i + k + len / 2] = ui - vi;
|
|
159
|
+
const ncr = cr * wr - ci * wi;
|
|
160
|
+
ci = cr * wi + ci * wr;
|
|
161
|
+
cr = ncr;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Onset strength over time (spectral flux): how much new energy arrives in each
|
|
169
|
+
* short window. This is the signal every beat and cut detector is built on.
|
|
170
|
+
*/
|
|
171
|
+
export function onsetEnvelope(samples, rate, {frameSize = 1024, hop = 512} = {}) {
|
|
172
|
+
const frames = Math.max(0, Math.floor((samples.length - frameSize) / hop));
|
|
173
|
+
const env = new Float64Array(frames);
|
|
174
|
+
const window = new Float64Array(frameSize);
|
|
175
|
+
for (let i = 0; i < frameSize; i++) window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (frameSize - 1));
|
|
176
|
+
let prev = new Float64Array(frameSize / 2);
|
|
177
|
+
const re = new Float64Array(frameSize);
|
|
178
|
+
const im = new Float64Array(frameSize);
|
|
179
|
+
for (let f = 0; f < frames; f++) {
|
|
180
|
+
const off = f * hop;
|
|
181
|
+
for (let i = 0; i < frameSize; i++) {
|
|
182
|
+
re[i] = samples[off + i] * window[i];
|
|
183
|
+
im[i] = 0;
|
|
184
|
+
}
|
|
185
|
+
fft(re, im);
|
|
186
|
+
let flux = 0;
|
|
187
|
+
const mag = new Float64Array(frameSize / 2);
|
|
188
|
+
for (let b = 0; b < frameSize / 2; b++) {
|
|
189
|
+
const m = Math.sqrt(re[b] * re[b] + im[b] * im[b]);
|
|
190
|
+
mag[b] = m;
|
|
191
|
+
const diff = m - prev[b];
|
|
192
|
+
if (diff > 0) flux += diff;
|
|
193
|
+
}
|
|
194
|
+
env[f] = flux;
|
|
195
|
+
prev = mag;
|
|
196
|
+
}
|
|
197
|
+
return {env, hopSeconds: hop / rate};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Loudness envelope, used for lining two recordings up against each other. */
|
|
201
|
+
export function energyEnvelope(samples, rate, hop = 256) {
|
|
202
|
+
const frames = Math.floor(samples.length / hop);
|
|
203
|
+
const env = new Float64Array(frames);
|
|
204
|
+
for (let f = 0; f < frames; f++) {
|
|
205
|
+
let sum = 0;
|
|
206
|
+
for (let i = 0; i < hop; i++) {
|
|
207
|
+
const s = samples[f * hop + i];
|
|
208
|
+
sum += s * s;
|
|
209
|
+
}
|
|
210
|
+
env[f] = Math.sqrt(sum / hop);
|
|
211
|
+
}
|
|
212
|
+
return {env, hopSeconds: hop / rate};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const mean = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : 0);
|
|
216
|
+
export const median = (a) => {
|
|
217
|
+
if (!a.length) return 0;
|
|
218
|
+
const s = [...a].sort((x, y) => x - y);
|
|
219
|
+
const m = s.length >> 1;
|
|
220
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
221
|
+
};
|
|
222
|
+
export const fmt = (n, d = 2) => Number(n).toFixed(d);
|
|
223
|
+
export const timecode = (sec) => {
|
|
224
|
+
const s = Math.max(0, sec);
|
|
225
|
+
const mm = String(Math.floor(s / 60)).padStart(2, '0');
|
|
226
|
+
const ss = String(Math.floor(s % 60)).padStart(2, '0');
|
|
227
|
+
return `${mm}:${ss}`;
|
|
228
|
+
};
|