@hraness/dawg 0.2.0 → 0.3.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/CHANGELOG.md +79 -0
- package/DAWG.md +196 -11
- package/README.md +79 -30
- package/core/diff.ts +249 -0
- package/core/key.ts +43 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +324 -1
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +280 -14
- package/src/agent/brief.ts +23 -3
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/planner.ts +38 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +398 -1
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +27 -12
- package/src/agent/xcb.ts +233 -21
- package/src/audio/click.ts +125 -0
- package/src/audio/engine.ts +407 -33
- package/src/audio/live.ts +152 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +290 -76
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +24 -7
- package/src/commands/sample.ts +302 -0
- package/src/main.ts +1043 -166
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +28 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +3 -37
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +261 -18
- package/tui/drums.ts +44 -0
- package/tui/highway.ts +18 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +143 -0
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
// Vendored from soundfish `lib/song-import/drums.ts` (same owner): the STFT drum reducer; the legacy classifier and stem wrapper are dropped.
|
|
2
|
+
import type { TimedNote } from "../types.ts";
|
|
3
|
+
import { clamp } from "./util.ts";
|
|
4
|
+
import { parseWav, type ParsedWav } from "./wav.ts";
|
|
5
|
+
|
|
6
|
+
function median(values: ArrayLike<number>): number {
|
|
7
|
+
if (values.length === 0) return 0;
|
|
8
|
+
const sorted = Float64Array.from(values).sort();
|
|
9
|
+
const middle = Math.floor(sorted.length / 2);
|
|
10
|
+
return sorted.length % 2 === 0
|
|
11
|
+
? (sorted[middle - 1]! + sorted[middle]!) / 2
|
|
12
|
+
: sorted[middle]!;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function medianAbsoluteDeviation(
|
|
16
|
+
values: ArrayLike<number>,
|
|
17
|
+
center: number,
|
|
18
|
+
): number {
|
|
19
|
+
const deviations = new Float64Array(values.length);
|
|
20
|
+
for (let index = 0; index < values.length; index += 1)
|
|
21
|
+
deviations[index] = Math.abs(values[index]! - center);
|
|
22
|
+
return median(deviations);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Drum vocabulary shared by the classifier, analysis.json, and the tests.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export const DRUM_CLASS_PITCHES = {
|
|
30
|
+
kick: 36,
|
|
31
|
+
snare: 38,
|
|
32
|
+
clap: 39,
|
|
33
|
+
"low-tom": 45,
|
|
34
|
+
"mid-tom": 47,
|
|
35
|
+
"closed-hat": 42,
|
|
36
|
+
"open-hat": 46,
|
|
37
|
+
crash: 49,
|
|
38
|
+
ride: 51,
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
export type DrumClassName = keyof typeof DRUM_CLASS_PITCHES;
|
|
42
|
+
|
|
43
|
+
export const DRUM_CLASS_NAMES = Object.keys(
|
|
44
|
+
DRUM_CLASS_PITCHES,
|
|
45
|
+
) as readonly DrumClassName[];
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// STFT front end: 1024-point Hann frames every 256 samples.
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export const STFT_FRAME_SIZE = 1024;
|
|
52
|
+
export const STFT_HOP_SIZE = 256;
|
|
53
|
+
const LOW_BAND_HZ = 150;
|
|
54
|
+
const HIGH_BAND_HZ = 2_500;
|
|
55
|
+
/** Compression applied to peak-normalized magnitudes before spectral flux. */
|
|
56
|
+
const FLUX_COMPRESSION = 20;
|
|
57
|
+
/** Onset threshold: per-band median plus this many MADs. */
|
|
58
|
+
const THRESHOLD_MADS = 4;
|
|
59
|
+
/** No band may trigger below this fraction of its own strongest flux. */
|
|
60
|
+
const THRESHOLD_FLOOR_FRACTION = 0.03;
|
|
61
|
+
const MINIMUM_ONSET_SPACING_SECONDS = 0.04;
|
|
62
|
+
const ENVELOPE_BLOCK_SECONDS = 0.001;
|
|
63
|
+
const ATTACK_WINDOW_SECONDS = 0.04;
|
|
64
|
+
const DECAY_FLOOR_RATIO = 0.01;
|
|
65
|
+
const MAXIMUM_DECAY_SECONDS = 1.5;
|
|
66
|
+
const SUB_ONSET_WINDOW_SECONDS = 0.045;
|
|
67
|
+
const SUB_ONSET_LOOKBACK_SECONDS = 0.025;
|
|
68
|
+
/** Onsets weaker than this fraction of the strongest onset are rumble or bleed. */
|
|
69
|
+
const STRENGTH_FLOOR = 0.03;
|
|
70
|
+
/** Two refined onsets closer than this are one hit. */
|
|
71
|
+
const DUPLICATE_ONSET_SECONDS = 0.03;
|
|
72
|
+
/** A much weaker onset this soon after a strong one is its own ring-out. */
|
|
73
|
+
const ECHO_WINDOW_SECONDS = 0.1;
|
|
74
|
+
const ECHO_STRENGTH_RATIO = 0.35;
|
|
75
|
+
/** A far weaker onset inside a cymbal's ring-out is its shimmer, not a hit. */
|
|
76
|
+
const RING_OUT_WINDOW_SECONDS = 0.5;
|
|
77
|
+
const RING_OUT_STRENGTH_RATIO = 8;
|
|
78
|
+
/** An onset must push at least one band this far past its threshold to count. */
|
|
79
|
+
const MINIMUM_BAND_EXCESS = 3.5;
|
|
80
|
+
const MINIMUM_ONSET_POWER_GROWTH = 1.2;
|
|
81
|
+
/** A hat under a kick or snare shows as high-band flux centred above this. */
|
|
82
|
+
const HAT_CO_ONSET_EXCESS = 5;
|
|
83
|
+
const HAT_CO_ONSET_CENTROID_HZ = 4_800;
|
|
84
|
+
const HAT_CO_ONSET_MINIMUM_SHARE = 0.02;
|
|
85
|
+
/** The low band has few bins and a tiny MAD, so a low-only trigger needs more margin. */
|
|
86
|
+
const LOW_ONLY_BAND_EXCESS = 8;
|
|
87
|
+
|
|
88
|
+
type Fft = Readonly<{
|
|
89
|
+
size: number;
|
|
90
|
+
reverse: Uint32Array;
|
|
91
|
+
cos: Float64Array;
|
|
92
|
+
sin: Float64Array;
|
|
93
|
+
}>;
|
|
94
|
+
|
|
95
|
+
function createFft(size: number): Fft {
|
|
96
|
+
const reverse = new Uint32Array(size);
|
|
97
|
+
const bits = Math.log2(size);
|
|
98
|
+
for (let index = 0; index < size; index += 1) {
|
|
99
|
+
let value = 0;
|
|
100
|
+
for (let bit = 0; bit < bits; bit += 1)
|
|
101
|
+
value |= ((index >> bit) & 1) << (bits - 1 - bit);
|
|
102
|
+
reverse[index] = value;
|
|
103
|
+
}
|
|
104
|
+
const cos = new Float64Array(size / 2);
|
|
105
|
+
const sin = new Float64Array(size / 2);
|
|
106
|
+
for (let index = 0; index < size / 2; index += 1) {
|
|
107
|
+
cos[index] = Math.cos((2 * Math.PI * index) / size);
|
|
108
|
+
sin[index] = -Math.sin((2 * Math.PI * index) / size);
|
|
109
|
+
}
|
|
110
|
+
return { size, reverse, cos, sin };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function fftInPlace(fft: Fft, re: Float64Array, im: Float64Array): void {
|
|
114
|
+
const size = fft.size;
|
|
115
|
+
for (let index = 0; index < size; index += 1) {
|
|
116
|
+
const target = fft.reverse[index]!;
|
|
117
|
+
if (target > index) {
|
|
118
|
+
const tempRe = re[index]!;
|
|
119
|
+
re[index] = re[target]!;
|
|
120
|
+
re[target] = tempRe;
|
|
121
|
+
const tempIm = im[index]!;
|
|
122
|
+
im[index] = im[target]!;
|
|
123
|
+
im[target] = tempIm;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (let width = 2; width <= size; width *= 2) {
|
|
127
|
+
const half = width / 2;
|
|
128
|
+
const step = size / width;
|
|
129
|
+
for (let start = 0; start < size; start += width) {
|
|
130
|
+
for (let offset = 0; offset < half; offset += 1) {
|
|
131
|
+
const twiddle = offset * step;
|
|
132
|
+
const cos = fft.cos[twiddle]!;
|
|
133
|
+
const sin = fft.sin[twiddle]!;
|
|
134
|
+
const even = start + offset;
|
|
135
|
+
const odd = even + half;
|
|
136
|
+
const oddRe = re[odd]! * cos - im[odd]! * sin;
|
|
137
|
+
const oddIm = re[odd]! * sin + im[odd]! * cos;
|
|
138
|
+
re[odd] = re[even]! - oddRe;
|
|
139
|
+
im[odd] = im[even]! - oddIm;
|
|
140
|
+
re[even] = re[even]! + oddRe;
|
|
141
|
+
im[even] = im[even]! + oddIm;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type FrameFeatures = Readonly<{
|
|
148
|
+
sampleRate: number;
|
|
149
|
+
frameCount: number;
|
|
150
|
+
/** Seconds at the centre of each analysis window. */
|
|
151
|
+
time: Float64Array;
|
|
152
|
+
powerLow: Float64Array;
|
|
153
|
+
powerMid: Float64Array;
|
|
154
|
+
powerHigh: Float64Array;
|
|
155
|
+
fluxLow: Float64Array;
|
|
156
|
+
fluxMid: Float64Array;
|
|
157
|
+
fluxHigh: Float64Array;
|
|
158
|
+
/** Power-weighted centroid over 30–1,000 Hz, in Hz. */
|
|
159
|
+
centroidLowMid: Float64Array;
|
|
160
|
+
/** Power-weighted centroid over the whole spectrum, in Hz. */
|
|
161
|
+
centroid: Float64Array;
|
|
162
|
+
/** Power-weighted centroid inside the high band, in Hz. */
|
|
163
|
+
centroidHigh: Float64Array;
|
|
164
|
+
/** Spectral flatness over 100 Hz to Nyquist; 1 is white noise, 0 is a pure tone. */
|
|
165
|
+
flatness: Float64Array;
|
|
166
|
+
/** 1 ms RMS envelope of the mono signal. */
|
|
167
|
+
envelope: Float32Array;
|
|
168
|
+
}>;
|
|
169
|
+
|
|
170
|
+
function monoPeakAndEnvelope(
|
|
171
|
+
wav: ParsedWav,
|
|
172
|
+
): Readonly<{ peak: number; envelope: Float32Array }> {
|
|
173
|
+
const blockSize = Math.max(
|
|
174
|
+
1,
|
|
175
|
+
Math.round(wav.sampleRate * ENVELOPE_BLOCK_SECONDS),
|
|
176
|
+
);
|
|
177
|
+
const envelope = new Float32Array(Math.ceil(wav.sampleCount / blockSize));
|
|
178
|
+
let peak = 0;
|
|
179
|
+
let block = 0;
|
|
180
|
+
let accumulator = 0;
|
|
181
|
+
let count = 0;
|
|
182
|
+
for (let index = 0; index < wav.sampleCount; index += 1) {
|
|
183
|
+
let mono = 0;
|
|
184
|
+
for (let channel = 0; channel < wav.channels; channel += 1)
|
|
185
|
+
mono += wav.sample(index, channel);
|
|
186
|
+
mono /= wav.channels;
|
|
187
|
+
const magnitude = Math.abs(mono);
|
|
188
|
+
if (magnitude > peak) peak = magnitude;
|
|
189
|
+
accumulator += mono * mono;
|
|
190
|
+
count += 1;
|
|
191
|
+
if (count === blockSize) {
|
|
192
|
+
envelope[block] = Math.sqrt(accumulator / count);
|
|
193
|
+
block += 1;
|
|
194
|
+
accumulator = 0;
|
|
195
|
+
count = 0;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (count > 0) envelope[block] = Math.sqrt(accumulator / count);
|
|
199
|
+
return { peak, envelope };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function extractFrameFeatures(wav: ParsedWav): FrameFeatures | undefined {
|
|
203
|
+
const size = STFT_FRAME_SIZE;
|
|
204
|
+
const hop = STFT_HOP_SIZE;
|
|
205
|
+
if (wav.sampleCount < size) return undefined;
|
|
206
|
+
const frameCount = Math.floor((wav.sampleCount - size) / hop) + 1;
|
|
207
|
+
const { peak, envelope } = monoPeakAndEnvelope(wav);
|
|
208
|
+
if (peak <= 0) return undefined;
|
|
209
|
+
const fft = createFft(size);
|
|
210
|
+
const window = new Float64Array(size);
|
|
211
|
+
for (let index = 0; index < size; index += 1) {
|
|
212
|
+
window[index] = 0.5 - 0.5 * Math.cos((2 * Math.PI * index) / (size - 1));
|
|
213
|
+
}
|
|
214
|
+
// A full-scale sine under a Hann window peaks near size / 4 in magnitude.
|
|
215
|
+
const magnitudeScale = 1 / ((peak * size) / 4);
|
|
216
|
+
const binHz = wav.sampleRate / size;
|
|
217
|
+
const bins = size / 2;
|
|
218
|
+
const lowEnd = Math.min(bins, Math.ceil(LOW_BAND_HZ / binHz));
|
|
219
|
+
const highStart = Math.min(bins, Math.ceil(HIGH_BAND_HZ / binHz));
|
|
220
|
+
const centroidStart = Math.max(1, Math.floor(30 / binHz));
|
|
221
|
+
const centroidEnd = Math.min(bins, Math.ceil(1_000 / binHz));
|
|
222
|
+
const flatnessStart = Math.max(1, Math.floor(100 / binHz));
|
|
223
|
+
const lag = Math.max(1, Math.round((0.01 * wav.sampleRate) / hop));
|
|
224
|
+
|
|
225
|
+
const features = {
|
|
226
|
+
sampleRate: wav.sampleRate,
|
|
227
|
+
frameCount,
|
|
228
|
+
time: new Float64Array(frameCount),
|
|
229
|
+
powerLow: new Float64Array(frameCount),
|
|
230
|
+
powerMid: new Float64Array(frameCount),
|
|
231
|
+
powerHigh: new Float64Array(frameCount),
|
|
232
|
+
fluxLow: new Float64Array(frameCount),
|
|
233
|
+
fluxMid: new Float64Array(frameCount),
|
|
234
|
+
fluxHigh: new Float64Array(frameCount),
|
|
235
|
+
centroidLowMid: new Float64Array(frameCount),
|
|
236
|
+
centroid: new Float64Array(frameCount),
|
|
237
|
+
centroidHigh: new Float64Array(frameCount),
|
|
238
|
+
flatness: new Float64Array(frameCount),
|
|
239
|
+
envelope,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const ring = new Float64Array(size);
|
|
243
|
+
const re = new Float64Array(size);
|
|
244
|
+
const im = new Float64Array(size);
|
|
245
|
+
const history: Float64Array[] = Array.from(
|
|
246
|
+
{ length: lag + 1 },
|
|
247
|
+
() => new Float64Array(bins),
|
|
248
|
+
);
|
|
249
|
+
let ringWrite = 0;
|
|
250
|
+
let nextSample = 0;
|
|
251
|
+
const readInto = (until: number): void => {
|
|
252
|
+
for (; nextSample < until; nextSample += 1) {
|
|
253
|
+
let mono = 0;
|
|
254
|
+
for (let channel = 0; channel < wav.channels; channel += 1)
|
|
255
|
+
mono += wav.sample(nextSample, channel);
|
|
256
|
+
ring[ringWrite] = mono / wav.channels;
|
|
257
|
+
ringWrite = (ringWrite + 1) % size;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
for (let frame = 0; frame < frameCount; frame += 1) {
|
|
262
|
+
const start = frame * hop;
|
|
263
|
+
readInto(start + size);
|
|
264
|
+
const ringStart = ringWrite; // oldest sample is exactly `size` samples back
|
|
265
|
+
for (let index = 0; index < size; index += 1) {
|
|
266
|
+
re[index] = ring[(ringStart + index) % size]! * window[index]!;
|
|
267
|
+
im[index] = 0;
|
|
268
|
+
}
|
|
269
|
+
fftInPlace(fft, re, im);
|
|
270
|
+
const compressed = history[frame % history.length]!;
|
|
271
|
+
const previous = history[(frame - lag + history.length) % history.length]!;
|
|
272
|
+
let powerLow = 0;
|
|
273
|
+
let powerMid = 0;
|
|
274
|
+
let powerHigh = 0;
|
|
275
|
+
let fluxLow = 0;
|
|
276
|
+
let fluxMid = 0;
|
|
277
|
+
let fluxHigh = 0;
|
|
278
|
+
let weightedHz = 0;
|
|
279
|
+
let weightedHighHz = 0;
|
|
280
|
+
let weightedLowMidHz = 0;
|
|
281
|
+
let powerLowMid = 0;
|
|
282
|
+
let logSum = 0;
|
|
283
|
+
let linearSum = 0;
|
|
284
|
+
let flatnessBins = 0;
|
|
285
|
+
for (let bin = 1; bin < bins; bin += 1) {
|
|
286
|
+
const magnitude = Math.hypot(re[bin]!, im[bin]!) * magnitudeScale;
|
|
287
|
+
const power = magnitude * magnitude;
|
|
288
|
+
const value = Math.log1p(FLUX_COMPRESSION * magnitude);
|
|
289
|
+
const flux = frame >= lag ? Math.max(0, value - previous[bin]!) : 0;
|
|
290
|
+
compressed[bin] = value;
|
|
291
|
+
const hz = bin * binHz;
|
|
292
|
+
weightedHz += hz * power;
|
|
293
|
+
if (bin < lowEnd) {
|
|
294
|
+
powerLow += power;
|
|
295
|
+
fluxLow += flux;
|
|
296
|
+
} else if (bin < highStart) {
|
|
297
|
+
powerMid += power;
|
|
298
|
+
fluxMid += flux;
|
|
299
|
+
} else {
|
|
300
|
+
powerHigh += power;
|
|
301
|
+
fluxHigh += flux;
|
|
302
|
+
weightedHighHz += hz * power;
|
|
303
|
+
}
|
|
304
|
+
if (bin >= centroidStart && bin < centroidEnd) {
|
|
305
|
+
weightedLowMidHz += hz * power;
|
|
306
|
+
powerLowMid += power;
|
|
307
|
+
}
|
|
308
|
+
if (bin >= flatnessStart) {
|
|
309
|
+
logSum += Math.log(magnitude + 1e-9);
|
|
310
|
+
linearSum += magnitude;
|
|
311
|
+
flatnessBins += 1;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const total = powerLow + powerMid + powerHigh;
|
|
315
|
+
features.time[frame] = (start + size / 2) / wav.sampleRate;
|
|
316
|
+
features.powerLow[frame] = powerLow;
|
|
317
|
+
features.powerMid[frame] = powerMid;
|
|
318
|
+
features.powerHigh[frame] = powerHigh;
|
|
319
|
+
features.fluxLow[frame] = fluxLow;
|
|
320
|
+
features.fluxMid[frame] = fluxMid;
|
|
321
|
+
features.fluxHigh[frame] = fluxHigh;
|
|
322
|
+
features.centroid[frame] = total > 0 ? weightedHz / total : 0;
|
|
323
|
+
features.centroidHigh[frame] =
|
|
324
|
+
powerHigh > 0 ? weightedHighHz / powerHigh : 0;
|
|
325
|
+
features.centroidLowMid[frame] =
|
|
326
|
+
powerLowMid > 0 ? weightedLowMidHz / powerLowMid : 0;
|
|
327
|
+
features.flatness[frame] =
|
|
328
|
+
flatnessBins > 0 && linearSum > 0
|
|
329
|
+
? Math.exp(logSum / flatnessBins) / (linearSum / flatnessBins)
|
|
330
|
+
: 0;
|
|
331
|
+
}
|
|
332
|
+
return features;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// Onset detection: per-band median + k·MAD thresholds, peak picking on the
|
|
337
|
+
// threshold-normalized sum, and a time-domain refinement of each onset.
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
function bandThreshold(flux: Float64Array): number {
|
|
341
|
+
const center = median(flux);
|
|
342
|
+
const deviation = medianAbsoluteDeviation(flux, center);
|
|
343
|
+
let maximum = 0;
|
|
344
|
+
for (const value of flux) if (value > maximum) maximum = value;
|
|
345
|
+
return Math.max(
|
|
346
|
+
center + THRESHOLD_MADS * deviation,
|
|
347
|
+
THRESHOLD_FLOOR_FRACTION * maximum,
|
|
348
|
+
1e-9,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export type DrumOnsetFeatures = Readonly<{
|
|
353
|
+
/** Refined onset time in seconds. */
|
|
354
|
+
time: number;
|
|
355
|
+
frame: number;
|
|
356
|
+
/** Each band's share of the power the onset adds; the three sum to 1. */
|
|
357
|
+
lowRatio: number;
|
|
358
|
+
midRatio: number;
|
|
359
|
+
highRatio: number;
|
|
360
|
+
/** Each band's flux divided by that band's threshold. */
|
|
361
|
+
lowExcess: number;
|
|
362
|
+
midExcess: number;
|
|
363
|
+
highExcess: number;
|
|
364
|
+
/** Total onset flux relative to the strongest onset in the stem. */
|
|
365
|
+
strength: number;
|
|
366
|
+
centroidHz: number;
|
|
367
|
+
centroidLowMidHz: number;
|
|
368
|
+
centroidHighHz: number;
|
|
369
|
+
flatness: number;
|
|
370
|
+
/** Seconds until the total power falls 20 dB under its attack peak, capped at the next onset. */
|
|
371
|
+
decaySeconds: number;
|
|
372
|
+
/** Seconds until the high band falls 20 dB under its attack peak, capped at the next onset. */
|
|
373
|
+
highDecaySeconds: number;
|
|
374
|
+
/** Envelope peaks inside the first 45 ms; claps flam into several. */
|
|
375
|
+
subOnsets: number;
|
|
376
|
+
}>;
|
|
377
|
+
|
|
378
|
+
function refineOnsetTime(
|
|
379
|
+
envelope: Float32Array,
|
|
380
|
+
blockSeconds: number,
|
|
381
|
+
approximate: number,
|
|
382
|
+
): number {
|
|
383
|
+
const center = Math.round(approximate / blockSeconds);
|
|
384
|
+
const radius = Math.round(0.025 / blockSeconds);
|
|
385
|
+
let best = clamp(center, 0, envelope.length - 1);
|
|
386
|
+
let bestRise = -Infinity;
|
|
387
|
+
for (
|
|
388
|
+
let block = Math.max(2, center - radius);
|
|
389
|
+
block <= Math.min(envelope.length - 1, center + radius);
|
|
390
|
+
block += 1
|
|
391
|
+
) {
|
|
392
|
+
const rise = envelope[block]! - envelope[block - 2]!;
|
|
393
|
+
if (rise > bestRise) {
|
|
394
|
+
bestRise = rise;
|
|
395
|
+
best = block;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return best * blockSeconds;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function countSubOnsets(
|
|
402
|
+
envelope: Float32Array,
|
|
403
|
+
blockSeconds: number,
|
|
404
|
+
onsetSeconds: number,
|
|
405
|
+
): number {
|
|
406
|
+
const start = Math.max(
|
|
407
|
+
0,
|
|
408
|
+
Math.round((onsetSeconds - SUB_ONSET_LOOKBACK_SECONDS) / blockSeconds),
|
|
409
|
+
);
|
|
410
|
+
const end = Math.min(
|
|
411
|
+
envelope.length - 1,
|
|
412
|
+
start +
|
|
413
|
+
Math.round(
|
|
414
|
+
(SUB_ONSET_WINDOW_SECONDS + SUB_ONSET_LOOKBACK_SECONDS) / blockSeconds,
|
|
415
|
+
),
|
|
416
|
+
);
|
|
417
|
+
let maximum = 0;
|
|
418
|
+
for (let block = start; block <= end; block += 1)
|
|
419
|
+
maximum = Math.max(maximum, envelope[block]!);
|
|
420
|
+
if (maximum <= 0) return 0;
|
|
421
|
+
const separation = Math.max(1, Math.round(0.006 / blockSeconds));
|
|
422
|
+
let count = 0;
|
|
423
|
+
let lastPeak = -Infinity;
|
|
424
|
+
for (let block = start + 1; block < end; block += 1) {
|
|
425
|
+
const value = envelope[block]!;
|
|
426
|
+
if (value < 0.45 * maximum) continue;
|
|
427
|
+
if (value < envelope[block - 1]! || value < envelope[block + 1]!) continue;
|
|
428
|
+
// Require a dip of at least half between successive peaks.
|
|
429
|
+
let dipped = lastPeak === -Infinity;
|
|
430
|
+
if (!dipped) {
|
|
431
|
+
for (let back = block - 1; back > lastPeak; back -= 1) {
|
|
432
|
+
if (envelope[back]! < 0.5 * Math.min(value, envelope[lastPeak]!)) {
|
|
433
|
+
dipped = true;
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (!dipped || block - lastPeak < separation) continue;
|
|
439
|
+
count += 1;
|
|
440
|
+
lastPeak = block;
|
|
441
|
+
}
|
|
442
|
+
return count;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function decayLength(
|
|
446
|
+
power: Float64Array,
|
|
447
|
+
time: Float64Array,
|
|
448
|
+
onsetFrame: number,
|
|
449
|
+
attackFrames: number,
|
|
450
|
+
limitFrame: number,
|
|
451
|
+
): number {
|
|
452
|
+
let peak = 0;
|
|
453
|
+
let peakFrame = onsetFrame;
|
|
454
|
+
for (
|
|
455
|
+
let frame = onsetFrame;
|
|
456
|
+
frame <= Math.min(limitFrame, onsetFrame + attackFrames);
|
|
457
|
+
frame += 1
|
|
458
|
+
) {
|
|
459
|
+
if (power[frame]! > peak) {
|
|
460
|
+
peak = power[frame]!;
|
|
461
|
+
peakFrame = frame;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (peak <= 0) return 0;
|
|
465
|
+
const floor = peak * DECAY_FLOOR_RATIO;
|
|
466
|
+
for (let frame = peakFrame; frame <= limitFrame; frame += 1) {
|
|
467
|
+
if (power[frame]! <= floor)
|
|
468
|
+
return Math.max(0, time[frame]! - time[onsetFrame]!);
|
|
469
|
+
}
|
|
470
|
+
return Math.min(
|
|
471
|
+
MAXIMUM_DECAY_SECONDS,
|
|
472
|
+
Math.max(0, time[limitFrame]! - time[onsetFrame]!),
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function detectDrumOnsets(
|
|
477
|
+
features: FrameFeatures,
|
|
478
|
+
): readonly DrumOnsetFeatures[] {
|
|
479
|
+
const { frameCount, time } = features;
|
|
480
|
+
if (frameCount < 3) return [];
|
|
481
|
+
const thresholds = {
|
|
482
|
+
low: bandThreshold(features.fluxLow),
|
|
483
|
+
mid: bandThreshold(features.fluxMid),
|
|
484
|
+
high: bandThreshold(features.fluxHigh),
|
|
485
|
+
};
|
|
486
|
+
const combined = new Float64Array(frameCount);
|
|
487
|
+
const triggered = new Uint8Array(frameCount);
|
|
488
|
+
for (let frame = 0; frame < frameCount; frame += 1) {
|
|
489
|
+
const low = features.fluxLow[frame]! / thresholds.low;
|
|
490
|
+
const mid = features.fluxMid[frame]! / thresholds.mid;
|
|
491
|
+
const high = features.fluxHigh[frame]! / thresholds.high;
|
|
492
|
+
combined[frame] = low + mid + high;
|
|
493
|
+
triggered[frame] = low >= 1 || mid >= 1 || high >= 1 ? 1 : 0;
|
|
494
|
+
}
|
|
495
|
+
const hopSeconds = STFT_HOP_SIZE / features.sampleRate;
|
|
496
|
+
const neighborhood = Math.max(1, Math.round(0.012 / hopSeconds));
|
|
497
|
+
const spacingFrames = Math.max(
|
|
498
|
+
1,
|
|
499
|
+
Math.round(MINIMUM_ONSET_SPACING_SECONDS / hopSeconds),
|
|
500
|
+
);
|
|
501
|
+
const peaks: number[] = [];
|
|
502
|
+
for (let frame = 1; frame < frameCount - 1; frame += 1) {
|
|
503
|
+
if (triggered[frame] === 0) continue;
|
|
504
|
+
let isPeak = true;
|
|
505
|
+
for (let offset = 1; offset <= neighborhood && isPeak; offset += 1) {
|
|
506
|
+
if (frame - offset >= 0 && combined[frame - offset]! > combined[frame]!)
|
|
507
|
+
isPeak = false;
|
|
508
|
+
if (
|
|
509
|
+
frame + offset < frameCount &&
|
|
510
|
+
combined[frame + offset]! >= combined[frame]!
|
|
511
|
+
)
|
|
512
|
+
isPeak = false;
|
|
513
|
+
}
|
|
514
|
+
if (!isPeak) continue;
|
|
515
|
+
const previous = peaks.at(-1);
|
|
516
|
+
if (previous !== undefined && frame - previous < spacingFrames) {
|
|
517
|
+
if (combined[frame]! > combined[previous]!)
|
|
518
|
+
peaks[peaks.length - 1] = frame;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
peaks.push(frame);
|
|
522
|
+
}
|
|
523
|
+
if (peaks.length === 0) return [];
|
|
524
|
+
|
|
525
|
+
const lag = Math.max(
|
|
526
|
+
1,
|
|
527
|
+
Math.round((0.01 * features.sampleRate) / STFT_HOP_SIZE),
|
|
528
|
+
);
|
|
529
|
+
const attackFrames = Math.max(
|
|
530
|
+
1,
|
|
531
|
+
Math.round(ATTACK_WINDOW_SECONDS / hopSeconds),
|
|
532
|
+
);
|
|
533
|
+
const blockSeconds = ENVELOPE_BLOCK_SECONDS;
|
|
534
|
+
const totalPower = new Float64Array(frameCount);
|
|
535
|
+
for (let frame = 0; frame < frameCount; frame += 1) {
|
|
536
|
+
totalPower[frame] =
|
|
537
|
+
features.powerLow[frame]! +
|
|
538
|
+
features.powerMid[frame]! +
|
|
539
|
+
features.powerHigh[frame]!;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Stage one: score every candidate, then drop rumble, duplicates, and ring-outs
|
|
543
|
+
// so stage two measures each hit against its real neighbours.
|
|
544
|
+
let maximumFlux = 0;
|
|
545
|
+
const scored = peaks.map((frame) => {
|
|
546
|
+
// Sum the increment over the frames the flux lag spreads an attack across.
|
|
547
|
+
let totalFlux = 0;
|
|
548
|
+
for (let offset = -lag; offset <= lag; offset += 1) {
|
|
549
|
+
const at = frame + offset;
|
|
550
|
+
if (at < 0 || at >= frameCount) continue;
|
|
551
|
+
totalFlux +=
|
|
552
|
+
features.fluxLow[at]! + features.fluxMid[at]! + features.fluxHigh[at]!;
|
|
553
|
+
}
|
|
554
|
+
maximumFlux = Math.max(maximumFlux, totalFlux);
|
|
555
|
+
return {
|
|
556
|
+
frame,
|
|
557
|
+
totalFlux,
|
|
558
|
+
time: refineOnsetTime(features.envelope, blockSeconds, time[frame]!),
|
|
559
|
+
lowExcess: features.fluxLow[frame]! / thresholds.low,
|
|
560
|
+
midExcess: features.fluxMid[frame]! / thresholds.mid,
|
|
561
|
+
highExcess: features.fluxHigh[frame]! / thresholds.high,
|
|
562
|
+
};
|
|
563
|
+
});
|
|
564
|
+
const hasBandAttack = (power: Float64Array, frame: number): boolean => {
|
|
565
|
+
let before = 0;
|
|
566
|
+
let after = 0;
|
|
567
|
+
for (let at = Math.max(0, frame - lag - 3); at < frame; at += 1)
|
|
568
|
+
before = Math.max(before, power[at]!);
|
|
569
|
+
for (
|
|
570
|
+
let at = frame;
|
|
571
|
+
at <= Math.min(frameCount - 1, frame + attackFrames);
|
|
572
|
+
at += 1
|
|
573
|
+
)
|
|
574
|
+
after = Math.max(after, power[at]!);
|
|
575
|
+
return after > 0 && after >= before * MINIMUM_ONSET_POWER_GROWTH;
|
|
576
|
+
};
|
|
577
|
+
const kept: Array<(typeof scored)[number] & { strength: number }> = [];
|
|
578
|
+
for (const candidate of scored) {
|
|
579
|
+
const onset = {
|
|
580
|
+
...candidate,
|
|
581
|
+
strength:
|
|
582
|
+
maximumFlux > 0 ? clamp(candidate.totalFlux / maximumFlux, 0, 1) : 0,
|
|
583
|
+
};
|
|
584
|
+
if (onset.strength < STRENGTH_FLOOR) continue;
|
|
585
|
+
if (
|
|
586
|
+
Math.max(onset.lowExcess, onset.midExcess, onset.highExcess) <
|
|
587
|
+
MINIMUM_BAND_EXCESS
|
|
588
|
+
)
|
|
589
|
+
continue;
|
|
590
|
+
if (
|
|
591
|
+
onset.midExcess < MINIMUM_BAND_EXCESS &&
|
|
592
|
+
onset.highExcess < MINIMUM_BAND_EXCESS &&
|
|
593
|
+
onset.lowExcess < LOW_ONLY_BAND_EXCESS
|
|
594
|
+
)
|
|
595
|
+
continue;
|
|
596
|
+
if (!(
|
|
597
|
+
(onset.lowExcess >= MINIMUM_BAND_EXCESS &&
|
|
598
|
+
hasBandAttack(features.powerLow, onset.frame)) ||
|
|
599
|
+
(onset.midExcess >= MINIMUM_BAND_EXCESS &&
|
|
600
|
+
hasBandAttack(features.powerMid, onset.frame)) ||
|
|
601
|
+
(onset.highExcess >= MINIMUM_BAND_EXCESS &&
|
|
602
|
+
hasBandAttack(features.powerHigh, onset.frame))
|
|
603
|
+
))
|
|
604
|
+
continue;
|
|
605
|
+
const previous = kept.at(-1);
|
|
606
|
+
if (previous !== undefined) {
|
|
607
|
+
const gap = onset.time - previous.time;
|
|
608
|
+
if (gap < DUPLICATE_ONSET_SECONDS) {
|
|
609
|
+
if (onset.strength > previous.strength) kept[kept.length - 1] = onset;
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (
|
|
613
|
+
gap < ECHO_WINDOW_SECONDS &&
|
|
614
|
+
onset.strength < ECHO_STRENGTH_RATIO * previous.strength
|
|
615
|
+
)
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
// `kept` is time-ordered, so the scan stops at the first onset outside the window.
|
|
619
|
+
let insideRingOut = false;
|
|
620
|
+
for (let back = kept.length - 1; back >= 0; back -= 1) {
|
|
621
|
+
const earlier = kept[back]!;
|
|
622
|
+
if (onset.time - earlier.time >= RING_OUT_WINDOW_SECONDS) break;
|
|
623
|
+
if (earlier.strength >= RING_OUT_STRENGTH_RATIO * onset.strength) {
|
|
624
|
+
insideRingOut = true;
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (insideRingOut) continue;
|
|
629
|
+
kept.push(onset);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Stage two: band shares, spectral shape, and decay for each kept onset.
|
|
633
|
+
const bandRise = (
|
|
634
|
+
power: Float64Array,
|
|
635
|
+
frame: number,
|
|
636
|
+
limitFrame: number,
|
|
637
|
+
): number => {
|
|
638
|
+
let before = Infinity;
|
|
639
|
+
for (let at = Math.max(0, frame - lag - 2); at < frame; at += 1)
|
|
640
|
+
before = Math.min(before, power[at]!);
|
|
641
|
+
if (before === Infinity) before = 0;
|
|
642
|
+
let after = 0;
|
|
643
|
+
for (
|
|
644
|
+
let at = frame;
|
|
645
|
+
at <= Math.min(limitFrame, frame + attackFrames);
|
|
646
|
+
at += 1
|
|
647
|
+
)
|
|
648
|
+
after = Math.max(after, power[at]!);
|
|
649
|
+
return Math.max(0, after - before);
|
|
650
|
+
};
|
|
651
|
+
return kept.map((onset, index) => {
|
|
652
|
+
const { frame } = onset;
|
|
653
|
+
const nextFrame = kept[index + 1]?.frame;
|
|
654
|
+
const limitFrame = Math.min(
|
|
655
|
+
frameCount - 1,
|
|
656
|
+
nextFrame === undefined ? frameCount - 1 : nextFrame - 1,
|
|
657
|
+
frame + Math.round(MAXIMUM_DECAY_SECONDS / hopSeconds),
|
|
658
|
+
);
|
|
659
|
+
const lowRise = bandRise(features.powerLow, frame, limitFrame);
|
|
660
|
+
const midRise = bandRise(features.powerMid, frame, limitFrame);
|
|
661
|
+
const highRise = bandRise(features.powerHigh, frame, limitFrame);
|
|
662
|
+
const totalRise = lowRise + midRise + highRise;
|
|
663
|
+
let attackPower = 0;
|
|
664
|
+
let centroid = 0;
|
|
665
|
+
let centroidLowMid = 0;
|
|
666
|
+
let centroidHigh = 0;
|
|
667
|
+
let highPower = 0;
|
|
668
|
+
let flatness = 0;
|
|
669
|
+
for (
|
|
670
|
+
let at = frame;
|
|
671
|
+
at <= Math.min(limitFrame, frame + attackFrames);
|
|
672
|
+
at += 1
|
|
673
|
+
) {
|
|
674
|
+
const weight = totalPower[at]!;
|
|
675
|
+
attackPower += weight;
|
|
676
|
+
centroid += features.centroid[at]! * weight;
|
|
677
|
+
centroidLowMid += features.centroidLowMid[at]! * weight;
|
|
678
|
+
flatness += features.flatness[at]! * weight;
|
|
679
|
+
centroidHigh += features.centroidHigh[at]! * features.powerHigh[at]!;
|
|
680
|
+
highPower += features.powerHigh[at]!;
|
|
681
|
+
}
|
|
682
|
+
return {
|
|
683
|
+
time: onset.time,
|
|
684
|
+
frame,
|
|
685
|
+
lowRatio: totalRise > 0 ? lowRise / totalRise : 0,
|
|
686
|
+
midRatio: totalRise > 0 ? midRise / totalRise : 0,
|
|
687
|
+
highRatio: totalRise > 0 ? highRise / totalRise : 0,
|
|
688
|
+
lowExcess: onset.lowExcess,
|
|
689
|
+
midExcess: onset.midExcess,
|
|
690
|
+
highExcess: onset.highExcess,
|
|
691
|
+
strength: onset.strength,
|
|
692
|
+
centroidHz: attackPower > 0 ? centroid / attackPower : 0,
|
|
693
|
+
centroidLowMidHz: attackPower > 0 ? centroidLowMid / attackPower : 0,
|
|
694
|
+
centroidHighHz: highPower > 0 ? centroidHigh / highPower : 0,
|
|
695
|
+
flatness: attackPower > 0 ? flatness / attackPower : 0,
|
|
696
|
+
decaySeconds: decayLength(
|
|
697
|
+
totalPower,
|
|
698
|
+
time,
|
|
699
|
+
frame,
|
|
700
|
+
attackFrames,
|
|
701
|
+
limitFrame,
|
|
702
|
+
),
|
|
703
|
+
highDecaySeconds: decayLength(
|
|
704
|
+
features.powerHigh,
|
|
705
|
+
time,
|
|
706
|
+
frame,
|
|
707
|
+
attackFrames,
|
|
708
|
+
limitFrame,
|
|
709
|
+
),
|
|
710
|
+
subOnsets: countSubOnsets(features.envelope, blockSeconds, onset.time),
|
|
711
|
+
};
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ---------------------------------------------------------------------------
|
|
716
|
+
// Classification into the eight General MIDI drum classes.
|
|
717
|
+
// ---------------------------------------------------------------------------
|
|
718
|
+
|
|
719
|
+
export type ClassifiedDrumHit = Readonly<{
|
|
720
|
+
className: DrumClassName;
|
|
721
|
+
/** The winning band's share of the onset increment; feeds the heuristic score. */
|
|
722
|
+
dominance: number;
|
|
723
|
+
}>;
|
|
724
|
+
|
|
725
|
+
export function classifyDrumOnset(
|
|
726
|
+
onset: DrumOnsetFeatures,
|
|
727
|
+
): readonly ClassifiedDrumHit[] {
|
|
728
|
+
const { lowRatio, midRatio, highRatio } = onset;
|
|
729
|
+
const cymbalFamily =
|
|
730
|
+
lowRatio < 0.15 && highRatio >= Math.max(0.35, midRatio * 0.8);
|
|
731
|
+
if (cymbalFamily) {
|
|
732
|
+
let className: DrumClassName;
|
|
733
|
+
if (onset.highDecaySeconds >= 0.5 && midRatio >= 0.25) className = "crash";
|
|
734
|
+
else if (onset.highDecaySeconds >= 0.35 && onset.centroidHz < 5_000)
|
|
735
|
+
className = "ride";
|
|
736
|
+
else if (onset.highDecaySeconds >= 0.15) className = "open-hat";
|
|
737
|
+
else className = "closed-hat";
|
|
738
|
+
return [{ className, dominance: highRatio }];
|
|
739
|
+
}
|
|
740
|
+
const hits: ClassifiedDrumHit[] = [];
|
|
741
|
+
// Toms ring longer than kicks and snares and carry almost no high band.
|
|
742
|
+
const pitched = highRatio < 0.1 && onset.decaySeconds >= 0.18;
|
|
743
|
+
if (lowRatio >= 0.4 && highRatio < 0.25) {
|
|
744
|
+
if (pitched && onset.centroidLowMidHz >= 130) {
|
|
745
|
+
hits.push({
|
|
746
|
+
className: onset.centroidLowMidHz < 230 ? "low-tom" : "mid-tom",
|
|
747
|
+
dominance: lowRatio,
|
|
748
|
+
});
|
|
749
|
+
} else {
|
|
750
|
+
hits.push({ className: "kick", dominance: lowRatio });
|
|
751
|
+
}
|
|
752
|
+
} else if (pitched && onset.centroidLowMidHz < 500) {
|
|
753
|
+
hits.push({ className: "mid-tom", dominance: midRatio });
|
|
754
|
+
} else if (
|
|
755
|
+
onset.subOnsets >= 3 &&
|
|
756
|
+
onset.flatness >= 0.2 &&
|
|
757
|
+
onset.centroidLowMidHz >= 450
|
|
758
|
+
) {
|
|
759
|
+
hits.push({ className: "clap", dominance: midRatio });
|
|
760
|
+
} else {
|
|
761
|
+
hits.push({ className: "snare", dominance: midRatio });
|
|
762
|
+
}
|
|
763
|
+
// A hat struck with a kick or snare adds little power but a distinct
|
|
764
|
+
// high-band flux centred well above the drum's own brightness.
|
|
765
|
+
if (
|
|
766
|
+
onset.highExcess >= HAT_CO_ONSET_EXCESS &&
|
|
767
|
+
highRatio >= HAT_CO_ONSET_MINIMUM_SHARE &&
|
|
768
|
+
onset.centroidHighHz >= HAT_CO_ONSET_CENTROID_HZ
|
|
769
|
+
) {
|
|
770
|
+
hits.push({
|
|
771
|
+
className: "closed-hat",
|
|
772
|
+
dominance: clamp(onset.highExcess / 10, 0, 1),
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
return hits;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** dawg velocities are 0..1 (soundfish used MIDI 1..127 here). */
|
|
779
|
+
function velocityFor(strength: number): number {
|
|
780
|
+
const midi = clamp(Math.round(35 + 92 * Math.sqrt(strength)), 1, 127);
|
|
781
|
+
return Math.round((midi / 127) * 100) / 100;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* STFT drum reducer. Every onset becomes one hit per emitted class with a
|
|
786
|
+
* one-hop duration; the loop builder later snaps each hit to a single step.
|
|
787
|
+
*/
|
|
788
|
+
export function classifyDrumWav(bytes: Uint8Array): readonly TimedNote[] {
|
|
789
|
+
const wav = parseWav(bytes);
|
|
790
|
+
const features = extractFrameFeatures(wav);
|
|
791
|
+
if (features === undefined) return [];
|
|
792
|
+
const onsets = detectDrumOnsets(features);
|
|
793
|
+
const hopSeconds = STFT_HOP_SIZE / wav.sampleRate;
|
|
794
|
+
const notes: TimedNote[] = [];
|
|
795
|
+
for (const onset of onsets) {
|
|
796
|
+
const hits = classifyDrumOnset(onset);
|
|
797
|
+
for (const [index, hit] of hits.entries()) {
|
|
798
|
+
// A co-emitted hat rides under the drum it was struck with.
|
|
799
|
+
const share = index > 0 ? 0.5 : 1;
|
|
800
|
+
notes.push({
|
|
801
|
+
pitch: DRUM_CLASS_PITCHES[hit.className],
|
|
802
|
+
startSeconds: onset.time,
|
|
803
|
+
endSeconds: onset.time + hopSeconds,
|
|
804
|
+
velocity: velocityFor(onset.strength * share),
|
|
805
|
+
heuristicScore: clamp(
|
|
806
|
+
0.35 + 0.4 * hit.dominance + 0.25 * onset.strength,
|
|
807
|
+
0,
|
|
808
|
+
1,
|
|
809
|
+
),
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return notes.sort(
|
|
814
|
+
(left, right) =>
|
|
815
|
+
left.startSeconds - right.startSeconds || left.pitch - right.pitch,
|
|
816
|
+
);
|
|
817
|
+
}
|