@octoseq/mir 0.1.0-main.4ecb074 → 0.1.0-main.5fdb072
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/dist/chunk-HF3QHCRK.js +3234 -0
- package/dist/chunk-HF3QHCRK.js.map +1 -0
- package/dist/index.d.ts +2122 -47
- package/dist/index.js +3524 -39
- package/dist/index.js.map +1 -1
- package/dist/runMir-CnJQbBr8.d.ts +187 -0
- package/dist/runner/runMir.d.ts +2 -2
- package/dist/runner/runMir.js +1 -1
- package/dist/runner/workerProtocol.d.ts +8 -1
- package/dist/runner/workerProtocol.js.map +1 -1
- package/dist/types-DqH4umN8.d.ts +761 -0
- package/package.json +2 -2
- package/src/dsp/activity.ts +544 -0
- package/src/dsp/bandCqt.ts +662 -0
- package/src/dsp/bandEvents.ts +351 -0
- package/src/dsp/bandMask.ts +225 -0
- package/src/dsp/bandMir.ts +524 -0
- package/src/dsp/bandProposal.ts +552 -0
- package/src/dsp/beatCandidates.ts +299 -0
- package/src/dsp/cqt.ts +386 -0
- package/src/dsp/cqtSignals.ts +462 -0
- package/src/dsp/customSignalReduction.ts +841 -0
- package/src/dsp/eventToSignal.ts +531 -0
- package/src/dsp/frequencyBand.ts +956 -0
- package/src/dsp/mel.ts +56 -3
- package/src/dsp/musicalTime.ts +240 -0
- package/src/dsp/onset.ts +296 -30
- package/src/dsp/peakPicking.ts +519 -0
- package/src/dsp/phaseAlignment.ts +153 -0
- package/src/dsp/pitch.ts +289 -0
- package/src/dsp/resample.ts +44 -0
- package/src/dsp/signalTransforms.ts +660 -0
- package/src/dsp/silenceGating.ts +511 -0
- package/src/dsp/spectral.ts +124 -4
- package/src/dsp/tempoHypotheses.ts +395 -0
- package/src/gpu/bufferPool.ts +266 -0
- package/src/gpu/context.ts +30 -6
- package/src/gpu/helpers.ts +85 -0
- package/src/gpu/melProject.ts +83 -0
- package/src/index.ts +366 -5
- package/src/runner/runMir.ts +234 -2
- package/src/runner/workerProtocol.ts +9 -1
- package/src/types.ts +768 -3
- package/dist/chunk-DUWYCAVG.js +0 -1525
- package/dist/chunk-DUWYCAVG.js.map +0 -1
- package/dist/runMir-CSIBwNZ3.d.ts +0 -84
- package/dist/types-BE3py4fZ.d.ts +0 -83
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import type { BeatCandidate, TempoHypothesis, TempoHypothesisEvidence } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Configuration for tempo hypothesis generation.
|
|
5
|
+
*/
|
|
6
|
+
export type TempoHypothesesOptions = {
|
|
7
|
+
/** Minimum BPM to consider. Default: 24. */
|
|
8
|
+
minBpm?: number;
|
|
9
|
+
/** Maximum BPM to consider. Default: 300. */
|
|
10
|
+
maxBpm?: number;
|
|
11
|
+
/** Histogram bin size in BPM. Default: 1.0. */
|
|
12
|
+
binSizeBpm?: number;
|
|
13
|
+
/** Maximum hypotheses to return. Default: 10. */
|
|
14
|
+
maxHypotheses?: number;
|
|
15
|
+
/** Minimum confidence threshold (0-1). Default: 0.05. */
|
|
16
|
+
minConfidence?: number;
|
|
17
|
+
/** Weight IOIs by candidate strength. Default: true. */
|
|
18
|
+
weightByStrength?: boolean;
|
|
19
|
+
/** Include histogram data in output. Default: false. */
|
|
20
|
+
includeHistogram?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type TempoHypothesesOutput = {
|
|
24
|
+
hypotheses: TempoHypothesis[];
|
|
25
|
+
inputCandidateCount: number;
|
|
26
|
+
histogram?: {
|
|
27
|
+
bpmBins: Float32Array;
|
|
28
|
+
counts: Float32Array;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Convert interval (seconds) to BPM.
|
|
34
|
+
*/
|
|
35
|
+
function intervalToBpm(intervalSec: number): number {
|
|
36
|
+
return 60.0 / intervalSec;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Convert BPM to interval (seconds).
|
|
41
|
+
*/
|
|
42
|
+
function bpmToInterval(bpm: number): number {
|
|
43
|
+
return 60.0 / bpm;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type IOI = { intervalSec: number; weight: number };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compute inter-onset intervals from beat candidates.
|
|
50
|
+
*
|
|
51
|
+
* @param candidates - Beat candidates sorted by time
|
|
52
|
+
* @param weightByStrength - Whether to weight by candidate strength
|
|
53
|
+
* @returns Array of { intervalSec, weight } pairs
|
|
54
|
+
*/
|
|
55
|
+
function computeIOIs(candidates: BeatCandidate[], weightByStrength: boolean): IOI[] {
|
|
56
|
+
if (candidates.length < 2) return [];
|
|
57
|
+
|
|
58
|
+
const iois: IOI[] = [];
|
|
59
|
+
|
|
60
|
+
// Sort candidates by time (should already be sorted, but be defensive)
|
|
61
|
+
const sorted = [...candidates].sort((a, b) => a.time - b.time);
|
|
62
|
+
|
|
63
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
64
|
+
const prev = sorted[i - 1]!;
|
|
65
|
+
const curr = sorted[i]!;
|
|
66
|
+
const interval = curr.time - prev.time;
|
|
67
|
+
|
|
68
|
+
// Skip invalid intervals
|
|
69
|
+
if (interval <= 0) continue;
|
|
70
|
+
|
|
71
|
+
// Weight is geometric mean of adjacent strengths, or 1.0 if not weighting
|
|
72
|
+
const weight = weightByStrength
|
|
73
|
+
? Math.sqrt(prev.strength * curr.strength)
|
|
74
|
+
: 1.0;
|
|
75
|
+
|
|
76
|
+
iois.push({ intervalSec: interval, weight });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return iois;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build a weighted histogram of BPM values from IOIs.
|
|
84
|
+
*
|
|
85
|
+
* @param iois - Inter-onset intervals with weights
|
|
86
|
+
* @param minBpm - Minimum BPM (determines max interval)
|
|
87
|
+
* @param maxBpm - Maximum BPM (determines min interval)
|
|
88
|
+
* @param binSizeBpm - Size of each histogram bin in BPM
|
|
89
|
+
* @returns { bins: center BPM of each bin, counts: weighted counts }
|
|
90
|
+
*/
|
|
91
|
+
function buildBpmHistogram(
|
|
92
|
+
iois: IOI[],
|
|
93
|
+
minBpm: number,
|
|
94
|
+
maxBpm: number,
|
|
95
|
+
binSizeBpm: number
|
|
96
|
+
): { bpmBins: Float32Array; counts: Float32Array } {
|
|
97
|
+
const numBins = Math.ceil((maxBpm - minBpm) / binSizeBpm);
|
|
98
|
+
const counts = new Float32Array(numBins);
|
|
99
|
+
const bpmBins = new Float32Array(numBins);
|
|
100
|
+
|
|
101
|
+
// Initialize bin centers
|
|
102
|
+
for (let i = 0; i < numBins; i++) {
|
|
103
|
+
bpmBins[i] = minBpm + (i + 0.5) * binSizeBpm;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Convert interval range to BPM range
|
|
107
|
+
const minInterval = bpmToInterval(maxBpm);
|
|
108
|
+
const maxInterval = bpmToInterval(minBpm);
|
|
109
|
+
|
|
110
|
+
for (const { intervalSec, weight } of iois) {
|
|
111
|
+
// Filter to plausible range
|
|
112
|
+
if (intervalSec < minInterval || intervalSec > maxInterval) continue;
|
|
113
|
+
|
|
114
|
+
const bpm = intervalToBpm(intervalSec);
|
|
115
|
+
const binIndex = Math.floor((bpm - minBpm) / binSizeBpm);
|
|
116
|
+
|
|
117
|
+
if (binIndex >= 0 && binIndex < numBins) {
|
|
118
|
+
counts[binIndex] = (counts[binIndex] ?? 0) + weight;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { bpmBins, counts };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Find peaks in the histogram using local maximum detection.
|
|
127
|
+
*
|
|
128
|
+
* @param counts - Weighted counts per bin
|
|
129
|
+
* @param minHeight - Minimum peak height (absolute)
|
|
130
|
+
* @returns Array of peak indices sorted by height descending
|
|
131
|
+
*/
|
|
132
|
+
function findHistogramPeaks(counts: Float32Array, minHeight: number): number[] {
|
|
133
|
+
const peaks: Array<{ index: number; height: number }> = [];
|
|
134
|
+
|
|
135
|
+
for (let i = 1; i < counts.length - 1; i++) {
|
|
136
|
+
const curr = counts[i]!;
|
|
137
|
+
const prev = counts[i - 1]!;
|
|
138
|
+
const next = counts[i + 1]!;
|
|
139
|
+
|
|
140
|
+
// Local maximum
|
|
141
|
+
if (curr > prev && curr > next && curr >= minHeight) {
|
|
142
|
+
peaks.push({ index: i, height: curr });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Also check boundary bins if they're high enough
|
|
147
|
+
if (counts.length > 0 && counts[0]! >= minHeight && counts[0]! > (counts[1] ?? 0)) {
|
|
148
|
+
peaks.push({ index: 0, height: counts[0]! });
|
|
149
|
+
}
|
|
150
|
+
if (counts.length > 1) {
|
|
151
|
+
const last = counts.length - 1;
|
|
152
|
+
if (counts[last]! >= minHeight && counts[last]! > (counts[last - 1] ?? 0)) {
|
|
153
|
+
peaks.push({ index: last, height: counts[last]! });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Sort by height descending
|
|
158
|
+
peaks.sort((a, b) => b.height - a.height);
|
|
159
|
+
|
|
160
|
+
return peaks.map((p) => p.index);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Merge adjacent peak bins to get refined BPM estimate.
|
|
165
|
+
* Uses weighted centroid of adjacent bins.
|
|
166
|
+
*/
|
|
167
|
+
function refinePeakBpm(
|
|
168
|
+
peakIndex: number,
|
|
169
|
+
bpmBins: Float32Array,
|
|
170
|
+
counts: Float32Array,
|
|
171
|
+
binSizeBpm: number
|
|
172
|
+
): { bpm: number; peakHeight: number; binRange: [number, number]; totalWeight: number } {
|
|
173
|
+
// Consider the peak bin and immediate neighbors
|
|
174
|
+
let totalWeight = 0;
|
|
175
|
+
let weightedBpm = 0;
|
|
176
|
+
let minBinBpm = bpmBins[peakIndex]! - binSizeBpm / 2;
|
|
177
|
+
let maxBinBpm = bpmBins[peakIndex]! + binSizeBpm / 2;
|
|
178
|
+
|
|
179
|
+
for (let offset = -1; offset <= 1; offset++) {
|
|
180
|
+
const idx = peakIndex + offset;
|
|
181
|
+
if (idx < 0 || idx >= bpmBins.length) continue;
|
|
182
|
+
|
|
183
|
+
const w = counts[idx]!;
|
|
184
|
+
const bpm = bpmBins[idx]!;
|
|
185
|
+
|
|
186
|
+
totalWeight += w;
|
|
187
|
+
weightedBpm += w * bpm;
|
|
188
|
+
|
|
189
|
+
if (w > 0) {
|
|
190
|
+
minBinBpm = Math.min(minBinBpm, bpm - binSizeBpm / 2);
|
|
191
|
+
maxBinBpm = Math.max(maxBinBpm, bpm + binSizeBpm / 2);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const refinedBpm = totalWeight > 0 ? weightedBpm / totalWeight : bpmBins[peakIndex]!;
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
bpm: refinedBpm,
|
|
199
|
+
peakHeight: counts[peakIndex]!,
|
|
200
|
+
binRange: [minBinBpm, maxBinBpm],
|
|
201
|
+
totalWeight,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Check if two BPMs are harmonically related (within tolerance).
|
|
207
|
+
* Returns the harmonic ratio if related, null otherwise.
|
|
208
|
+
*/
|
|
209
|
+
function getHarmonicRatio(bpm1: number, bpm2: number, tolerance: number = 0.03): number | null {
|
|
210
|
+
const ratios = [0.5, 1 / 3, 2 / 3, 1.0, 1.5, 2.0, 3.0];
|
|
211
|
+
|
|
212
|
+
for (const ratio of ratios) {
|
|
213
|
+
const expected = bpm1 * ratio;
|
|
214
|
+
const relativeError = Math.abs(bpm2 - expected) / expected;
|
|
215
|
+
if (relativeError <= tolerance) {
|
|
216
|
+
return ratio;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Group hypotheses into harmonic families.
|
|
225
|
+
* Assigns familyId and harmonicRatio to each hypothesis.
|
|
226
|
+
*
|
|
227
|
+
* Uses deterministic family IDs based on the root BPM.
|
|
228
|
+
*/
|
|
229
|
+
function assignHarmonicFamilies(hypotheses: TempoHypothesis[]): void {
|
|
230
|
+
if (hypotheses.length === 0) return;
|
|
231
|
+
|
|
232
|
+
const families: Map<string, { rootBpm: number; members: TempoHypothesis[] }> = new Map();
|
|
233
|
+
|
|
234
|
+
for (const hyp of hypotheses) {
|
|
235
|
+
let foundFamily = false;
|
|
236
|
+
|
|
237
|
+
for (const [familyId, family] of families) {
|
|
238
|
+
const ratio = getHarmonicRatio(family.rootBpm, hyp.bpm);
|
|
239
|
+
if (ratio !== null) {
|
|
240
|
+
hyp.familyId = familyId;
|
|
241
|
+
hyp.harmonicRatio = ratio;
|
|
242
|
+
family.members.push(hyp);
|
|
243
|
+
foundFamily = true;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!foundFamily) {
|
|
249
|
+
// Create new family with this hypothesis as root
|
|
250
|
+
// Use deterministic family ID based on root BPM
|
|
251
|
+
const familyId = `fam-${Math.round(hyp.bpm)}`;
|
|
252
|
+
hyp.familyId = familyId;
|
|
253
|
+
hyp.harmonicRatio = 1.0;
|
|
254
|
+
families.set(familyId, { rootBpm: hyp.bpm, members: [hyp] });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Normalize confidence scores to [0, 1] range.
|
|
261
|
+
*/
|
|
262
|
+
function normalizeConfidence(hypotheses: TempoHypothesis[]): void {
|
|
263
|
+
if (hypotheses.length === 0) return;
|
|
264
|
+
|
|
265
|
+
const maxHeight = Math.max(...hypotheses.map((h) => h.evidence.peakHeight));
|
|
266
|
+
if (maxHeight <= 0) return;
|
|
267
|
+
|
|
268
|
+
for (const hyp of hypotheses) {
|
|
269
|
+
hyp.confidence = hyp.evidence.peakHeight / maxHeight;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Generate tempo hypotheses from beat candidates.
|
|
275
|
+
*
|
|
276
|
+
* Algorithm:
|
|
277
|
+
* 1. Compute inter-onset intervals (IOIs) from beat candidates
|
|
278
|
+
* 2. Filter IOIs to musically plausible range (0.2s-2.5s -> 24-300 BPM)
|
|
279
|
+
* 3. Build weighted histogram with configurable bin size
|
|
280
|
+
* 4. Extract peaks as tempo candidates
|
|
281
|
+
* 5. Refine BPM estimates using weighted centroid
|
|
282
|
+
* 6. Group into harmonic families
|
|
283
|
+
* 7. Normalize confidence scores
|
|
284
|
+
*
|
|
285
|
+
* @param candidates - Beat candidates from B1
|
|
286
|
+
* @param options - Configuration options
|
|
287
|
+
* @returns Tempo hypotheses with confidence and family groupings
|
|
288
|
+
*/
|
|
289
|
+
export function generateTempoHypotheses(
|
|
290
|
+
candidates: BeatCandidate[],
|
|
291
|
+
options?: TempoHypothesesOptions
|
|
292
|
+
): TempoHypothesesOutput {
|
|
293
|
+
const minBpm = options?.minBpm ?? 24;
|
|
294
|
+
const maxBpm = options?.maxBpm ?? 300;
|
|
295
|
+
const binSizeBpm = options?.binSizeBpm ?? 1.0;
|
|
296
|
+
const maxHypotheses = options?.maxHypotheses ?? 10;
|
|
297
|
+
const minConfidence = options?.minConfidence ?? 0.05;
|
|
298
|
+
const weightByStrength = options?.weightByStrength ?? true;
|
|
299
|
+
const includeHistogram = options?.includeHistogram ?? false;
|
|
300
|
+
|
|
301
|
+
// Early return if insufficient candidates
|
|
302
|
+
if (candidates.length < 2) {
|
|
303
|
+
return {
|
|
304
|
+
hypotheses: [],
|
|
305
|
+
inputCandidateCount: candidates.length,
|
|
306
|
+
histogram: includeHistogram
|
|
307
|
+
? {
|
|
308
|
+
bpmBins: new Float32Array(0),
|
|
309
|
+
counts: new Float32Array(0),
|
|
310
|
+
}
|
|
311
|
+
: undefined,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Step 1: Compute IOIs
|
|
316
|
+
const iois = computeIOIs(candidates, weightByStrength);
|
|
317
|
+
|
|
318
|
+
if (iois.length === 0) {
|
|
319
|
+
return {
|
|
320
|
+
hypotheses: [],
|
|
321
|
+
inputCandidateCount: candidates.length,
|
|
322
|
+
histogram: includeHistogram
|
|
323
|
+
? {
|
|
324
|
+
bpmBins: new Float32Array(0),
|
|
325
|
+
counts: new Float32Array(0),
|
|
326
|
+
}
|
|
327
|
+
: undefined,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Step 2-3: Build histogram (filtering happens during binning)
|
|
332
|
+
const { bpmBins, counts } = buildBpmHistogram(iois, minBpm, maxBpm, binSizeBpm);
|
|
333
|
+
|
|
334
|
+
// Calculate minimum height threshold based on minConfidence
|
|
335
|
+
const maxCount = Math.max(...counts);
|
|
336
|
+
const minHeight = maxCount * minConfidence;
|
|
337
|
+
|
|
338
|
+
// Step 4: Find peaks
|
|
339
|
+
const peakIndices = findHistogramPeaks(counts, minHeight);
|
|
340
|
+
|
|
341
|
+
// Step 5: Create hypotheses with refined BPM
|
|
342
|
+
const hypotheses: TempoHypothesis[] = [];
|
|
343
|
+
|
|
344
|
+
for (const peakIndex of peakIndices.slice(0, maxHypotheses * 2)) {
|
|
345
|
+
// Get extra for filtering
|
|
346
|
+
const { bpm, peakHeight, binRange, totalWeight } = refinePeakBpm(
|
|
347
|
+
peakIndex,
|
|
348
|
+
bpmBins,
|
|
349
|
+
counts,
|
|
350
|
+
binSizeBpm
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// Skip if below confidence threshold
|
|
354
|
+
if (maxCount > 0 && peakHeight / maxCount < minConfidence) continue;
|
|
355
|
+
|
|
356
|
+
const evidence: TempoHypothesisEvidence = {
|
|
357
|
+
supportingIntervalCount: Math.round(totalWeight),
|
|
358
|
+
weightedSupport: totalWeight,
|
|
359
|
+
peakHeight,
|
|
360
|
+
binRange,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
hypotheses.push({
|
|
364
|
+
id: "", // Will be assigned after sorting
|
|
365
|
+
bpm: Math.round(bpm * 10) / 10, // Round to 0.1 BPM precision
|
|
366
|
+
confidence: 0, // Will be normalized
|
|
367
|
+
evidence,
|
|
368
|
+
familyId: "", // Will be assigned
|
|
369
|
+
harmonicRatio: 1.0, // Will be assigned
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Step 6: Group into harmonic families
|
|
374
|
+
assignHarmonicFamilies(hypotheses);
|
|
375
|
+
|
|
376
|
+
// Step 7: Normalize confidence
|
|
377
|
+
normalizeConfidence(hypotheses);
|
|
378
|
+
|
|
379
|
+
// Filter by minConfidence and sort by confidence descending
|
|
380
|
+
const filtered = hypotheses
|
|
381
|
+
.filter((h) => h.confidence >= minConfidence)
|
|
382
|
+
.sort((a, b) => b.confidence - a.confidence)
|
|
383
|
+
.slice(0, maxHypotheses);
|
|
384
|
+
|
|
385
|
+
// Assign deterministic IDs based on rank
|
|
386
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
387
|
+
filtered[i]!.id = `hyp-${i}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
hypotheses: filtered,
|
|
392
|
+
inputCandidateCount: candidates.length,
|
|
393
|
+
histogram: includeHistogram ? { bpmBins, counts } : undefined,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GPU Buffer Pool for efficient buffer reuse across MIR computations.
|
|
3
|
+
*
|
|
4
|
+
* Reduces allocation overhead by maintaining pools of buffers grouped by
|
|
5
|
+
* size and usage flags. Buffers are acquired for operations and released
|
|
6
|
+
* back to the pool for reuse.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface BufferPoolEntry {
|
|
10
|
+
buffer: GPUBuffer;
|
|
11
|
+
size: number;
|
|
12
|
+
usage: number;
|
|
13
|
+
lastUsed: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BufferPoolStats {
|
|
17
|
+
totalBuffers: number;
|
|
18
|
+
activeBuffers: number;
|
|
19
|
+
availableBuffers: number;
|
|
20
|
+
poolsByUsage: Map<number, number>;
|
|
21
|
+
totalMemoryBytes: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Buffer pool configuration options.
|
|
26
|
+
*/
|
|
27
|
+
export interface BufferPoolOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Maximum number of buffers to keep in the pool per usage type.
|
|
30
|
+
* Default: 32
|
|
31
|
+
*/
|
|
32
|
+
maxBuffersPerUsage?: number;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Time in milliseconds before an unused buffer is destroyed.
|
|
36
|
+
* Default: 60000 (1 minute)
|
|
37
|
+
*/
|
|
38
|
+
bufferTTLMs?: number;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether to enable automatic cleanup of old buffers.
|
|
42
|
+
* Default: true
|
|
43
|
+
*/
|
|
44
|
+
enableAutoCleanup?: boolean;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Interval in milliseconds for automatic cleanup.
|
|
48
|
+
* Default: 30000 (30 seconds)
|
|
49
|
+
*/
|
|
50
|
+
cleanupIntervalMs?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DEFAULT_OPTIONS: Required<BufferPoolOptions> = {
|
|
54
|
+
maxBuffersPerUsage: 32,
|
|
55
|
+
bufferTTLMs: 60000,
|
|
56
|
+
enableAutoCleanup: true,
|
|
57
|
+
cleanupIntervalMs: 30000,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Key for indexing buffers by size and usage.
|
|
62
|
+
*/
|
|
63
|
+
function makePoolKey(size: number, usage: number): string {
|
|
64
|
+
return `${size}-${usage}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* BufferPool manages reusable GPU buffers to reduce allocation overhead.
|
|
69
|
+
*
|
|
70
|
+
* Usage:
|
|
71
|
+
* ```ts
|
|
72
|
+
* const pool = new BufferPool(gpu.device);
|
|
73
|
+
* const buffer = pool.acquire(1024, GPUBufferUsage.STORAGE);
|
|
74
|
+
* // ... use buffer ...
|
|
75
|
+
* pool.release(buffer);
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export class BufferPool {
|
|
79
|
+
private device: GPUDevice;
|
|
80
|
+
private options: Required<BufferPoolOptions>;
|
|
81
|
+
|
|
82
|
+
// Available buffers indexed by size+usage key
|
|
83
|
+
private availableBuffers = new Map<string, BufferPoolEntry[]>();
|
|
84
|
+
|
|
85
|
+
// Currently in-use buffers (for tracking and stats)
|
|
86
|
+
private activeBuffers = new Set<GPUBuffer>();
|
|
87
|
+
|
|
88
|
+
// Cleanup interval handle
|
|
89
|
+
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
|
90
|
+
|
|
91
|
+
constructor(device: GPUDevice, options: BufferPoolOptions = {}) {
|
|
92
|
+
this.device = device;
|
|
93
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
94
|
+
|
|
95
|
+
if (this.options.enableAutoCleanup) {
|
|
96
|
+
this.startAutoCleanup();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Acquire a buffer from the pool or create a new one if none available.
|
|
102
|
+
*
|
|
103
|
+
* @param size - Byte size of the buffer
|
|
104
|
+
* @param usage - Buffer usage flags (e.g. GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST)
|
|
105
|
+
* @returns A GPUBuffer ready for use
|
|
106
|
+
*/
|
|
107
|
+
acquire(size: number, usage: number): GPUBuffer {
|
|
108
|
+
const key = makePoolKey(size, usage);
|
|
109
|
+
const pool = this.availableBuffers.get(key);
|
|
110
|
+
|
|
111
|
+
// Try to reuse an existing buffer
|
|
112
|
+
if (pool && pool.length > 0) {
|
|
113
|
+
const entry = pool.pop()!;
|
|
114
|
+
this.activeBuffers.add(entry.buffer);
|
|
115
|
+
return entry.buffer;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// No buffer available, create a new one
|
|
119
|
+
const buffer = this.device.createBuffer({ size, usage });
|
|
120
|
+
this.activeBuffers.add(buffer);
|
|
121
|
+
return buffer;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Release a buffer back to the pool for reuse.
|
|
126
|
+
*
|
|
127
|
+
* @param buffer - The buffer to release
|
|
128
|
+
* @param size - Original size of the buffer
|
|
129
|
+
* @param usage - Original usage flags of the buffer
|
|
130
|
+
*/
|
|
131
|
+
release(buffer: GPUBuffer, size: number, usage: number): void {
|
|
132
|
+
if (!this.activeBuffers.has(buffer)) {
|
|
133
|
+
console.warn("@octoseq/mir: Attempting to release buffer not tracked by pool");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.activeBuffers.delete(buffer);
|
|
138
|
+
|
|
139
|
+
const key = makePoolKey(size, usage);
|
|
140
|
+
let pool = this.availableBuffers.get(key);
|
|
141
|
+
|
|
142
|
+
if (!pool) {
|
|
143
|
+
pool = [];
|
|
144
|
+
this.availableBuffers.set(key, pool);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check if pool is full
|
|
148
|
+
if (pool.length >= this.options.maxBuffersPerUsage) {
|
|
149
|
+
// Pool is full, destroy the buffer instead of pooling it
|
|
150
|
+
buffer.destroy();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Add to pool for reuse
|
|
155
|
+
pool.push({
|
|
156
|
+
buffer,
|
|
157
|
+
size,
|
|
158
|
+
usage,
|
|
159
|
+
lastUsed: performance.now(),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Clean up old unused buffers that exceed the TTL.
|
|
165
|
+
*/
|
|
166
|
+
cleanup(): void {
|
|
167
|
+
const now = performance.now();
|
|
168
|
+
const ttl = this.options.bufferTTLMs;
|
|
169
|
+
|
|
170
|
+
for (const [key, pool] of this.availableBuffers.entries()) {
|
|
171
|
+
const remaining: BufferPoolEntry[] = [];
|
|
172
|
+
|
|
173
|
+
for (const entry of pool) {
|
|
174
|
+
if (now - entry.lastUsed > ttl) {
|
|
175
|
+
// Buffer is too old, destroy it
|
|
176
|
+
entry.buffer.destroy();
|
|
177
|
+
} else {
|
|
178
|
+
// Keep this buffer
|
|
179
|
+
remaining.push(entry);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (remaining.length === 0) {
|
|
184
|
+
this.availableBuffers.delete(key);
|
|
185
|
+
} else {
|
|
186
|
+
this.availableBuffers.set(key, remaining);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Destroy all pooled buffers and clear the pool.
|
|
193
|
+
*/
|
|
194
|
+
clear(): void {
|
|
195
|
+
// Destroy all available buffers
|
|
196
|
+
for (const pool of this.availableBuffers.values()) {
|
|
197
|
+
for (const entry of pool) {
|
|
198
|
+
entry.buffer.destroy();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
this.availableBuffers.clear();
|
|
203
|
+
|
|
204
|
+
// Note: We don't destroy active buffers as they're still in use
|
|
205
|
+
// Callers are responsible for releasing them properly
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get statistics about the buffer pool.
|
|
210
|
+
*/
|
|
211
|
+
getStats(): BufferPoolStats {
|
|
212
|
+
let totalBuffers = this.activeBuffers.size;
|
|
213
|
+
let totalMemoryBytes = 0;
|
|
214
|
+
const poolsByUsage = new Map<number, number>();
|
|
215
|
+
|
|
216
|
+
for (const pool of this.availableBuffers.values()) {
|
|
217
|
+
totalBuffers += pool.length;
|
|
218
|
+
|
|
219
|
+
for (const entry of pool) {
|
|
220
|
+
totalMemoryBytes += entry.size;
|
|
221
|
+
|
|
222
|
+
const count = poolsByUsage.get(entry.usage) || 0;
|
|
223
|
+
poolsByUsage.set(entry.usage, count + 1);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
totalBuffers,
|
|
229
|
+
activeBuffers: this.activeBuffers.size,
|
|
230
|
+
availableBuffers: totalBuffers - this.activeBuffers.size,
|
|
231
|
+
poolsByUsage,
|
|
232
|
+
totalMemoryBytes,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Start automatic cleanup of old buffers.
|
|
238
|
+
*/
|
|
239
|
+
private startAutoCleanup(): void {
|
|
240
|
+
if (this.cleanupInterval !== null) {
|
|
241
|
+
return; // Already running
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.cleanupInterval = setInterval(() => {
|
|
245
|
+
this.cleanup();
|
|
246
|
+
}, this.options.cleanupIntervalMs);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Stop automatic cleanup.
|
|
251
|
+
*/
|
|
252
|
+
stopAutoCleanup(): void {
|
|
253
|
+
if (this.cleanupInterval !== null) {
|
|
254
|
+
clearInterval(this.cleanupInterval);
|
|
255
|
+
this.cleanupInterval = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Dispose of the buffer pool and destroy all buffers.
|
|
261
|
+
*/
|
|
262
|
+
dispose(): void {
|
|
263
|
+
this.stopAutoCleanup();
|
|
264
|
+
this.clear();
|
|
265
|
+
}
|
|
266
|
+
}
|
package/src/gpu/context.ts
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
|
+
import { BufferPool, type BufferPoolOptions, type BufferPoolStats } from "./bufferPool";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* WebGPU context wrapper for MIR computations.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
* - Provide a safe, explicit way for callers to opt into GPU usage.
|
|
6
|
-
* - Throw a clear error when called outside the browser or when WebGPU is unavailable.
|
|
6
|
+
* Includes buffer pooling for efficient GPU memory reuse across operations.
|
|
7
7
|
*/
|
|
8
8
|
export class MirGPU {
|
|
9
9
|
public readonly device: GPUDevice;
|
|
10
10
|
public readonly queue: GPUQueue;
|
|
11
|
+
public readonly bufferPool: BufferPool;
|
|
11
12
|
|
|
12
|
-
private constructor(device: GPUDevice) {
|
|
13
|
+
private constructor(device: GPUDevice, bufferPoolOptions?: BufferPoolOptions) {
|
|
13
14
|
this.device = device;
|
|
14
15
|
this.queue = device.queue;
|
|
16
|
+
this.bufferPool = new BufferPool(device, bufferPoolOptions);
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
static async create(): Promise<MirGPU> {
|
|
19
|
+
static async create(bufferPoolOptions?: BufferPoolOptions): Promise<MirGPU> {
|
|
18
20
|
// Next.js note: callers must create MirGPU from a client component.
|
|
19
21
|
if (typeof navigator === "undefined") {
|
|
20
22
|
throw new Error(
|
|
@@ -39,6 +41,28 @@ export class MirGPU {
|
|
|
39
41
|
// We keep this minimal: no required features for v0.1.
|
|
40
42
|
const device = await adapter.requestDevice();
|
|
41
43
|
|
|
42
|
-
return new MirGPU(device);
|
|
44
|
+
return new MirGPU(device, bufferPoolOptions);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get statistics about buffer pool usage.
|
|
49
|
+
*/
|
|
50
|
+
getBufferPoolStats(): BufferPoolStats {
|
|
51
|
+
return this.bufferPool.getStats();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Manually trigger buffer pool cleanup (normally runs automatically).
|
|
56
|
+
*/
|
|
57
|
+
cleanupBufferPool(): void {
|
|
58
|
+
this.bufferPool.cleanup();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Dispose of the GPU context and clean up all resources.
|
|
63
|
+
*/
|
|
64
|
+
dispose(): void {
|
|
65
|
+
this.bufferPool.dispose();
|
|
66
|
+
// Note: GPUDevice doesn't have a dispose method, but we clean up our resources
|
|
43
67
|
}
|
|
44
68
|
}
|